create-avocado-site 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,24 @@
1
1
  <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
2
2
  <title id="title">The Avocado Hub logo</title>
3
- <desc id="desc">Stylized avocado mark — brand green shell, warm cream flesh, muted brown stone.</desc>
4
- <!-- One green, and it is the brand's. The mark previously used #1F7A3A over
5
- #7ED957: two greens, neither of them the #0f766e on every button, and the
6
- lime saturated well past anything else on the page. -->
7
- <path d="M64 8C88 8 112 40 112 72C112 104 88 120 64 120C40 120 16 104 16 72C16 40 40 8 64 8Z" fill="#0f766e"/>
8
- <path d="M64 18C82 18 100 44 100 72C100 100 82 110 64 110C46 110 28 100 28 72C28 44 46 18 64 18Z" fill="#e9e4d4"/>
9
- <circle cx="64" cy="72" r="20" fill="#8a6a4f"/>
3
+ <desc id="desc">Avocado cross-section — near-black skin, chartreuse flesh, near-black stone.</desc>
4
+ <!--
5
+ The original silhouette, recoloured. Transparent ground, and the same two
6
+ fills on either page ground.
7
+
8
+ The mark is loaded through an img element, so it sits outside the page's
9
+ cascade: it cannot resolve a custom property and cannot see the dark class
10
+ on the html element. One colouring therefore has to hold on #12170F and on
11
+ #F2F3EA, and chartreuse alone does not — roughly 11:1 against the dark
12
+ ground and 1.3:1 against the light one, so a chartreuse silhouette that
13
+ reads as a mark in dark reads as a smudge in light.
14
+
15
+ Green in the flesh and near-black in the skin solves it with no theme
16
+ logic. On the dark ground the skin is the ground, so what you see is the
17
+ chartreuse flesh with the stone punched out of it. On the light ground the
18
+ same skin becomes a heavy outline with the flesh inside. Two renderings,
19
+ one file, nothing conditional.
20
+ -->
21
+ <path d="M64 8C88 8 112 40 112 72C112 104 88 120 64 120C40 120 16 104 16 72C16 40 40 8 64 8Z" fill="#12170F"/>
22
+ <path d="M64 18C82 18 100 44 100 72C100 100 82 110 64 110C46 110 28 100 28 72C28 44 46 18 64 18Z" fill="#C5E035"/>
23
+ <circle cx="64" cy="72" r="20" fill="#12170F"/>
10
24
  </svg>
@@ -1,5 +1,35 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { AVOCADO, NEXT, REACT, TYPESCRIPT, TYPES_NODE, TYPES_REACT, TYPES_REACT_DOM } from "../versions.js";
2
3
  import { DEMO_SITE_CONFIG_JSON } from "./demo-content.js";
4
+ /*
5
+ * The theme, as a file on disk rather than a template literal.
6
+ *
7
+ * It is 300 lines of token values with no interpolation in it, and it has to
8
+ * stay identical to the one `apps/site` wears or the scaffold drifts into a
9
+ * near-miss of the demo. `scripts/sync-demo-seed.mjs` writes it here from
10
+ * `apps/site/app/theme-night-harvest.css`; like the demo assets beside it,
11
+ * this file is generated and must not be edited in place.
12
+ *
13
+ * Resolved relative to the package root — two levels up from both
14
+ * `src/templates` and `dist/templates`.
15
+ */
16
+ const THEME_FILE = fileURLToPath(new URL("../../theme/night-harvest.css", import.meta.url));
17
+ /*
18
+ * The theme control, on the same terms.
19
+ *
20
+ * `layout.tsx` below has always carried a pre-paint script that reads
21
+ * `site-theme-v1` out of localStorage — and until these two files were added,
22
+ * nothing in a scaffolded project ever wrote that key. Half a feature: the
23
+ * page followed the OS and offered no way to say otherwise, while the demo it
24
+ * is a copy of has a control in the header and another under the footer.
25
+ *
26
+ * Both are generated by `scripts/sync-demo-seed.mjs` from `apps/site`, so the
27
+ * scaffold's control is the demo's control rather than a re-creation of it.
28
+ * Do not edit them here.
29
+ */
30
+ const CONTROL_FILE = fileURLToPath(new URL("../../theme/theme-toggle.tsx", import.meta.url));
31
+ const CONTROL_CSS_FILE = fileURLToPath(new URL("../../theme/theme-toggle.css", import.meta.url));
32
+ const CHROME_CSS_FILE = fileURLToPath(new URL("../../theme/site-chrome.css", import.meta.url));
3
33
  /**
4
34
  * The name the scaffolded site renders in its own header. Read from the seed
5
35
  * rather than restated, so it cannot drift from what the preview shows — the
@@ -404,9 +434,18 @@ if (siteUp) open(OPEN_URL)
404
434
  * Without \`@avocadostudio-ai/blocks/styles.css\` every block renders correct,
405
435
  * complete, unstyled HTML — which does not read as "a stylesheet is missing",
406
436
  * it reads as "this product produces ugly pages".
437
+ *
438
+ * `./theme.css` comes second, because a theme is a set of values for the
439
+ * tokens that stylesheet declares and the later declaration wins. It is the
440
+ * same file `apps/site` wears, copied in by `scripts/sync-demo-seed.mjs`, so
441
+ * a scaffold looks like the demo everyone was shown rather than like the
442
+ * package defaults underneath it.
407
443
  */
