atproto blogging
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}