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.
- package/assets/logos/avocado-hub.svg +21 -7
- package/dist/templates/demo-app.js +276 -9
- package/dist/versions.d.ts +1 -1
- package/dist/versions.js +1 -1
- package/package.json +5 -4
- package/theme/night-harvest.css +320 -0
- package/theme/site-chrome.css +333 -0
- package/theme/theme-toggle.css +104 -0
- package/theme/theme-toggle.tsx +196 -0
|
@@ -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">
|
|
4
|
-
<!--
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
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
|
|
445
|
-
|
|
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
|
}
|
package/dist/versions.d.ts
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 declare const AVOCADO = "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.
|
|
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.
|
|
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.
|
|
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/
|
|
39
|
-
"@avocadostudio-ai/
|
|
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
|
+
}
|