408
444
  function globalsCss() {
409
445
  return `@import "@avocadostudio-ai/blocks/styles.css";
446
+ @import "./theme.css";
447
+ @import "./theme-toggle.css";
448
+ @import "./site-chrome.css";
410
449
 
411
450
  :root {
412
451
  color-scheme: light dark;
@@ -416,33 +455,257 @@ function globalsCss() {
416
455
  box-sizing: border-box;
417
456
  }
418
457
 
458
+ /*
459
+ * The base rules are the demo site's, token by token.
460
+ *
461
+ * This used to hardcode \`ui-sans-serif, system-ui, …\` here, after the theme
462
+ * import — so the theme shipped Archivo, declared it as \`--font-body\`, loaded
463
+ * it through \`next/font\`, and then this rule won. Headings came out in Anton
464
+ * and every other word on the page in the system sans, which is close enough
465
+ * to right that nobody reads it as a bug.
466
+ */
419
467
  html,
420
468
  body {
421
469
  margin: 0;
422
470
  padding: 0;
471
+ font-family: var(--font-body);
472
+ font-size: 16px;
473
+ line-height: 1.5;
474
+ background: var(--bg-0);
475
+ color: var(--text-100);
476
+ -webkit-font-smoothing: antialiased;
423
477
  }
424
478
 
425
- body {
426
- font-family:
427
- ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
428
- -webkit-font-smoothing: antialiased;
479
+ /*
480
+ * The reset the demo site gets from Tailwind, restated without Tailwind.
481
+ *
482
+ * \`apps/site\` imports \`tailwindcss\`, whose preflight zeroes every margin and
483
+ * padding, sizes headings from their block rather than the UA, makes images
484
+ * block-level and lets buttons inherit the page's font. This project has no
485
+ * Tailwind, and every one of those showed up as a difference against the demo
486
+ * it was meant to reproduce: footer headings at the UA's 1.5em with a 0.83em
487
+ * margin under them, carousel arrows squeezed by the UA's \`1px 6px\` button
488
+ * padding, a slide 61px taller than its picture.
489
+ *
490
+ * It sits in \`@layer base\` for the same reason Tailwind's does: a layered rule
491
+ * loses to every unlayered one whatever its specificity, so a block stylesheet
492
+ * or a rule further down this file always wins over it — which is the cascade
493
+ * the demo renders with.
494
+ */
495
+ @layer base {
496
+ *,
497
+ ::after,
498
+ ::before,
499
+ ::backdrop,
500
+ ::file-selector-button {
501
+ box-sizing: border-box;
502
+ margin: 0;
503
+ padding: 0;
504
+ border: 0 solid;
505
+ }
506
+
507
+ html {
508
+ line-height: 1.5;
509
+ -webkit-text-size-adjust: 100%;
510
+ tab-size: 4;
511
+ -webkit-tap-highlight-color: transparent;
512
+ }
513
+
514
+ hr {
515
+ height: 0;
516
+ color: inherit;
517
+ border-top-width: 1px;
518
+ }
519
+
520
+ h1,
521
+ h2,
522
+ h3,
523
+ h4,
524
+ h5,
525
+ h6 {
526
+ font-size: inherit;
527
+ font-weight: inherit;
528
+ }
529
+
530
+ a {
531
+ color: inherit;
532
+ text-decoration: inherit;
533
+ }
534
+
535
+ b,
536
+ strong {
537
+ font-weight: bolder;
538
+ }
539
+
540
+ code,
541
+ kbd,
542
+ samp,
543
+ pre {
544
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
545
+ font-size: 1em;
546
+ }
547
+
548
+ small {
549
+ font-size: 80%;
550
+ }
551
+
552
+ table {
553
+ text-indent: 0;
554
+ border-color: inherit;
555
+ border-collapse: collapse;
556
+ }
557
+
558
+ ol,
559
+ ul,
560
+ menu {
561
+ list-style: none;
562
+ }
563
+
564
+ img,
565
+ svg,
566
+ video,
567
+ canvas,
568
+ audio,
569
+ iframe,
570
+ embed,
571
+ object {
572
+ display: block;
573
+ vertical-align: middle;
574
+ }
575
+
576
+ img,
577
+ video {
578
+ max-width: 100%;
579
+ height: auto;
580
+ }
581
+
582
+ button,
583
+ input,
584
+ select,
585
+ optgroup,
586
+ textarea,
587
+ ::file-selector-button {
588
+ font: inherit;
589
+ font-feature-settings: inherit;
590
+ font-variation-settings: inherit;
591
+ letter-spacing: inherit;
592
+ color: inherit;
593
+ border-radius: 0;
594
+ background-color: transparent;
595
+ opacity: 1;
596
+ }
597
+
598
+ ::placeholder {
599
+ opacity: 1;
600
+ }
601
+
602
+ textarea {
603
+ resize: vertical;
604
+ }
605
+
606
+ button,
607
+ input:where([type="button"], [type="reset"], [type="submit"]),
608
+ ::file-selector-button {
609
+ appearance: button;
610
+ }
611
+
612
+ [hidden]:where(:not([hidden="until-found"])) {
613
+ display: none !important;
614
+ }
615
+ }
616
+
617
+ h1,
618
+ h2,
619
+ h3 {
620
+ font-family: var(--font-heading);
621
+ }
622
+
623
+ h1,
624
+ h2,
625
+ h3,
626
+ p {
627
+ margin-top: 0;
628
+ }
629
+
630
+ ul {
631
+ padding-left: 18px;
632
+ margin: 0;
633
+ }
634
+
635
+ a {
636
+ color: var(--link);
637
+ }
638
+
639
+ main {
640
+ width: 100%;
641
+ margin: 0;
642
+ padding: 0;
429
643
  }
430
644
  `;
431
645
  }
