at main 1379 lines 32 kB view raw
1use crate::theme::{ResolvedTheme, ThemeDarkCodeTheme, ThemeLightCodeTheme}; 2use miette::IntoDiagnostic; 3use smol_str::format_smolstr; 4use std::io::Cursor; 5use syntect::highlighting::ThemeSet; 6use syntect::html::{ClassStyle, css_for_theme_with_class_style}; 7use weaver_api::com_atproto::sync::get_blob::GetBlob; 8use weaver_api::sh_weaver::notebook::theme::FontValue; 9use weaver_common::jacquard::client::BasicClient; 10use weaver_common::jacquard::prelude::*; 11use weaver_common::jacquard::xrpc::XrpcExt; 12 13// Embed rose-pine themes at compile time 14const ROSE_PINE_THEME: &str = include_str!("../themes/rose-pine.tmTheme"); 15const ROSE_PINE_DAWN_THEME: &str = include_str!("../themes/rose-pine-dawn.tmTheme"); 16 17pub fn generate_base_css(theme: &ResolvedTheme) -> String { 18 let dark = &theme.dark_scheme; 19 let light = &theme.light_scheme; 20 let fonts = &theme.fonts; 21 let spacing = &theme.spacing; 22 23 // interim until handle fonts from blobs 24 let body = fonts 25 .body 26 .iter() 27 .filter_map(|f| match &f.value { 28 FontValue::FontName(cow_str) => Some(format_smolstr!("'{cow_str}'")), 29 FontValue::FontFile(_font_file) => None, 30 FontValue::Unknown(_data) => None, 31 }) 32 .collect::<Vec<_>>() 33 .join(","); 34 let monospace = fonts 35 .monospace 36 .iter() 37 .filter_map(|f| match &f.value { 38 FontValue::FontName(cow_str) => Some(format_smolstr!("'{cow_str}'")), 39 FontValue::FontFile(_font_file) => None, 40 FontValue::Unknown(_data) => None, 41 }) 42 .collect::<Vec<_>>() 43 .join(","); 44 let heading = fonts 45 .heading 46 .iter() 47 .filter_map(|f| match &f.value { 48 FontValue::FontName(cow_str) => Some(format_smolstr!("'{cow_str}'")), 49 FontValue::FontFile(_font_file) => None, 50 FontValue::Unknown(_data) => None, 51 }) 52 .collect::<Vec<_>>() 53 .join(","); 54 55 format!( 56 r#"/* CSS Reset */ 57*, *::before, *::after {{ 58 box-sizing: border-box; 59 margin: 0; 60 padding: 0; 61}} 62 63/* CSS Variables - Light Mode (default) */ 64:root {{ 65 --color-base: {}; 66 --color-surface: {}; 67 --color-overlay: {}; 68 --color-text: {}; 69 --color-muted: {}; 70 --color-subtle: {}; 71 --color-emphasis: {}; 72 --color-primary: {}; 73 --color-secondary: {}; 74 --color-tertiary: {}; 75 --color-error: {}; 76 --color-warning: {}; 77 --color-success: {}; 78 --color-border: {}; 79 --color-link: {}; 80 --color-highlight: {}; 81 82 --font-body: {}; 83 --font-heading: {}; 84 --font-mono: {}; 85 86 --spacing-base: {}; 87 --spacing-line-height: {}; 88 --spacing-scale: {}; 89}} 90 91/* CSS Variables - Dark Mode */ 92@media (prefers-color-scheme: dark) {{ 93 :root {{ 94 --color-base: {}; 95 --color-surface: {}; 96 --color-overlay: {}; 97 --color-text: {}; 98 --color-muted: {}; 99 --color-subtle: {}; 100 --color-emphasis: {}; 101 --color-primary: {}; 102 --color-secondary: {}; 103 --color-tertiary: {}; 104 --color-error: {}; 105 --color-warning: {}; 106 --color-success: {}; 107 --color-border: {}; 108 --color-link: {}; 109 --color-highlight: {}; 110 }} 111}} 112 113/* Base Styles */ 114html {{ 115 font-size: var(--spacing-base); 116 line-height: var(--spacing-line-height); 117}} 118 119/* Scoped to notebook-content container */ 120.notebook-content {{ 121 font-family: var(--font-body); 122 color: var(--color-text); 123 background-color: var(--color-base); 124 margin: 0 auto; 125 padding: 1rem 0rem; 126 word-wrap: break-word; 127 overflow-wrap: break-word; 128 counter-reset: sidenote-counter; 129 max-width: 95ch; 130}} 131 132/* When sidenotes exist, body padding creates the gutter */ 133/* Left padding shrinks first as viewport narrows, right stays for sidenotes */ 134body:has(.sidenote) {{ 135 padding-inline-start: clamp(1rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem); 136 padding-inline-end: 15.5rem; 137}} 138 139/* Typography */ 140h1, h2, h3, h4, h5, h6 {{ 141 font-family: var(--font-heading); 142 margin-top: calc(1rem * var(--spacing-scale)); 143 margin-bottom: 0.5rem; 144 line-height: 1.2; 145}} 146 147h1 {{ 148 font-size: 2rem; 149 color: var(--color-secondary); 150}} 151h2 {{ 152 font-size: 1.5rem; 153 color: var(--color-primary); 154}} 155h3 {{ 156 font-size: 1.25rem; 157 color: var(--color-secondary); 158}} 159h4 {{ 160 font-size: 1.2rem; 161 color: var(--color-tertiary); 162}} 163h5 {{ 164 font-size: 1.125rem; 165 color: var(--color-secondary); 166}} 167h6 {{ font-size: 1rem; }} 168 169p {{ 170 margin-bottom: 1rem; 171 word-wrap: break-word; 172 overflow-wrap: break-word; 173}} 174 175a {{ 176 color: var(--color-link); 177 text-decoration: none; 178}} 179 180.notebook-content a:hover {{ 181 color: var(--color-emphasis); 182 text-decoration: underline; 183}} 184 185/* Wikilink validation (editor) */ 186.link-valid {{ 187 color: var(--color-link); 188}} 189 190.link-broken {{ 191 color: var(--color-error); 192 text-decoration: underline wavy; 193 text-decoration-color: var(--color-error); 194 opacity: 0.8; 195}} 196 197/* Selection */ 198::selection {{ 199 background: var(--color-highlight); 200 color: var(--color-text); 201}} 202 203/* Lists */ 204ul, ol {{ 205 margin-inline-start: 1rem; 206 margin-bottom: 1rem; 207}} 208 209li {{ 210 margin-bottom: 0.25rem; 211}} 212 213/* Code */ 214code {{ 215 font-family: var(--font-mono); 216 background: var(--color-surface); 217 padding: 0.125rem 0.25rem; 218 border-radius: 4px; 219 font-size: 0.9em; 220}} 221 222pre {{ 223 overflow-x: auto; 224 margin-bottom: 1rem; 225 border-radius: 5px; 226 border: 1px solid var(--color-border); 227 box-sizing: border-box; 228}} 229 230/* Code blocks inside pre are handled by syntax theme */ 231pre code {{ 232 233 display: block; 234 width: fit-content; 235 min-width: 100%; 236 padding: 1rem; 237 background: var(--color-surface); 238}} 239 240/* Math */ 241.math {{ 242 font-family: var(--font-mono); 243}} 244 245.math-display {{ 246 display: block; 247 margin: 1rem 0; 248 text-align: center; 249}} 250 251/* Blockquotes */ 252blockquote {{ 253 border-inline-start: 2px solid var(--color-secondary); 254 background: var(--color-surface); 255 padding-inline-start: 1rem; 256 padding-inline-end: 1rem; 257 padding-top: 0.5rem; 258 padding-bottom: 0.04rem; 259 margin: 1rem 0; 260 font-size: 0.95em; 261 border-bottom-right-radius: 5px; 262 border-top-right-radius: 5px; 263}} 264 265/* Tables */ 266table {{ 267 border-collapse: collapse; 268 width: 100%; 269 margin-bottom: 1rem; 270 display: block; 271 overflow-x: auto; 272 max-width: 100%; 273}} 274 275th, td {{ 276 border: 1px solid var(--color-border); 277 padding: 0.5rem; 278 text-align: start; 279}} 280 281th {{ 282 background: var(--color-surface); 283 font-weight: 600; 284}} 285 286tr:hover {{ 287 background: var(--color-surface); 288}} 289 290/* Footnotes */ 291.footnote-reference {{ 292 font-size: 0.8em; 293 color: var(--color-subtle); 294}} 295 296.footnote-definition {{ 297 order: 9999; 298 margin: 0; 299 padding: 0.5rem 0; 300 font-size: 0.9em; 301}} 302 303.footnote-definition:first-of-type {{ 304 margin-top: 2rem; 305 padding-top: 1rem; 306 border-top: 2px solid var(--color-border); 307}} 308 309.footnote-definition:first-of-type::before {{ 310 content: "Footnotes"; 311 display: block; 312 font-weight: 600; 313 font-size: 1.1em; 314 color: var(--color-subtle); 315 margin-bottom: 0.75rem; 316}} 317 318.footnote-definition-label {{ 319 font-weight: 600; 320 margin-inline-end: 0.5rem; 321 color: var(--color-primary); 322}} 323 324/* Aside blocks (via WeaverBlock prefix) - scoped to notebook content */ 325.notebook-content aside, 326.notebook-content .aside {{ 327 float: inline-start; 328 width: 40%; 329 margin: 0 1.5rem 1rem 0; 330 padding: 1rem; 331 background: var(--color-surface); 332 border-inline-end: 3px solid var(--color-primary); 333 font-size: 0.9em; 334 clear: inline-start; 335}} 336 337.notebook-content aside > *:first-child, 338.notebook-content .aside > *:first-child {{ 339 margin-top: 0; 340}} 341 342.notebook-content aside > *:last-child, 343.notebook-content .aside > *:last-child {{ 344 margin-bottom: 0; 345}} 346 347/* Reset blockquote styling inside asides */ 348.notebook-content aside > blockquote, 349.notebook-content .aside > blockquote {{ 350 border-inline-start: none; 351 background: transparent; 352 padding: 0; 353 margin: 0; 354 font-size: inherit; 355}} 356 357/* Indent utilities */ 358.indent-1 {{ margin-inline-start: 1em; }} 359.indent-2 {{ margin-inline-start: 2em; }} 360.indent-3 {{ margin-inline-start: 3em; }} 361 362/* Tufte-style Sidenotes */ 363/* Hide checkbox for sidenote toggle */ 364.margin-toggle {{ 365 display: none; 366}} 367 368/* Sidenote number marker (inline superscript) */ 369.sidenote-number {{ 370 counter-increment: sidenote-counter; 371}} 372 373.sidenote-number::after {{ 374 content: counter(sidenote-counter); 375 font-size: 0.7em; 376 position: relative; 377 top: -0.5em; 378 color: var(--color-primary); 379 padding-inline-start: 0.1em; 380}} 381 382/* Sidenote content (margin notes on wide screens) */ 383.sidenote {{ 384 float: inline-end; 385 clear: inline-end; 386 margin-inline-end: -15.5rem; 387 width: 14rem; 388 margin-top: 0.3rem; 389 margin-bottom: 1rem; 390 font-size: 0.85em; 391 line-height: 1.4; 392 color: var(--color-subtle); 393}} 394 395.sidenote::before {{ 396 content: counter(sidenote-counter) ". "; 397 color: var(--color-primary); 398}} 399 400/* Mobile sidenotes: toggle behavior */ 401@media (max-width: 900px) {{ 402 /* Reset sidenote gutter on mobile */ 403 body:has(.sidenote) {{ 404 padding-inline-end: 0; 405 }} 406 407 aside, .aside {{ 408 float: none; 409 width: 100%; 410 margin: 1rem 0; 411 }} 412 413 .sidenote {{ 414 display: none; 415 }} 416 417 .margin-toggle:checked + .sidenote {{ 418 display: block; 419 float: none; 420 width: 95%; 421 margin: 0.5rem 2.5%; 422 padding: 0.5rem; 423 background: var(--color-surface); 424 border-inline-start: 2px solid var(--color-primary); 425 }} 426 427 label.sidenote-number {{ 428 cursor: pointer; 429 }} 430 431 label.sidenote-number::after {{ 432 text-decoration: underline; 433 }} 434}} 435 436/* Images */ 437img {{ 438 max-width: 100%; 439 height: auto; 440 display: block; 441 margin: 1rem 0; 442 border-radius: 4px; 443}} 444 445/* Hygiene for iframes */ 446.html-embed-block {{ 447 max-width: 100%; 448 height: auto; 449 display: block; 450 margin: 1rem 0; 451}} 452 453/* AT Protocol Embeds - Container */ 454/* Light mode: paper with shadow, dark mode: blueprint with borders */ 455.atproto-embed {{ 456 display: block; 457 position: relative; 458 max-width: 550px; 459 margin: 1rem 0; 460 padding: 1rem; 461 background: var(--color-surface); 462 border-inline-start: 2px solid var(--color-secondary); 463 box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 8%, transparent); 464}} 465 466.atproto-embed:hover {{ 467 border-inline-start-color: var(--color-primary); 468}} 469 470@media (prefers-color-scheme: dark) {{ 471 .atproto-embed {{ 472 box-shadow: none; 473 border: 1px solid var(--color-border); 474 border-inline-start: 2px solid var(--color-secondary); 475 }} 476}} 477 478.atproto-embed-placeholder {{ 479 color: var(--color-muted); 480 font-style: italic; 481}} 482 483.embed-loading {{ 484 display: block; 485 padding: 0.5rem 0; 486 color: var(--color-subtle); 487 font-family: var(--font-mono); 488 font-size: 0.85rem; 489}} 490 491/* Embed Author Block */ 492.embed-author {{ 493 display: flex; 494 align-items: center; 495 gap: 0.75rem; 496 padding-bottom: 0.5rem; 497}} 498 499.embed-avatar {{ 500 width: 36px; 501 height: 36px; 502 max-width: 36px; 503 max-height: 36px; 504 aspect-ratio: 1; 505 margin: 0; 506 object-fit: cover; 507}} 508 509.embed-author-info {{ 510 display: flex; 511 flex-direction: column; 512 gap: 0; 513 min-width: 0; 514}} 515 516.embed-avatar-link {{ 517 display: block; 518 flex-shrink: 0; 519}} 520 521.embed-author-name {{ 522 font-weight: 600; 523 color: var(--color-text); 524 overflow: hidden; 525 text-overflow: ellipsis; 526 white-space: nowrap; 527 text-decoration: none; 528 line-height: 1.2; 529}} 530 531a.embed-author-name:hover {{ 532 color: var(--color-link); 533}} 534 535.embed-author-handle {{ 536 font-size: 0.85em; 537 font-family: var(--font-mono); 538 color: var(--color-subtle); 539 text-decoration: none; 540 overflow: hidden; 541 text-overflow: ellipsis; 542 white-space: nowrap; 543 line-height: 1.2; 544}} 545 546.embed-author-handle:hover {{ 547 color: var(--color-link); 548}} 549 550/* Card-wide clickable link (sits behind content) */ 551.embed-card-link {{ 552 position: absolute; 553 inset: 0; 554 z-index: 0; 555}} 556 557.embed-card-link:focus {{ 558 outline: 2px solid var(--color-primary); 559 outline-offset: 2px; 560}} 561 562/* Interactive elements sit above the card link */ 563.embed-author, 564.embed-external, 565.embed-quote, 566.embed-images, 567.embed-meta {{ 568 position: relative; 569 z-index: 1; 570}} 571 572/* Embed Content Block */ 573.embed-content {{ 574 display: block; 575 color: var(--color-text); 576 line-height: 1.5; 577 margin-bottom: 0.75rem; 578 white-space: pre-wrap; 579}} 580 581 582 583.embed-description {{ 584 display: block; 585 color: var(--color-text); 586 font-size: 0.95em; 587 line-height: 1.4; 588}} 589 590/* Embed Metadata Block */ 591.embed-meta {{ 592 display: flex; 593 justify-content: space-between; 594 align-items: center; 595 font-size: 0.85em; 596 color: var(--color-muted); 597 margin-top: 0.75rem; 598}} 599 600.embed-stats {{ 601 display: flex; 602 gap: 1rem; 603 font-family: var(--font-mono); 604}} 605 606.embed-stat {{ 607 color: var(--color-subtle); 608 font-size: 0.9em; 609}} 610 611.embed-time {{ 612 color: var(--color-subtle); 613 text-decoration: none; 614 font-family: var(--font-mono); 615 font-size: 0.9em; 616}} 617 618.embed-time:hover {{ 619 color: var(--color-link); 620}} 621 622.embed-type {{ 623 font-size: 0.8em; 624 color: var(--color-subtle); 625 font-family: var(--font-mono); 626 text-transform: uppercase; 627 letter-spacing: 0.05em; 628}} 629 630/* Embed URL link (shown with syntax in editor) */ 631.embed-url {{ 632 color: var(--color-link); 633 font-family: var(--font-mono); 634 font-size: 0.9em; 635 word-break: break-all; 636}} 637 638/* External link cards */ 639.embed-external {{ 640 display: flex; 641 gap: 0.75rem; 642 padding: 0.75rem; 643 background: var(--color-surface); 644 border: 1px dashed var(--color-border); 645 text-decoration: none; 646 color: inherit; 647 margin-top: 0.5rem; 648}} 649 650.embed-external:hover {{ 651 border-inline-start: 2px solid var(--color-primary); 652 margin-inline-start: -1px; 653}} 654 655@media (prefers-color-scheme: dark) {{ 656 .embed-external {{ 657 border: 1px solid var(--color-border); 658 }} 659 660 .embed-external:hover {{ 661 border-inline-start: 2px solid var(--color-primary); 662 margin-inline-start: -1px; 663 }} 664}} 665 666.embed-external-thumb {{ 667 width: 120px; 668 height: 80px; 669 object-fit: cover; 670 flex-shrink: 0; 671}} 672 673.embed-external-info {{ 674 display: flex; 675 flex-direction: column; 676 gap: 0.25rem; 677 min-width: 0; 678}} 679 680.embed-external-title {{ 681 font-weight: 600; 682 color: var(--color-text); 683 overflow: hidden; 684 text-overflow: ellipsis; 685 white-space: nowrap; 686}} 687 688.embed-external-description {{ 689 font-size: 0.9em; 690 color: var(--color-muted); 691 overflow: hidden; 692 text-overflow: ellipsis; 693 display: -webkit-box; 694 -webkit-line-clamp: 2; 695 -webkit-box-orient: vertical; 696}} 697 698.embed-external-url {{ 699 font-size: 0.8em; 700 font-family: var(--font-mono); 701 color: var(--color-subtle); 702}} 703 704/* Image embeds */ 705.embed-images {{ 706 display: grid; 707 gap: 4px; 708 margin-top: 0.5rem; 709 overflow: hidden; 710}} 711 712.embed-images-1 {{ 713 grid-template-columns: 1fr; 714}} 715 716.embed-images-2 {{ 717 grid-template-columns: 1fr 1fr; 718}} 719 720.embed-images-3 {{ 721 grid-template-columns: 1fr 1fr; 722}} 723 724.embed-images-4 {{ 725 grid-template-columns: 1fr 1fr; 726}} 727 728.embed-image-link {{ 729 display: block; 730 line-height: 0; 731}} 732 733.embed-image {{ 734 width: 100%; 735 height: auto; 736 max-height: 500px; 737 object-fit: cover; 738 object-position: center; 739 margin: 0; 740}} 741 742/* Quoted records */ 743.embed-quote {{ 744 display: block; 745 margin-top: 0.5rem; 746 padding: 0.75rem; 747 background: var(--color-overlay); 748 border-inline-start: 2px solid var(--color-tertiary); 749}} 750 751@media (prefers-color-scheme: dark) {{ 752 .embed-quote {{ 753 border: 1px solid var(--color-border); 754 border-inline-start: 2px solid var(--color-tertiary); 755 }} 756}} 757 758.embed-quote .embed-author {{ 759 margin-bottom: 0.5rem; 760}} 761 762.embed-quote .embed-avatar {{ 763 width: 24px; 764 height: 24px; 765 min-width: 24px; 766 min-height: 24px; 767 max-width: 24px; 768 max-height: 24px; 769}} 770 771.embed-quote .embed-content {{ 772 font-size: 0.95em; 773 margin-bottom: 0; 774}} 775 776/* Placeholder states */ 777.embed-video-placeholder, 778.embed-not-found, 779.embed-blocked, 780.embed-detached, 781.embed-unknown {{ 782 display: block; 783 padding: 1rem; 784 background: var(--color-overlay); 785 border-inline-start: 2px solid var(--color-border); 786 color: var(--color-muted); 787 font-style: italic; 788 margin-top: 0.5rem; 789 font-family: var(--font-mono); 790 font-size: 0.9em; 791}} 792 793@media (prefers-color-scheme: dark) {{ 794 .embed-video-placeholder, 795 .embed-not-found, 796 .embed-blocked, 797 .embed-detached, 798 .embed-unknown {{ 799 border: 1px dashed var(--color-border); 800 }} 801}} 802 803/* Record card embeds (feeds, lists, labelers, starter packs) */ 804.embed-record-card {{ 805 display: block; 806 margin-top: 0.5rem; 807 padding: 0.75rem; 808 background: var(--color-overlay); 809 border-inline-start: 2px solid var(--color-tertiary); 810}} 811 812.embed-record-card > .embed-author-name {{ 813 display: block; 814 font-size: 1.1em; 815}} 816 817.embed-subtitle {{ 818 display: block; 819 font-size: 0.85em; 820 color: var(--color-muted); 821 margin-bottom: 0.5rem; 822}} 823 824.embed-record-card .embed-description {{ 825 display: block; 826 margin: 0.5rem 0; 827}} 828 829.embed-record-card .embed-stats {{ 830 display: block; 831 margin-top: 0.25rem; 832}} 833 834/* Generic record fields */ 835.embed-fields {{ 836 display: block; 837 margin-top: 0.5rem; 838 font-family: var(--font-ui); 839 font-size: 0.85rem; 840 color: var(--color-muted); 841}} 842 843.embed-field {{ 844 display: block; 845 margin-top: 0.25rem; 846}} 847 848/* Nested fields get indentation */ 849.embed-fields .embed-fields {{ 850 display: block; 851 margin-top: 0.5rem; 852 margin-inline-start: 1rem; 853 padding-inline-start: 0.5rem; 854 border-inline-start: 1px solid var(--color-border); 855}} 856 857/* Type label inside fields should be block with spacing */ 858.embed-fields > .embed-author-handle {{ 859 display: block; 860 margin-bottom: 0.25rem; 861}} 862 863.embed-field-name {{ 864 color: var(--color-subtle); 865}} 866 867.embed-field-number {{ 868 color: var(--color-tertiary); 869}} 870 871.embed-field-date {{ 872 color: var(--color-muted); 873}} 874 875.embed-field-count {{ 876 color: var(--color-muted); 877 font-style: italic; 878}} 879 880.embed-field-bool-true {{ 881 color: var(--color-success); 882}} 883 884.embed-field-bool-false {{ 885 color: var(--color-muted); 886}} 887 888.embed-field-link, 889.embed-field-aturi {{ 890 color: var(--color-link); 891 text-decoration: none; 892}} 893 894.embed-field-link:hover, 895.embed-field-aturi:hover {{ 896 text-decoration: underline; 897}} 898 899.embed-field-did {{ 900 font-family: var(--font-mono); 901 font-size: 0.9em; 902}} 903 904.embed-field-did .did-scheme, 905.embed-field-did .did-separator {{ 906 color: var(--color-muted); 907}} 908 909.embed-field-did .did-method {{ 910 color: var(--color-tertiary); 911}} 912 913.embed-field-did .did-identifier {{ 914 color: var(--color-text); 915}} 916 917.embed-field-nsid {{ 918 color: var(--color-secondary); 919}} 920 921.embed-field-handle {{ 922 color: var(--color-link); 923}} 924 925/* AT URI highlighting */ 926.aturi-scheme {{ 927 color: var(--color-muted); 928}} 929 930.aturi-slash {{ 931 color: var(--color-muted); 932}} 933 934.aturi-authority {{ 935 color: var(--color-link); 936}} 937 938.aturi-collection {{ 939 color: var(--color-secondary); 940}} 941 942.aturi-rkey {{ 943 color: var(--color-tertiary); 944}} 945 946/* Generic AT Protocol record embed */ 947.atproto-record > .embed-author-handle {{ 948 display: block; 949 margin-bottom: 0.25rem; 950}} 951 952.atproto-record > .embed-author-name {{ 953 display: block; 954 margin-bottom: 0.5rem; 955}} 956 957.atproto-record > .embed-content {{ 958 margin-bottom: 0.5rem; 959}} 960 961/* Notebook entry embed - full width, expandable */ 962.atproto-entry {{ 963 max-width: none; 964 width: 100%; 965 margin: 1.5rem 0; 966 padding: 0; 967 background: var(--color-surface); 968 border: 1px solid var(--color-border); 969 border-inline-start: 1px solid var(--color-border); 970 box-shadow: none; 971 overflow: hidden; 972}} 973 974.atproto-entry:hover {{ 975 border-inline-start-color: var(--color-border); 976}} 977 978@media (prefers-color-scheme: dark) {{ 979 .atproto-entry {{ 980 border: 1px solid var(--color-border); 981 border-inline-start: 1px solid var(--color-border); 982 }} 983}} 984 985.embed-entry-header {{ 986 display: flex; 987 flex-wrap: wrap; 988 align-items: baseline; 989 gap: 0.5rem 1rem; 990 padding: 0.75rem 1rem; 991 background: var(--color-overlay); 992 border-bottom: 1px solid var(--color-border); 993}} 994 995.embed-entry-title {{ 996 font-size: 1.1em; 997 font-weight: 600; 998 color: var(--color-text); 999}} 1000 1001.embed-entry-author {{ 1002 font-size: 0.85em; 1003 color: var(--color-muted); 1004}} 1005 1006/* Hidden checkbox for expand/collapse */ 1007.embed-entry-toggle {{ 1008 display: none; 1009}} 1010 1011/* Content wrapper - scrollable when collapsed */ 1012.embed-entry-content {{ 1013 max-height: 30rem; 1014 overflow-y: auto; 1015 padding: 1rem; 1016 transition: max-height 0.3s ease; 1017}} 1018 1019/* When checkbox is checked, expand fully */ 1020.embed-entry-toggle:checked ~ .embed-entry-content {{ 1021 max-height: none; 1022}} 1023 1024/* Expand/collapse button */ 1025.embed-entry-expand {{ 1026 display: block; 1027 width: 100%; 1028 padding: 0.5rem; 1029 text-align: center; 1030 font-size: 0.85em; 1031 font-family: var(--font-ui); 1032 color: var(--color-muted); 1033 background: var(--color-overlay); 1034 border-top: 1px solid var(--color-border); 1035 cursor: pointer; 1036 user-select: none; 1037}} 1038 1039.embed-entry-expand:hover {{ 1040 color: var(--color-text); 1041 background: var(--color-surface); 1042}} 1043 1044/* Toggle button text */ 1045.embed-entry-expand::before {{ 1046 content: "Expand ↓"; 1047}} 1048 1049.embed-entry-toggle:checked ~ .embed-entry-expand::before {{ 1050 content: "Collapse ↑"; 1051}} 1052 1053/* Hide expand button if content doesn't overflow (via JS class) */ 1054.atproto-entry.no-overflow .embed-entry-expand {{ 1055 display: none; 1056}} 1057 1058/* Horizontal Rule */ 1059hr {{ 1060 border: none; 1061 border-top: 2px solid var(--color-border); 1062 margin: 2rem 0; 1063}} 1064 1065/* Tablet and mobile responsiveness */ 1066@media (max-width: 900px) {{ 1067 .notebook-content {{ 1068 padding: 1.5rem 1rem; 1069 max-width: 100%; 1070 }} 1071 1072 h1 {{ font-size: 1.85rem; }} 1073 h2 {{ font-size: 1.4rem; }} 1074 h3 {{ font-size: 1.2rem; }} 1075 1076 blockquote {{ 1077 margin-inline-start: 0; 1078 margin-inline-end: 0; 1079 }} 1080}} 1081 1082/* Small mobile phones */ 1083@media (max-width: 480px) {{ 1084 .notebook-content {{ 1085 padding: 1rem 0.75rem; 1086 }} 1087 1088 h1 {{ font-size: 1.65rem; }} 1089 h2 {{ font-size: 1.3rem; }} 1090 h3 {{ font-size: 1.1rem; }} 1091 1092 blockquote {{ 1093 padding-inline-start: 0.75rem; 1094 padding-inline-end: 0.75rem; 1095 }} 1096}} 1097 1098/* Leaflet document embeds */ 1099.atproto-leaflet {{ 1100 max-width: none; 1101 width: 100%; 1102 margin: 1rem 0; 1103}} 1104 1105.leaflet-document {{ 1106 display: block; 1107}} 1108 1109.leaflet-text {{ 1110 margin: 0.5rem 0; 1111}} 1112 1113.leaflet-button {{ 1114 display: inline-block; 1115 padding: 0.5rem 1rem; 1116 background: var(--color-primary); 1117 color: var(--color-base); 1118 text-decoration: none; 1119 border-radius: 4px; 1120 margin: 0.5rem 0; 1121}} 1122 1123.leaflet-button:hover {{ 1124 opacity: 0.9; 1125}} 1126 1127/* Alignment utilities */ 1128.align-center {{ text-align: center; }} 1129.align-right {{ text-align: right; }} 1130.align-justify {{ text-align: justify; }} 1131"#, 1132 // Light mode colours 1133 light.base, 1134 light.surface, 1135 light.overlay, 1136 light.text, 1137 light.muted, 1138 light.subtle, 1139 light.emphasis, 1140 light.primary, 1141 light.secondary, 1142 light.tertiary, 1143 light.error, 1144 light.warning, 1145 light.success, 1146 light.border, 1147 light.link, 1148 light.highlight, 1149 // Fonts and spacing 1150 body, 1151 heading, 1152 monospace, 1153 spacing.base_size, 1154 spacing.line_height, 1155 spacing.scale, 1156 // Dark mode colours 1157 dark.base, 1158 dark.surface, 1159 dark.overlay, 1160 dark.text, 1161 dark.muted, 1162 dark.subtle, 1163 dark.emphasis, 1164 dark.primary, 1165 dark.secondary, 1166 dark.tertiary, 1167 dark.error, 1168 dark.warning, 1169 dark.success, 1170 dark.border, 1171 dark.link, 1172 dark.highlight, 1173 ) 1174} 1175 1176async fn load_syntect_dark_theme( 1177 code_theme: &ThemeDarkCodeTheme<'_>, 1178) -> miette::Result<syntect::highlighting::Theme> { 1179 match code_theme { 1180 ThemeDarkCodeTheme::CodeThemeName(name) => { 1181 match name.as_str() { 1182 "rose-pine" => { 1183 let mut cursor = Cursor::new(ROSE_PINE_THEME.as_bytes()); 1184 ThemeSet::load_from_reader(&mut cursor) 1185 .into_diagnostic() 1186 .map_err(|e| { 1187 miette::miette!("Failed to load embedded rose-pine theme: {}", e) 1188 }) 1189 } 1190 "rose-pine-dawn" => { 1191 let mut cursor = Cursor::new(ROSE_PINE_DAWN_THEME.as_bytes()); 1192 ThemeSet::load_from_reader(&mut cursor) 1193 .into_diagnostic() 1194 .map_err(|e| { 1195 miette::miette!("Failed to load embedded rose-pine-dawn theme: {}", e) 1196 }) 1197 } 1198 _ => { 1199 // Fall back to syntect's built-in themes 1200 let theme_set = ThemeSet::load_defaults(); 1201 theme_set 1202 .themes 1203 .get(name.as_str()) 1204 .ok_or_else(|| miette::miette!("Theme '{}' not found in defaults", name)) 1205 .cloned() 1206 } 1207 } 1208 } 1209 ThemeDarkCodeTheme::CodeThemeFile(file) => { 1210 let client = BasicClient::unauthenticated(); 1211 let pds = client.pds_for_did(&file.did).await?; 1212 let blob = client 1213 .xrpc(pds) 1214 .send( 1215 &GetBlob::new() 1216 .did(file.did.clone()) 1217 .cid(file.content.blob().cid().clone()) 1218 .build(), 1219 ) 1220 .await? 1221 .buffer() 1222 .clone(); 1223 let mut cursor = Cursor::new(blob); 1224 ThemeSet::load_from_reader(&mut cursor) 1225 .into_diagnostic() 1226 .map_err(|e| miette::miette!("Failed to download theme: {}", e)) 1227 } 1228 _ => { 1229 let mut cursor = Cursor::new(ROSE_PINE_THEME.as_bytes()); 1230 ThemeSet::load_from_reader(&mut cursor) 1231 .into_diagnostic() 1232 .map_err(|e| miette::miette!("Failed to load embedded rose-pine theme: {}", e)) 1233 } 1234 } 1235} 1236 1237async fn load_syntect_light_theme( 1238 code_theme: &ThemeLightCodeTheme<'_>, 1239) -> miette::Result<syntect::highlighting::Theme> { 1240 match code_theme { 1241 ThemeLightCodeTheme::CodeThemeName(name) => { 1242 match name.as_str() { 1243 "rose-pine" => { 1244 let mut cursor = Cursor::new(ROSE_PINE_THEME.as_bytes()); 1245 ThemeSet::load_from_reader(&mut cursor) 1246 .into_diagnostic() 1247 .map_err(|e| { 1248 miette::miette!("Failed to load embedded rose-pine theme: {}", e) 1249 }) 1250 } 1251 "rose-pine-dawn" => { 1252 let mut cursor = Cursor::new(ROSE_PINE_DAWN_THEME.as_bytes()); 1253 ThemeSet::load_from_reader(&mut cursor) 1254 .into_diagnostic() 1255 .map_err(|e| { 1256 miette::miette!("Failed to load embedded rose-pine-dawn theme: {}", e) 1257 }) 1258 } 1259 _ => { 1260 // Fall back to syntect's built-in themes 1261 let theme_set = ThemeSet::load_defaults(); 1262 theme_set 1263 .themes 1264 .get(name.as_str()) 1265 .ok_or_else(|| miette::miette!("Theme '{}' not found in defaults", name)) 1266 .cloned() 1267 } 1268 } 1269 } 1270 ThemeLightCodeTheme::CodeThemeFile(file) => { 1271 let client = BasicClient::unauthenticated(); 1272 let pds = client.pds_for_did(&file.did).await?; 1273 let blob = client 1274 .xrpc(pds) 1275 .send( 1276 &GetBlob::new() 1277 .did(file.did.clone()) 1278 .cid(file.content.blob().cid().clone()) 1279 .build(), 1280 ) 1281 .await? 1282 .buffer() 1283 .clone(); 1284 let mut cursor = Cursor::new(blob); 1285 ThemeSet::load_from_reader(&mut cursor) 1286 .into_diagnostic() 1287 .map_err(|e| miette::miette!("Failed to download theme: {}", e)) 1288 } 1289 _ => { 1290 let mut cursor = Cursor::new(ROSE_PINE_THEME.as_bytes()); 1291 ThemeSet::load_from_reader(&mut cursor) 1292 .into_diagnostic() 1293 .map_err(|e| miette::miette!("Failed to load embedded rose-pine theme: {}", e)) 1294 } 1295 } 1296} 1297 1298pub async fn generate_syntax_css(theme: &ResolvedTheme<'_>) -> miette::Result<String> { 1299 // Load both themes 1300 let dark_syntect_theme = load_syntect_dark_theme(&theme.dark_code_theme).await?; 1301 let light_syntect_theme = load_syntect_light_theme(&theme.light_code_theme).await?; 1302 1303 // Generate dark mode CSS (default) 1304 let dark_css = css_for_theme_with_class_style( 1305 &dark_syntect_theme, 1306 ClassStyle::SpacedPrefixed { 1307 prefix: crate::code_pretty::CSS_PREFIX, 1308 }, 1309 ) 1310 .into_diagnostic()?; 1311 1312 // Generate light mode CSS 1313 let light_css = css_for_theme_with_class_style( 1314 &light_syntect_theme, 1315 ClassStyle::SpacedPrefixed { 1316 prefix: crate::code_pretty::CSS_PREFIX, 1317 }, 1318 ) 1319 .into_diagnostic()?; 1320 1321 // Combine with media queries 1322 let mut result = String::new(); 1323 result.push_str("/* Syntax highlighting - Light Mode (default) */\n"); 1324 result.push_str(&light_css); 1325 result.push_str("\n\n/* Syntax highlighting - Dark Mode */\n"); 1326 result.push_str("@media (prefers-color-scheme: dark) {\n"); 1327 result.push_str(&dark_css); 1328 result.push_str("}\n"); 1329 1330 Ok(result) 1331} 1332 1333pub fn generate_default_css() -> miette::Result<String> { 1334 let mut theme_set = ThemeSet::load_defaults(); 1335 let rose_pine = { 1336 let mut cursor = Cursor::new(ROSE_PINE_THEME.as_bytes()); 1337 ThemeSet::load_from_reader(&mut cursor) 1338 .into_diagnostic() 1339 .map_err(|e| miette::miette!("Failed to load embedded rose-pine theme: {}", e))? 1340 }; 1341 let rose_pine_dawn = { 1342 let mut cursor = Cursor::new(ROSE_PINE_DAWN_THEME.as_bytes()); 1343 ThemeSet::load_from_reader(&mut cursor) 1344 .into_diagnostic() 1345 .map_err(|e| miette::miette!("Failed to load embedded rose-pine-dawn theme: {}", e))? 1346 }; 1347 theme_set.themes.insert("rose-pine".to_string(), rose_pine); 1348 theme_set 1349 .themes 1350 .insert("rose-pine-dawn".to_string(), rose_pine_dawn); 1351 // Generate dark mode CSS (default) 1352 let dark_css = css_for_theme_with_class_style( 1353 theme_set.themes.get("rose-pine").unwrap(), 1354 ClassStyle::SpacedPrefixed { 1355 prefix: crate::code_pretty::CSS_PREFIX, 1356 }, 1357 ) 1358 .into_diagnostic()?; 1359 1360 // Generate light mode CSS 1361 let light_css = css_for_theme_with_class_style( 1362 theme_set.themes.get("rose-pine-dawn").unwrap(), 1363 ClassStyle::SpacedPrefixed { 1364 prefix: crate::code_pretty::CSS_PREFIX, 1365 }, 1366 ) 1367 .into_diagnostic()?; 1368 1369 // Combine with media queries 1370 let mut result = String::new(); 1371 result.push_str("/* Syntax highlighting - Light Mode (default) */\n"); 1372 result.push_str(&light_css); 1373 result.push_str("\n\n/* Syntax highlighting - Dark Mode */\n"); 1374 result.push_str("@media (prefers-color-scheme: dark) {\n"); 1375 result.push_str(&dark_css); 1376 result.push_str("}\n"); 1377 1378 Ok(result) 1379}