432
646
  function layout(config) {
433
647
  return `import type { ReactNode } from "react"
648
+ import { Anton, Archivo } from "next/font/google"
649
+ import { SiteThemeToggle } from "./theme-toggle"
434
650
  import "./globals.css"
435
651
 
436
652
  /*
437
- * Deliberately minimal. Every page's own <title> and description come from
438
- * \`generateMetadata\` in app/[[...slug]]/page.tsx, which derives them from the
439
- * page content — a title set here would be inherited by all nine pages and
653
+ * The theme's two faces, self-hosted by \`next/font\` with a metric-matched
654
+ * local fallback so the page does not reflow as they land. \`theme.css\` points
655
+ * \`--font-display\` and \`--font-body\` at these variables; without them the
656
+ * whole thing falls back to system sans and reads like a different design.
657
+ *
658
+ * Anton has one cut, so asking it for a bold gets a synthesised one — the
659
+ * display weight token stays at 400.
660
+ */
661
+ const anton = Anton({ weight: "400", subsets: ["latin"], display: "swap", variable: "--font-anton" })
662
+ const archivo = Archivo({ weight: ["400", "500"], subsets: ["latin"], display: "swap", variable: "--font-archivo" })
663
+
664
+ /*
665
+ * Runs before first paint so the theme never flashes.
666
+ *
667
+ * The query is \`prefers-color-scheme: light\`, not \`: dark\`: this is a dark
668
+ * theme with a light half, so dark is the fallback and light is the opt-in. A
669
+ * stored value is a pin; no stored value follows the system.
670
+ */
671
+ const themeScript = \`(function(){try{var t=localStorage.getItem('site-theme-v1');var d=t==='dark'||(t!=='light'&&!matchMedia('(prefers-color-scheme:light)').matches);if(d)document.documentElement.classList.add('dark')}catch(e){document.documentElement.classList.add('dark')}})()\`
672
+
673
+ /*
674
+ * Otherwise deliberately minimal. Every page's own <title> and description come
675
+ * from \`generateMetadata\` in app/[[...slug]]/page.tsx, which derives them from
676
+ * the page content — a title set here would be inherited by all nine pages and
440
677
  * override none of them.
441
678
  */
442
679
  export default function RootLayout({ children }: { children: ReactNode }) {
443
680
  return (
444
- <html lang="en">
445
- <body>{children}</body>
681
+ <html
682
+ lang="en"
683
+ suppressHydrationWarning
684
+ className={\`theme-night-harvest \${anton.variable} \${archivo.variable}\`}
685
+ style={{ background: "var(--bg-0, #12170f)" }}
686
+ >
687
+ <head>
688
+ <script dangerouslySetInnerHTML={{ __html: themeScript }} />
689
+ </head>
690
+ <body style={{ background: "var(--bg-0, #12170f)" }}>
691
+ {children}
692
+ {/*
693
+ Two controls, one state — see the note in \`theme-toggle.tsx\`.
694
+
695
+ The header copy is portalled into the nav the SiteHeader block
696
+ renders, because that markup belongs to the blocks package rather
697
+ than to this app; as a real child of that flex row it inherits the
698
+ sticky positioning instead of guessing at the header's height.
699
+
700
+ The footer copy needs no portal. \`children\` is the whole page
701
+ including the footer, so a sibling after it lands exactly where the
702
+ demo puts it: on the footer's own ground, below the last link.
703
+ */}
704
+ <SiteThemeToggle portalTo=".site-top-nav-inner" />
705
+ <div className="site-theme-bar">
706
+ <SiteThemeToggle />
707
+ </div>
708
+ </body>
446
709
  </html>
447
710
  )
448
711
  }
@@ -619,6 +882,10 @@ export function demoAppTemplates(config) {
619
882
  { path: "scripts/dev.mjs", content: devScript(config) },
620
883
  { path: "app/layout.tsx", content: layout(config) },
621
884
  { path: "app/globals.css", content: globalsCss() },
885
+ { path: "app/theme.css", source: THEME_FILE },
886
+ { path: "app/theme-toggle.tsx", source: CONTROL_FILE },
887
+ { path: "app/theme-toggle.css", source: CONTROL_CSS_FILE },
888
+ { path: "app/site-chrome.css", source: CHROME_CSS_FILE },
622
889
  { path: "app/api/avocado/[[...path]]/route.ts", content: orchestratorRoute(config) },
623
890
  ];
624
891
  }
@@ -17,7 +17,7 @@
17
17
  * `@avocadostudio-ai/orchestrator-core`, already pinned there; naming it again
18
18
  * here is how a project ends up with two.
19
19
  */
20
- export declare const AVOCADO = "0.19.0";
20
+ export declare const AVOCADO = "0.21.0";
21
21
  /**
22
22
  * Next 15.5.25 rather than 16, because that is the version every example app
23
23
  * and the demo site in this repository build and test against. The SDK
package/dist/versions.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * `@avocadostudio-ai/orchestrator-core`, already pinned there; naming it again
18
18
  * here is how a project ends up with two.
19
19
  */
20
- export const AVOCADO = "0.19.0";
20
+ export const AVOCADO = "0.21.0";
21
21
  /**
22
22
  * Next 15.5.25 rather than 16, because that is the version every example app
23
23
  * and the demo site in this repository build and test against. The SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-avocado-site",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Bootstrap a runnable Avocado Studio demo site, or wire Avocado into an existing Next.js project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "dist",
11
11
  "assets",
12
+ "theme",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],
@@ -29,14 +30,14 @@
29
30
  },
30
31
  "dependencies": {
31
32
  "@clack/prompts": "^0.9.1",
32
- "@avocadostudio-ai/skills": "^0.19.0"
33
+ "@avocadostudio-ai/skills": "^0.21.0"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/node": "^22.13.10",
36
37
  "tsx": "^4.19.0",
37
38
  "typescript": "^5.7.3",
38
- "@avocadostudio-ai/site-sdk": "0.19.0",
39
- "@avocadostudio-ai/shared": "0.19.0"
39
+ "@avocadostudio-ai/shared": "0.21.0",
40
+ "@avocadostudio-ai/site-sdk": "0.21.0"
40
41
  },
41
42
  "license": "Apache-2.0",
42
43
  "homepage": "https://docs.avocadostudio.dev",
@@ -0,0 +1,320 @@
1
+ /*
2
+ * Theme: Night Harvest
3
+ *
4
+ * Chartreuse on near-black, Anton over Archivo, nothing rounded. A set of
5
+ * values for the tokens the blocks package already reads — there is no Night
6
+ * Harvest code anywhere, and no block knows this file exists.
7
+ *
8
+ * Dark is the theme, not a mode of it. `html.theme-night-harvest.dark` is
9
+ * therefore the primary declaration and the site's pre-paint script leaves the
10
+ * `dark` class on unless a visitor has asked for light. The light half is a
11
+ * genuine inversion rather than a lightening: the band flips from chartreuse
12
+ * on ink to ink on chartreuse, and the accent stops being legible as text, so
13
+ * words in the accent switch to a darker relative of the same hue.
14
+ *
15
+ * ── The three that are easy to get wrong ──
16
+ *
17
+ * `--accent` is a fill and `--accent-ink` is the accent as a word. They are
18
+ * the same value in dark and different in light: #C5E035 on #F2F3EA is about
19
+ * 1.3:1, which is a highlighter, not text. Anything that sets `color` reads
20
+ * `--accent-ink`.
21
+ *
22
+ * `--band` and `--on-band` are the full-bleed strip. The band inverts between
23
+ * themes instead of lightening, so it cannot be derived from the accent.
24
+ *
25
+ * `--bg-100` (the panel) against `--bg-0` (the ground) is roughly 1.1:1 in
26
+ * both themes, and that is the intent. A card is separated by the grid gap.
27
+ * Do not reach for a border or a shadow to make one "visible".
28
+ */
29
+
30
+ /* ── Light ──────────────────────────────────────────────────────────────── */
31
+
32
+ html.theme-night-harvest {
33
+ color-scheme: light;
34
+
35
+ /* Ground and panel */
36
+ --bg-0: #f2f3ea;
37
+ --bg-000: #f2f3ea;
38
+ --bg-100: #e4e7d7;
39
+ --bg-200: #e4e7d7;
40
+ --bg-300: #d5d9c6;
41
+ --bg-1: #e4e7d7;
42
+ --surface: #e4e7d7;
43
+ --card-bg: #e4e7d7;
44
+ --section-bg: #f2f3ea;
45
+ --hero-bg: #f2f3ea;
46
+ --cta-bg: #e4e7d7;
47
+ --stripe-bg: #e4e7d7;
48
+ --table-header-bg: #e4e7d7;
49
+
50
+ /* The alternating ground. Panel is the same #E4E7D7 a card sits on, so a
51
+ panel section and the cards inside it read as one surface. */
52
+ --section-bg-panel: #e4e7d7;
53
+
54
+ /* Ink and muted */
55
+ --text-100: #14190f;
56
+ --text-200: #14190f;
57
+ --text-300: #565c4c;
58
+ --text-400: #565c4c;
59
+ --text-500: #565c4c;
60
+ --heading: #14190f;
61
+ --body: #14190f;
62
+ --body-secondary: #565c4c;
63
+ --caption: #565c4c;
64
+ --prose-body: #14190f;
65
+ --lead: #565c4c;
66
+
67
+ /*
68
+ * The accent, in its two jobs. `--brand` is the repo's name for "the colour
69
+ * a word is set in", so it takes the text-safe value; the fill lives in
70
+ * `--accent` and reaches the primary button through `--btn-primary-bg`.
71
+ */
72
+ --accent: #c5e035;
73
+ --accent-hover: #c5e035;
74
+ --accent-ink: #46630d;
75
+ --on-accent: #14190f;
76
+ --brand: #46630d;
77
+ --brand-hover: #14190f;
78
+ --brand-fg: #14190f;
79
+ --brand-subtle: rgba(197, 224, 53, 0.22);
80
+ --link: #46630d;
81
+ --btn-primary-bg: #c5e035;
82
+ --btn-primary-fg: #14190f;
83
+
84
+ /* The band inverts: ink ground, chartreuse type. */
85
+ --band: #14190f;
86
+ --on-band: #c5e035;
87
+ --on-ink: #c5e035;
88
+
89
+ /* Hairlines */
90
+ --rule: rgba(20, 25, 15, 0.16);
91
+ --border: rgba(20, 25, 15, 0.16);
92
+ --surface-border: rgba(20, 25, 15, 0.16);
93
+ --nav-border: rgba(20, 25, 15, 0.16);
94
+ --footer-border: rgba(20, 25, 15, 0.16);
95
+ --code-bg: rgba(20, 25, 15, 0.1);
96
+ --pre-bg: rgba(20, 25, 15, 0.07);
97
+
98
+ /* Chrome */
99
+ --nav-bg: rgba(242, 243, 234, 0.94);
100
+
101
+ /*
102
+ * The footer is a panel, not a slab of ink.
103
+ *
104
+ * It was #14190F — the same value as the inverse band — so on any page
105
+ * ending in a CTA the band ran straight into the footer and the two read as
106
+ * one undifferentiated dark block a third of the page tall. The boundary
107
+ * between "the last thing we are asking you to do" and "the small print"
108
+ * had disappeared.
109
+ *
110
+ * Making it the panel ground fixes it in both directions: in light the ink
111
+ * band now sits against a pale footer, and in dark the chartreuse band sits
112
+ * against the same panel. The footer is the same surface as a card in both
113
+ * themes, which is what it is.
114
+ */
115
+ --footer-bg: var(--section-bg-panel);
116
+ --footer-text: #565c4c;
117
+ --footer-heading: #14190f;
118
+ --footer-link: #565c4c;
119
+ --footer-link-hover: #46630d;
120
+
121
+ /*
122
+ * An image position with no image is a flat accent field, not a grey box.
123
+ * It is a designed state: on a page whose photography is not all there yet,
124
+ * a chartreuse panel reads as a decision and a grey one reads as a bug.
125
+ */
126
+ --placeholder-img: #c5e035;
127
+ --media-bg: #14190f;
128
+
129
+ /*
130
+ * The hero as a poster.
131
+ *
132
+ * Most of the fold, the headline at display scale set solid, and the type
133
+ * anchored to the bottom of the frame rather than floating in the middle of
134
+ * it — which is what separates a poster from a stock hero with words on it.
135
+ *
136
+ * The scrim sinks the photograph towards the page's own near-black instead
137
+ * of neutral black. A warm palette with a neutral scrim over its one
138
+ * photograph reads as two designs, and the grey is the one you notice.
139
+ */
140
+ /*
141
+ * A duotone, built from filters rather than an asset, so it applies to
142
+ * whatever photograph the content happens to name. Desaturate, lift the
143
+ * contrast, drop the exposure, then push the whole thing through the
144
+ * palette's own hue. The photograph stops being a warm neutral standing
145
+ * beside chartreuse and becomes the dark end of it.
146
+ */
147
+ --photo-treatment: grayscale(1) contrast(1.15) brightness(0.55) sepia(0.55) hue-rotate(35deg) saturate(2.6);
148
+
149
+ /* 58vh, not 78. The frame was taking the whole fold for a photograph that is
150
+ mostly empty wall, which is a lot of screen spent on very little. */
151
+ /* The fruit sits in the upper half of this photograph, so a centred crop
152
+ takes the top off it. */
153
+ --hero-image-position: 50% 18%;
154
+
155
+ --hero-min-h: min(58vh, 560px);
156
+ --hero-headline-size: clamp(2.5rem, 5.2vw, 4.5rem);
157
+ --hero-headline-leading: 0.92;
158
+ --hero-content-anchor: flex-end;
159
+ --hero-content-width: 100%;
160
+ /*
161
+ * 11ch, not 14. At 14 the headline ran two wide lines straight across the
162
+ * middle of the frame and landed on the fruit — the photograph had room for
163
+ * type on the left and the type refused to stay in it. A narrow column is
164
+ * also simply what a poster does: three short lines stacked hard left read
165
+ * as deliberate, where two long ones read as a caption that outgrew its box.
166
+ */
167
+ --hero-headline-width: 11ch;
168
+ --scrim: linear-gradient(to bottom, rgb(18 23 15 / 0.35) 0%, rgb(18 23 15 / 0.62) 55%, rgb(18 23 15 / 0.9) 100%);
169
+ --on-image: #ecefe3;
170
+ --on-image-muted: rgba(236, 239, 227, 0.82);
171
+ --on-image-subtle: #c5e035;
172
+
173
+ --quote-mark: #46630d;
174
+ --quote-mark-opacity: 1;
175
+
176
+ /* Nothing is rounded. */
177
+ --radius: 0px;
178
+ --radius-btn: 0px;
179
+ --radius-card: 0px;
180
+ --radius-feature: 0px;
181
+ --radius-logo: 0px;
182
+ --radius-input: 0px;
183
+ --radius-dropdown: 0px;
184
+
185
+ /* Flat. The panel/ground relationship does the separating. */
186
+ --card-shadow: none;
187
+ --shadow-btn: none;
188
+ --shadow-float: none;
189
+ --shadow-dropdown: 0 0 0 1px var(--rule);
190
+ --shadow-menu: 0 0 0 1px var(--rule);
191
+
192
+ /*
193
+ * Anton has one cut, so the display weight drops to 400 — asking a poster
194
+ * face for 800 gets you a synthesised bold that ruins its sidebearings.
195
+ */
196
+ --font-display: var(--font-anton), Impact, "Haettenschweiler", sans-serif;
197
+ --font-heading: var(--font-anton), Impact, "Haettenschweiler", sans-serif;
198
+ --font-body: var(--font-archivo), Helvetica, Arial, sans-serif;
199
+ --font-label: var(--font-archivo), Helvetica, Arial, sans-serif;
200
+ --font-quote: var(--font-anton), Impact, sans-serif;
201
+ --font-display-weight: 400;
202
+ --font-display-track: -0.005em;
203
+
204
+ /*
205
+ * ~120px BETWEEN sections, which is 56 a side — not 120 a side.
206
+ *
207
+ * Sections abut, so each one pays half the gap. Reading "section spacing
208
+ * holds at 120px" as 120 of padding put 240px of dead ground between a card
209
+ * row and the next heading, which is not a rhythm, it is a hole.
210
+ *
211
+ * 56 rather than 60 because 60 is not on the scale (4 8 12 16 24 32 40 56 80
212
+ * 120) and a step invented to make one sum come out is how a scale stops
213
+ * being one. 56 a side gives 112 between, four pixels off the number in the
214
+ * brief and indistinguishable from it on a page.
215
+ *
216
+ * This is the first thing that quietly collapses during an implementation,
217
+ * so it is one value: every block reads it, and no block sets its own.
218
+ */
219
+ --section-pad-y: 56px;
220
+ --section-pad-y-tight: 56px;
221
+ --section-pad-y-cta: 56px;
222
+ --section-pad-y-hero: 56px;
223
+ --section-pad-x: 40px;
224
+
225
+ --section-pad-y-sm: 32px;
226
+ --section-pad-y-tight-sm: 32px;
227
+ --section-pad-y-hero-sm: 32px;
228
+ --section-pad-x-sm: 16px;
229
+ --section-pad-x-tight-sm: 16px;
230
+ }
231
+
232
+ /* ── Dark, the primary ──────────────────────────────────────────────────── */
233
+
234
+ html.theme-night-harvest.dark {
235
+ color-scheme: dark;
236
+
237
+ --bg-0: #12170f;
238
+ --bg-000: #12170f;
239
+ --bg-100: #1c2317;
240
+ --bg-200: #1c2317;
241
+ --bg-300: #262f1f;
242
+ --bg-1: #1c2317;
243
+ --surface: #1c2317;
244
+ --card-bg: #1c2317;
245
+ --section-bg: #12170f;
246
+ --hero-bg: #12170f;
247
+ --cta-bg: #1c2317;
248
+ --stripe-bg: #1c2317;
249
+ --table-header-bg: #1c2317;
250
+ --section-bg-panel: #1c2317;
251
+
252
+ --text-100: #ecefe3;
253
+ --text-200: #ecefe3;
254
+ --text-300: #9aa38c;
255
+ --text-400: #9aa38c;
256
+ --text-500: #9aa38c;
257
+ --heading: #ecefe3;
258
+ --body: #ecefe3;
259
+ --body-secondary: #9aa38c;
260
+ --caption: #9aa38c;
261
+ --prose-body: #ecefe3;
262
+ --lead: #9aa38c;
263
+
264
+ /* Here the fill and the ink are the same value — chartreuse on #12170F is
265
+ legible as a word. That coincidence is why `--accent-ink` has to exist:
266
+ the light half proves it is a coincidence. */
267
+ --accent: #c5e035;
268
+ --accent-hover: #c5e035;
269
+ --accent-ink: #c5e035;
270
+ --on-accent: #12170f;
271
+ --brand: #c5e035;
272
+ --brand-hover: #ecefe3;
273
+ --brand-fg: #12170f;
274
+ --brand-subtle: rgba(197, 224, 53, 0.12);
275
+ --link: #c5e035;
276
+ --btn-primary-bg: #c5e035;
277
+ --btn-primary-fg: #12170f;
278
+
279
+ --band: #c5e035;
280
+ --on-band: #12170f;
281
+ --on-ink: #c5e035;
282
+
283
+ --rule: rgba(236, 239, 227, 0.2);
284
+ --border: rgba(236, 239, 227, 0.2);
285
+ --surface-border: rgba(236, 239, 227, 0.2);
286
+ --nav-border: rgba(236, 239, 227, 0.2);
287
+ --footer-border: rgba(236, 239, 227, 0.2);
288
+ --code-bg: rgba(236, 239, 227, 0.12);
289
+ --pre-bg: rgba(236, 239, 227, 0.08);
290
+
291
+ --nav-bg: rgba(18, 23, 15, 0.94);
292
+ --footer-bg: var(--section-bg-panel);
293
+ --footer-text: #9aa38c;
294
+ --footer-heading: #ecefe3;
295
+ --footer-link: #9aa38c;
296
+ --footer-link-hover: #c5e035;
297
+
298
+ --placeholder-img: #c5e035;
299
+ --media-bg: #12170f;
300
+
301
+ --quote-mark: #c5e035;
302
+ --quote-mark-opacity: 1;
303
+
304
+ --card-shadow: none;
305
+ --shadow-btn: none;
306
+ --shadow-float: none;
307
+ --shadow-dropdown: 0 0 0 1px var(--rule);
308
+ --shadow-menu: 0 0 0 1px var(--rule);
309
+
310
+ /* Status fills, restated so they sit in the palette rather than arriving
311
+ from the package's slate-and-emerald defaults. */
312
+ --banner-success-bg: #1c2317;
313
+ --banner-success-text: #c5e035;
314
+ --banner-warning-bg: #1c2317;
315
+ --banner-warning-text: #ecefe3;
316
+ --error-bg: #1c2317;
317
+ --error-border: rgba(236, 239, 227, 0.2);
318
+ --error-text: #ecefe3;
319
+ --error-detail: #9aa38c;
320
+ }
@@ -0,0 +1,333 @@
1
+ /*
2
+ * The demo's skin on the SiteHeader block, in a file of its own because it
3
+ * ships twice.
4
+ *
5
+ * `scripts/sync-demo-seed.mjs` copies this into `create-avocado-site`, which
6
+ * writes it into every scaffolded project — so a scaffold's header is the
7
+ * header on the demo site rather than a near-miss of it. Before that, the two
8
+ * agreed on markup and disagreed on four gaps, a padding and a breakpoint:
9
+ * identical HTML, a nav row 19px narrower, and a burger that appeared at a
10
+ * different window width.
11
+ *
12
+ * It is NOT in `@avocadostudio-ai/blocks`. The package's own header styles are
13
+ * the neutral default every integrator inherits, and quietly moving the demo's
14
+ * values into them would restyle the header of every site that installs the
15
+ * package to match one design. A skin belongs with the design it is part of.
16
+ *
17
+ * Generated file at the far end of the copy — edit this one.
18
+ */
19
+
20
+ /* Site chrome — navigation */
21
+
22
+ .site-top-nav {
23
+ position: sticky;
24
+ top: 0;
25
+ z-index: 20;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: flex-start;
29
+ gap: 18px;
30
+ padding: 14px 20px;
31
+ border-bottom: 1px solid var(--nav-border);
32
+ background: var(--nav-bg);
33
+ backdrop-filter: blur(4px);
34
+ container-type: inline-size;
35
+ container-name: site-nav;
36
+ }
37
+
38
+ .site-brand {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 10px;
42
+ min-width: 0;
43
+ text-decoration: none;
44
+ color: inherit;
45
+ }
46
+
47
+ .site-logo {
48
+ width: 38px;
49
+ height: 38px;
50
+ border: none;
51
+ border-radius: var(--radius-logo);
52
+ object-fit: contain;
53
+ display: block;
54
+ }
55
+
56
+ /* Size, weight and colour are the package's (`.site-brand`); restating them
57
+ here — at 800 and `var(--brand)` — made the wordmark a third green thing in
58
+ the header. Only the truncation is local. */
59
+ .site-brand-text {
60
+ white-space: nowrap;
61
+ overflow: hidden;
62
+ text-overflow: ellipsis;
63
+ }
64
+
65
+ .site-nav-links {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 8px;
69
+ flex-wrap: wrap;
70
+ }
71
+
72
+ .site-nav-links-desktop {
73
+ margin-left: 10px;
74
+ }
75
+
76
+ .site-mobile-menu {
77
+ display: none;
78
+ }
79
+
80
+ .site-mobile-menu-button {
81
+ /* A <button> starts from the UA's own 13.33px Arial. This app has a reset
82
+ that says otherwise and a scaffolded project does not, so the rule has to
83
+ be here rather than assumed. Same reason as `.site-theme-toggle`. */
84
+ font: inherit;
85
+ min-height: 44px;
86
+ min-width: 44px;
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ padding: 0;
91
+ border: 0;
92
+ background: transparent;
93
+ cursor: pointer;
94
+ }
95
+
96
+
97
+ .site-mobile-menu summary {
98
+ list-style: none;
99
+ cursor: pointer;
100
+ }
101
+
102
+ .site-mobile-menu summary::-webkit-details-marker {
103
+ display: none;
104
+ }
105
+
106
+ /*
107
+ * The burger's box, removed.
108
+ *
109
+ * It was a 38x36 bordered tile filled with `--brand-subtle` holding three
110
+ * green bars — the only element in the header wearing a container, sitting
111
+ * next to a theme control that is three strokes and nothing else. The package
112
+ * already draws the icon flat, in `currentColor`, so deleting the local
113
+ * override is the whole change: three lines, the same weight and colour as
114
+ * the icon beside them, on a 44px target the button still owns.
115
+ */
116
+
117
+ /*
118
+ * Only the underline treatment belongs to this site. Colour, size, weight and
119
+ * padding used to be restated here as brand/0.9375rem/700 — a tie on
120
+ * specificity with the package's own `.site-nav-links-desktop > a`, won on
121
+ * source order because this file is imported after it. The visible result was a
122
+ * nav in three resting colours: plain links green, group triggers green, and
123
+ * only the active item near-black. Whoever retuned the package rule never saw
124
+ * it take effect.
125
+ */
126
+ .site-nav-links a {
127
+ text-decoration: none;
128
+ text-decoration-thickness: 2px;
129
+ text-underline-offset: 0.32em;
130
+ }
131
+
132
+ /*
133
+ * The rule under a nav item is drawn, not switched on.
134
+ *
135
+ * A background gradient sized from 0% to 100% rather than a pseudo-element,
136
+ * because `background-size` interpolates and `text-decoration` does not — and
137
+ * it needs no extra box, so it cannot disturb the flex row it sits in.
138
+ */
139
+ @media (prefers-reduced-motion: no-preference) {
140
+ .site-nav-links-desktop > a {
141
+ background-image: linear-gradient(var(--accent-ink), var(--accent-ink));
142
+ background-repeat: no-repeat;
143
+ background-position: 0.75rem 78%;
144
+ background-size: 0% 2px;
145
+ transition:
146
+ background-size var(--motion-fast) var(--ease-silk),
147
+ color var(--motion-fast) var(--ease-silk);
148
+ }
149
+
150
+ .site-nav-links-desktop > a:hover {
151
+ background-size: calc(100% - 1.5rem) 2px;
152
+ }
153
+
154
+ /* The active item already carries its own underline; sweeping a second one
155
+ underneath it just thickens the first. */
156
+ .site-nav-links-desktop > a.is-active:hover {
157
+ background-size: 0% 2px;
158
+ }
159
+ }
160
+
161
+ /*
162
+ * The active item was near-black under a 4px slab while its neighbours were
163
+ * muted grey — two jumps at once, weight and colour, for a state that only has
164
+ * to say "you are here". A 2px rule in the accent says it once, and leaves the
165
+ * colour difference to do the rest.
166
+ */
167
+ .site-nav-links a.is-active {
168
+ text-decoration: underline;
169
+ text-decoration-thickness: 2px;
170
+ text-decoration-color: var(--accent-ink);
171
+ }
172
+
173
+ .site-top-nav:has(.site-mobile-menu.is-open) {
174
+ border-bottom: none !important;
175
+ }
176
+
177
+ /*
178
+ * The scrim behind an open mobile menu.
179
+ *
180
+ * Top level, not inside the container query below, and that is the whole bug
181
+ * it used to have: `@container site-nav` only matches descendants of the
182
+ * header, and the backdrop is a sibling of it. So none of this applied, the
183
+ * package's own `z-index: 99` stood, and a scrim meant to sit *under* the
184
+ * header (`z-index: 20`) sat over it — swallowing every tap inside the open
185
+ * menu. Expanding a nav group did nothing; the click never reached the button.
186
+ */
187
+ .site-mobile-backdrop {
188
+ position: fixed;
189
+ top: 0;
190
+ left: 0;
191
+ right: 0;
192
+ bottom: 0;
193
+ z-index: 19;
194
+ background: rgb(from var(--nav-bg) r g b / 0.5);
195
+ backdrop-filter: blur(4px);
196
+ }
197
+
198
+ /* Mobile — uses a container query on the header so the burger menu kicks in
199
+ based on the header's own width (works inside the editor iframe regardless
200
+ of viewport width). */
201
+
202
+ @container site-nav (max-width: 900px) {
203
+ .site-top-nav {
204
+ align-items: center;
205
+ flex-direction: row;
206
+ justify-content: space-between;
207
+ flex-wrap: wrap;
208
+ padding: 12px 16px;
209
+ }
210
+
211
+ .site-nav-links-desktop {
212
+ display: none;
213
+ }
214
+
215
+ /* The burger keeps the outer edge: it is the one control a thumb reaches
216
+ for without looking. The theme control sits inboard of it and carries the
217
+ `auto` margin for both — two auto margins in one row do not stack, they
218
+ split the free space between them. */
219
+ .site-mobile-menu {
220
+ display: block;
221
+ order: 3;
222
+ position: static;
223
+ z-index: 3;
224
+ margin-left: 0;
225
+ }
226
+
227
+ /* Flat in every state — three lines and nothing else. The open state used
228
+ to tint the button green, which drew a box round the burger at exactly
229
+ the moment the menu below it is already saying the same thing. */
230
+
231
+ .site-mobile-menu.is-open .burger-icon i:nth-child(1) {
232
+ transform: translateY(6px) rotate(45deg);
233
+ }
234
+
235
+ .site-mobile-menu.is-open .burger-icon i:nth-child(2) {
236
+ opacity: 0;
237
+ }
238
+
239
+ .site-mobile-menu.is-open .burger-icon i:nth-child(3) {
240
+ transform: translateY(-6px) rotate(-45deg);
241
+ }
242
+
243
+ .burger-icon i {
244
+ transition: transform 0.2s ease, opacity 0.2s ease;
245
+ }
246
+
247
+ .site-top-nav-inner > .site-theme-toggle {
248
+ margin-left: auto;
249
+ order: 2;
250
+ }
251
+
252
+ .site-mobile-menu summary {
253
+ min-height: 44px;
254
+ min-width: 44px;
255
+ display: flex;
256
+ align-items: center;
257
+ justify-content: center;
258
+ }
259
+
260
+ .site-nav-links-mobile {
261
+ position: absolute;
262
+ top: 100%;
263
+ left: 0;
264
+ right: 0;
265
+ width: 100%;
266
+ max-height: min(65vh, 520px);
267
+ overflow-y: auto;
268
+ display: flex;
269
+ flex-direction: column;
270
+ /*
271
+ * `stretch`, not the `center` this inherits from `.site-nav-links`.
272
+ *
273
+ * The base rule centres a horizontal row of links, which is right for the
274
+ * desktop header and wrong the moment the same element becomes a column:
275
+ * every row shrank to the width of its own label and sat centred, so the
276
+ * menu read as five differently-sized pills down the middle of the screen.
277
+ * The `text-align: left` below has been there the whole time and never had
278
+ * a box wide enough to act in.
279
+ */
280
+ align-items: stretch;
281
+ gap: 0;
282
+ padding: 6px 16px;
283
+ border-bottom: 1px solid var(--nav-border);
284
+ /* Opaque. It was `--brand-subtle`, a translucent accent wash, so the page
285
+ scrolled visibly underneath the open menu. */
286
+ background: var(--bg-100);
287
+ box-shadow: var(--shadow-menu);
288
+ z-index: 20;
289
+ }
290
+
291
+ /*
292
+ * One row treatment for every row.
293
+ *
294
+ * A plain link was 46px tall at 14px with a hairline under it; a dropdown
295
+ * trigger was 37px tall at 16px with none. Three row heights and two type
296
+ * sizes in a five-item menu, and the hairlines landing under some rows and
297
+ * not others — which is what made one divider look like a stray rule rather
298
+ * than a list.
299
+ */
300
+ .site-nav-links-mobile > a,
301
+ .site-nav-links-mobile .site-nav-mobile-group-trigger {
302
+ display: flex;
303
+ align-items: center;
304
+ width: 100%;
305
+ text-align: left;
306
+ min-height: var(--tap-min);
307
+ padding: 12px 4px;
308
+ font-size: 0.9375rem;
309
+ border-bottom: 1px solid var(--nav-border);
310
+ border-radius: 0;
311
+ text-decoration: none;
312
+ }
313
+
314
+ .site-nav-links-mobile > *:last-child > .site-nav-mobile-group-trigger,
315
+ .site-nav-links-mobile > a:last-child {
316
+ border-bottom: 0;
317
+ }
318
+
319
+ /* The nested children of an open group are one step in and one step
320
+ quieter, so the group reads as a group rather than as more top-level
321
+ items that happen to be indented. */
322
+ .site-nav-links-mobile .site-nav-mobile-group-children a {
323
+ min-height: var(--tap-min);
324
+ display: flex;
325
+ align-items: center;
326
+ font-size: 0.9375rem;
327
+ }
328
+
329
+ .site-nav-links-mobile a.is-active {
330
+ font-weight: 700;
331
+ text-decoration: none;
332
+ }
333
+ }
@@ -0,0 +1,104 @@
1
+ /*
2
+ * The theme control's styles, in a file of their own because they ship twice.
3
+ *
4
+ * `theme-toggle.tsx` and this stylesheet are copied into
5
+ * `create-avocado-site` by `scripts/sync-demo-seed.mjs` and written into every
6
+ * scaffolded project, so a scaffold gets the same control the demo site has
7
+ * rather than a pre-paint script reading a storage key nothing ever writes.
8
+ *
9
+ * They are NOT in `@avocadostudio-ai/blocks`: the SiteHeader block does not
10
+ * render a theme control, and CSS in the package for an element the package
11
+ * never emits is the dead-stylesheet trap documented in
12
+ * `blocks/site-header/styles.css`. The host app owns the control; the host app
13
+ * owns its styles; one source file feeds both hosts.
14
+ *
15
+ * Breakpoint-independent on purpose. The app collapses its nav at 900px and
16
+ * the package's own header collapses at 768px, so the rule that reorders this
17
+ * control against the burger lives with each of those media queries rather
18
+ * than here.
19
+ */
20
+
21
+ /*
22
+ * The theme control, in the footer.
23
+ *
24
+ * It was styled as a 38x36 icon square for a header slot it was never
25
+ * actually placed in. In the footer it can afford a label, which removes the
26
+ * guess a lone sun-or-moon glyph always asks of the reader: does the icon
27
+ * show the theme I am in, or the one I would get?
28
+ */
29
+ .site-theme-bar {
30
+ display: flex;
31
+ justify-content: center;
32
+ padding: 0 var(--section-pad-x) var(--section-pad-y-tight);
33
+ background: var(--footer-bg);
34
+ }
35
+
36
+ /*
37
+ * Icon only, and square.
38
+ *
39
+ * It carried a text label — "System" / "Light" / "Dark" — which on a strip of
40
+ * its own read as a lone word in a box rather than as a control. The meaning
41
+ * moves to `aria-label` and `title`, where a screen reader and a hover can
42
+ * both reach it, and the button becomes the one square thing a reader's eye
43
+ * can skip until they want it.
44
+ */
45
+ /*
46
+ * Borderless.
47
+ *
48
+ * It was a 44px square with a 1px rule round it holding an 18px hairline
49
+ * glyph — the box was the loudest part of the control and the icon the
50
+ * quietest, which is backwards for something a reader should be able to skip
51
+ * until they want it. The target stays 44px; only the box goes.
52
+ */
53
+ .site-theme-toggle {
54
+ /* A <button> does not inherit the page's font, and outside this app there is
55
+ no CSS reset to say otherwise — the control came out at the UA's 13.33px
56
+ Arial in a scaffolded project. Nothing visible sits inside it, so the only
57
+ way to see it is to measure it. */
58
+ font: inherit;
59
+ display: inline-flex;
60
+ align-items: center;
61
+ justify-content: center;
62
+ flex: none;
63
+ width: var(--tap-min);
64
+ height: var(--tap-min);
65
+ padding: 0;
66
+ border: 0;
67
+ border-radius: var(--radius-btn);
68
+ background: transparent;
69
+ color: var(--text-300);
70
+ cursor: pointer;
71
+ transition: color 0.15s;
72
+ }
73
+
74
+ /* Colour only. A filled hover square is the bordered box coming back in a
75
+ different guise — on an icon button the glyph is the affordance. */
76
+ .site-theme-toggle:hover {
77
+ color: var(--accent-ink);
78
+ }
79
+
80
+ /* On the footer's own ground, which is a different surface from the page. */
81
+ .site-theme-bar .site-theme-toggle {
82
+ color: var(--footer-link);
83
+ }
84
+
85
+ .site-theme-bar .site-theme-toggle:hover {
86
+ color: var(--footer-link-hover);
87
+ }
88
+
89
+ /* Portalled into the header's inner flex row; `auto` pushes it to the far
90
+ right without the row needing to know it is there. */
91
+ .site-top-nav-inner > .site-theme-toggle {
92
+ margin-left: auto;
93
+ }
94
+
95
+ .site-theme-toggle-icon {
96
+ /* The attributes on the element carry the real size; these are belt and
97
+ braces for a flex context. Fill and stroke are set per shape on the
98
+ element now — these icons are solid forms with stroked rays, not one
99
+ uniformly-outlined drawing. */
100
+ flex: none;
101
+ width: 20px;
102
+ height: 20px;
103
+ stroke-linejoin: round;
104
+ }
@@ -0,0 +1,196 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useState } from "react"
4
+ import { createPortal } from "react-dom"
5
+
6
+ const SITE_THEME_STORAGE_KEY = "site-theme-v1"
7
+
8
+ type Choice = "system" | "light" | "dark"
9
+
10
+ const ORDER: Choice[] = ["system", "light", "dark"]
11
+ const LABEL: Record<Choice, string> = { system: "System", light: "Light", dark: "Dark" }
12
+
13
+ /**
14
+ * Three states, not two: follow the system, or pin light, or pin dark.
15
+ *
16
+ * "Follow the system" is the absence of a stored value, which is what makes it
17
+ * a real third state rather than a label on one of the other two — a visitor
18
+ * who has never touched the control tracks their OS for as long as the page is
19
+ * open, and one who has chosen keeps their choice across visits.
20
+ *
21
+ * `prefers-color-scheme: light` and not `: dark`. Night Harvest is a dark
22
+ * theme with a light half, so the fallback is dark and light is the opt-in.
23
+ * That is the same way round as the token contract itself, where the light
24
+ * values sit inside a `prefers-color-scheme: light` query and dark is what
25
+ * `:root` says. A browser that expresses no preference gets the theme's
26
+ * primary state rather than its secondary one.
27
+ *
28
+ * It writes the `dark` class on `<html>`, not `data-theme`. The brief asks for
29
+ * the attribute and the attribute would be the nicer contract, but the class
30
+ * is already what decides the theme here: `_tokens.css` keys its dark half on
31
+ * `.dark`, so does `theme-modern-orange.css`, so does every consumer of the
32
+ * blocks package, and so does the pre-paint script in `layout.tsx`. Two
33
+ * mechanisms for one state is how a theme ends up half-applied, so this is
34
+ * flagged rather than fudged.
35
+ */
36
+ /*
37
+ * Both copies of the control answer to one state.
38
+ *
39
+ * There are two on the page — one in the header, one in the footer — and two
40
+ * React instances do not share a `useState`. Without this, clicking the header
41
+ * button changed the theme and left the footer button still reporting the old
42
+ * one, which is the kind of thing that looks like a caching bug.
43
+ */
44
+ const THEME_EVENT = "site-theme-change"
45
+
46
+ export function SiteThemeToggle({ portalTo }: { portalTo?: string } = {}) {
47
+ const [choice, setChoice] = useState<Choice>("system")
48
+ const [resolved, setResolved] = useState<"light" | "dark">("dark")
49
+
50
+ /* Kept identical to the pre-paint script in `layout.tsx`. If the two ever
51
+ disagree the page paints one theme and then visibly switches to the
52
+ other, which is the flash the script exists to prevent. */
53
+ const resolve = useCallback((c: Choice): "light" | "dark" => {
54
+ if (c !== "system") return c
55
+ return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark"
56
+ }, [])
57
+
58
+ const [host, setHost] = useState<Element | null>(null)
59
+ useEffect(() => {
60
+ if (!portalTo) return
61
+ /*
62
+ * The header is a block renderer's output, not this app's markup, so the
63
+ * control is placed into it after mount rather than by editing the
64
+ * package. A portal keeps it a real child of the nav — it inherits the
65
+ * sticky positioning and the flex row, and needs no absolute placement
66
+ * guessing at the header's height.
67
+ */
68
+ setHost(document.querySelector(portalTo))
69
+ }, [portalTo])
70
+
71
+ useEffect(() => {
72
+ const stored = window.localStorage.getItem(SITE_THEME_STORAGE_KEY)
73
+ const next: Choice = stored === "light" || stored === "dark" ? stored : "system"
74
+ setChoice(next)
75
+ setResolved(resolve(next))
76
+ }, [resolve])
77
+
78
+ /* Track the OS for as long as the visitor is on "system". Re-subscribing on
79
+ every change of `choice` is what stops a pinned theme from being dragged
80
+ back by the OS mid-session. */
81
+ useEffect(() => {
82
+ if (choice !== "system") return
83
+ const mq = window.matchMedia?.("(prefers-color-scheme: light)")
84
+ if (!mq) return
85
+ const onChange = () => setResolved(resolve("system"))
86
+ mq.addEventListener("change", onChange)
87
+ return () => mq.removeEventListener("change", onChange)
88
+ }, [choice, resolve])
89
+
90
+ useEffect(() => {
91
+ const onPeer = (e: Event) => {
92
+ const next = (e as CustomEvent<Choice>).detail
93
+ setChoice(next)
94
+ setResolved(resolve(next))
95
+ }
96
+ window.addEventListener(THEME_EVENT, onPeer)
97
+ return () => window.removeEventListener(THEME_EVENT, onPeer)
98
+ }, [resolve])
99
+
100
+ useEffect(() => {
101
+ const root = window.document.documentElement
102
+ /*
103
+ * Colour transitions are right for a hover and wrong for a theme change:
104
+ * every button, link and nav item carries one, so flipping the control
105
+ * sent all of them on their own 150ms journey and the page dissolved
106
+ * rather than switched. This kills transitions for exactly one frame —
107
+ * see `[data-theme-switching]` in the blocks package's `_base.css`.
108
+ */
109
+ root.setAttribute("data-theme-switching", "")
110
+ root.classList.toggle("dark", resolved === "dark")
111
+ const frame = window.requestAnimationFrame(() =>
112
+ window.requestAnimationFrame(() => root.removeAttribute("data-theme-switching"))
113
+ )
114
+ return () => window.cancelAnimationFrame(frame)
115
+ }, [resolved])
116
+
117
+ const handleClick = () => {
118
+ const next = ORDER[(ORDER.indexOf(choice) + 1) % ORDER.length]
119
+ setChoice(next)
120
+ setResolved(resolve(next))
121
+ window.dispatchEvent(new CustomEvent(THEME_EVENT, { detail: next }))
122
+ /*
123
+ * Not inside the preview iframe: an editor session should not leave a
124
+ * pinned preference behind in the published site's own storage.
125
+ */
126
+ if (window.parent === window) {
127
+ try {
128
+ if (next === "system") window.localStorage.removeItem(SITE_THEME_STORAGE_KEY)
129
+ else window.localStorage.setItem(SITE_THEME_STORAGE_KEY, next)
130
+ } catch {
131
+ // Blocked storage — the class toggle above still applies for this visit.
132
+ }
133
+ }
134
+ }
135
+
136
+ const nextLabel = LABEL[ORDER[(ORDER.indexOf(choice) + 1) % ORDER.length]]
137
+
138
+ const button = (
139
+ <button
140
+ type="button"
141
+ className="site-theme-toggle"
142
+ onClick={handleClick}
143
+ /* Icon only, so the label has to live somewhere a screen reader can
144
+ reach. It reports the state the control is IN and names the one the
145
+ next press gives — a control that only says what it will do leaves a
146
+ reader guessing what it is currently doing. */
147
+ aria-label={`Theme: ${LABEL[choice]}. Switch to ${nextLabel}.`}
148
+ title={`Theme: ${LABEL[choice]} — switch to ${nextLabel}`}
149
+ >
150
+ {/*
151
+ `width`/`height` attributes as well as the CSS. An inline SVG carries
152
+ no intrinsic size, so as a flex item it resolves to a zero-width
153
+ content box and the CSS width loses to `flex-shrink` — the icon
154
+ measured 0x16 and the button looked like an empty bordered square.
155
+ */}
156
+ <svg
157
+ className="site-theme-toggle-icon"
158
+ width="20"
159
+ height="20"
160
+ viewBox="0 0 24 24"
161
+ /* Outline only, and declared once here so every shape inherits it.
162
+ The set used to mix filled forms with stroked rays — a solid screen,
163
+ a solid disc, a solid crescent — which read as three weights of icon
164
+ rather than one family. One stroke width, no fills. */
165
+ fill="none"
166
+ stroke="currentColor"
167
+ strokeWidth="1.8"
168
+ strokeLinecap="round"
169
+ strokeLinejoin="round"
170
+ aria-hidden="true"
171
+ >
172
+ {choice === "system" ? (
173
+ /* A monitor: "whatever this machine is set to". */
174
+ <>
175
+ <rect x="2.8" y="4.2" width="18.4" height="12.6" rx="2.2" />
176
+ <path d="M12 16.8v3.4M8.4 20.2h7.2" />
177
+ </>
178
+ ) : choice === "light" ? (
179
+ /* A ring and eight rays. The rays stop short of the ring so the two
180
+ stay legible as separate strokes at 20px. */
181
+ <>
182
+ <circle cx="12" cy="12" r="4.4" />
183
+ <path d="M12 2.2v2.4M12 19.4v2.4M2.2 12h2.4M19.4 12h2.4M5.1 5.1l1.7 1.7M17.2 17.2l1.7 1.7M18.9 5.1l-1.7 1.7M6.8 17.2l-1.7 1.7" />
184
+ </>
185
+ ) : (
186
+ /* A crescent cut from one disc by another, so the terminator is a
187
+ true circular arc rather than a drawn curve. */
188
+ <path d="M21.4 14.2A9.6 9.6 0 1 1 10.3 2.4a7.6 7.6 0 0 0 11.1 11.8Z" />
189
+ )}
190
+ </svg>
191
+ </button>
192
+ )
193
+
194
+ if (!portalTo) return button
195
+ return host ? createPortal(button, host) : null
196
+ }