cursedbelt 5.63.0 → 5.64.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.
Files changed (63) hide show
  1. package/dist/looks.css +237 -0
  2. package/dist/react/components/Button.d.ts.map +1 -1
  3. package/dist/react/components/Button.js +13 -3
  4. package/dist/react/components/Button.js.map +1 -1
  5. package/dist/react/components/CopyButton.d.ts.map +1 -1
  6. package/dist/react/components/CopyButton.js +2 -1
  7. package/dist/react/components/CopyButton.js.map +1 -1
  8. package/dist/react/components/auth/SignInCard.d.ts +9 -1
  9. package/dist/react/components/auth/SignInCard.d.ts.map +1 -1
  10. package/dist/react/components/auth/SignInCard.js +2 -2
  11. package/dist/react/components/auth/SignInCard.js.map +1 -1
  12. package/dist/react/index.d.ts +3 -0
  13. package/dist/react/index.d.ts.map +1 -1
  14. package/dist/react/index.js +3 -0
  15. package/dist/react/index.js.map +1 -1
  16. package/dist/react/look/LookPicker.d.ts +27 -0
  17. package/dist/react/look/LookPicker.d.ts.map +1 -0
  18. package/dist/react/look/LookPicker.js +29 -0
  19. package/dist/react/look/LookPicker.js.map +1 -0
  20. package/dist/react/look/lookStore.d.ts +88 -0
  21. package/dist/react/look/lookStore.d.ts.map +1 -0
  22. package/dist/react/look/lookStore.js +120 -0
  23. package/dist/react/look/lookStore.js.map +1 -0
  24. package/dist/react/look/useLook.d.ts +22 -0
  25. package/dist/react/look/useLook.d.ts.map +1 -0
  26. package/dist/react/look/useLook.js +16 -0
  27. package/dist/react/look/useLook.js.map +1 -0
  28. package/dist/styles-areas/calendar.css +2 -2
  29. package/dist/styles-areas/chat.css +7 -7
  30. package/dist/styles-areas/core.css +38 -37
  31. package/dist/styles-areas/emoji.css +3 -3
  32. package/dist/styles-areas/folder-tree.css +3 -3
  33. package/dist/styles-areas/layout-engine.css +6 -6
  34. package/dist/styles-areas/media-gallery.css +3 -3
  35. package/dist/styles-areas/media.css +4 -4
  36. package/dist/styles-areas/overlays.css +4 -4
  37. package/dist/styles-areas/workbench.css +7 -7
  38. package/dist/styles-utilities.css +16 -0
  39. package/package.json +2 -1
  40. package/scripts/styleAreas.ts +2 -0
  41. package/src/looks.css +237 -0
  42. package/src/looks.spec.ts +128 -0
  43. package/src/react/components/Button.tsx +14 -3
  44. package/src/react/components/CopyButton.tsx +2 -1
  45. package/src/react/components/auth/SignInCard.spec.tsx +9 -0
  46. package/src/react/components/auth/SignInCard.tsx +10 -1
  47. package/src/react/index.ts +22 -0
  48. package/src/react/look/LookPicker.spec.tsx +40 -0
  49. package/src/react/look/LookPicker.tsx +112 -0
  50. package/src/react/look/lookStore.spec.ts +120 -0
  51. package/src/react/look/lookStore.ts +172 -0
  52. package/src/react/look/useLook.ts +37 -0
  53. package/src/styles-areas/calendar.css +2 -2
  54. package/src/styles-areas/chat.css +7 -7
  55. package/src/styles-areas/core.css +38 -37
  56. package/src/styles-areas/emoji.css +3 -3
  57. package/src/styles-areas/folder-tree.css +3 -3
  58. package/src/styles-areas/layout-engine.css +6 -6
  59. package/src/styles-areas/media-gallery.css +3 -3
  60. package/src/styles-areas/media.css +4 -4
  61. package/src/styles-areas/overlays.css +4 -4
  62. package/src/styles-areas/workbench.css +7 -7
  63. package/src/styles-utilities.css +16 -0
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 23 candidate(s).
21
+ * 22 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -26,5 +26,5 @@
26
26
  */
27
27
  @source inline("[&>*]:min-w-0 bg-[var(--card)] bg-[var(--muted)]/10 bg-[var(--muted)]/20 bg-accent/15 collapse");
28
28
  @source inline("duration-150 hover:decoration-border hover:underline-offset-4 max-h-72 max-w max-w-48 max-w-80");
29
- @source inline("min-w-8 mx-0.5 p-1.5 place-items-center px-0.5 ring-1 ring-[var(--accent)] rotate-180");
30
- @source inline("self-stretch text-[var(--danger)]");
29
+ @source inline("min-w-8 mx-0.5 place-items-center px-0.5 ring-1 ring-[var(--accent)] rotate-180 self-stretch");
30
+ @source inline("text-[var(--danger)]");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 61 candidate(s).
21
+ * 59 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -30,8 +30,8 @@
30
30
  @source inline("bg-tone-purple-bg/50 bg-tone-rose-bg/50 bg-tone-sky-bg/50 bg-tone-slate-bg/50 border-2");
31
31
  @source inline("border-current/20 border-dashed col-span-12 col-span-6 collapse focus:border-accent focus:ring-1");
32
32
  @source inline("focus:ring-ring gap-y-3 grid-cols-1 grid-cols-12 group-focus-within:opacity-100");
33
- @source inline("hover:bg-accent/5 hover:border-border-strong lg:flex-row lg:inset-8 lg:items-start lg:w-72 p-1.5");
34
- @source inline("p-5 pt-1 ring-1 ring-accent ring-accent/40 self-stretch sm:border sm:border-border sm:inset-4");
35
- @source inline("text-3xl text-muted-foreground/60 text-muted-foreground/70 text-tone-amber-ink");
36
- @source inline("text-tone-cyan-ink text-tone-emerald-ink text-tone-purple-ink text-tone-rose-ink");
37
- @source inline("text-tone-sky-ink text-tone-slate-ink text-xl");
33
+ @source inline("hover:bg-accent/5 lg:flex-row lg:inset-8 lg:items-start lg:w-72 p-5 pt-1 ring-1 ring-accent");
34
+ @source inline("ring-accent/40 self-stretch sm:border sm:border-border sm:inset-4 text-3xl");
35
+ @source inline("text-muted-foreground/60 text-muted-foreground/70 text-tone-amber-ink text-tone-cyan-ink");
36
+ @source inline("text-tone-emerald-ink text-tone-purple-ink text-tone-rose-ink text-tone-sky-ink");
37
+ @source inline("text-tone-slate-ink text-xl");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 57 candidate(s).
21
+ * 56 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -28,8 +28,8 @@
28
28
  @source inline("bg-black/95 bg-white/10 border-none bottom-1 col collapse ease-in-out f");
29
29
  @source inline("focus-visible:ring-offset-black focus-within:border-field-ring focus-within:ring-2");
30
30
  @source inline("focus-within:ring-field-ring/30 hover:bg-white/20 hover:border-accent-solid hover:text-white");
31
- @source inline("inset-4 left-1 max-h-[40vh] min-h-20 min-h-dvh min-w-12 min-w-16 min-w-20 min-w-24 mx-1 p-1.5");
32
- @source inline("pl-2 place-items-center pr-1 resize-none right-1 right-4 size-9 text-tone-amber-ink");
31
+ @source inline("inset-4 left-1 max-h-[40vh] min-h-20 min-h-dvh min-w-12 min-w-16 min-w-20 min-w-24 mx-1 pl-2");
32
+ @source inline("place-items-center pr-1 resize-none right-1 right-4 size-9 text-tone-amber-ink");
33
33
  @source inline("text-tone-cyan-ink text-tone-emerald-ink text-tone-orange-ink text-tone-purple-ink");
34
34
  @source inline("text-tone-rose-ink text-tone-sky-ink text-tone-slate-ink text-white/70 top-1 top-4 translate");
35
35
  @source inline("translate-x-px w-40 w-48 zoom zoom-in");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 82 candidate(s).
21
+ * 81 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -26,9 +26,9 @@
26
26
  */
27
27
  @source inline("[&_svg]:max-h-10 [&_svg]:min-h-5 [&_svg]:size-1/3 accent-white active:cursor-grabbing");
28
28
  @source inline("appearance-none aspect-square aspect-video backdrop-blur-xs bg-accent/15 bg-background/65");
29
- @source inline("bg-background/85 bg-black bg-black/55 bg-black/60 bg-black/85 bg-black/90 bg-card-raised");
30
- @source inline("bg-center bg-cover bg-linear-to-t bg-white/90 border-2 border-accent-solid border-white/90");
31
- @source inline("border-y-2 bottom-1 bottom-1.5 bottom-2 cursor-crosshair cursor-grab cursor-zoom-in ease-in-out");
29
+ @source inline("bg-background/85 bg-black bg-black/55 bg-black/60 bg-black/85 bg-black/90 bg-center bg-cover");
30
+ @source inline("bg-linear-to-t bg-white/90 border-2 border-accent-solid border-white/90 border-y-2 bottom-1");
31
+ @source inline("bottom-1.5 bottom-2 cursor-crosshair cursor-grab cursor-zoom-in ease-in-out");
32
32
  @source inline("focus-within:opacity-100 from-black/80 g grayscale group-focus-within:opacity-100 h-11");
33
33
  @source inline("hover:bg-black/75 hover:bg-destructive hover:bg-white/20 hover:border-accent-solid invert left-0");
34
34
  @source inline("left-1 max-h-[40vh] max-h-[var(--media-max-h)] max-h-full max-w-50 min-h-64 min-h-dvh min-w-10");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 25 candidate(s).
21
+ * 24 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -28,6 +28,6 @@
28
28
  @source inline("data-[state=closed]:slide-out-to-bottom data-[state=closed]:slide-out-to-left");
29
29
  @source inline("data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-bottom");
30
30
  @source inline("data-[state=open]:slide-in-from-left data-[state=open]:slide-in-from-right hover:border-border");
31
- @source inline("hover:border-border-strong left-0 max-h-[var(--radix-popover-content-available-height)]");
32
- @source inline("max-h-[var(--sheet-h-lg)] max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)]");
33
- @source inline("max-w-[var(--overlay-max-w)] max-w-sm rounded-l-xl rounded-r-xl rounded-t-xl w-64");
31
+ @source inline("left-0 max-h-[var(--radix-popover-content-available-height)] max-h-[var(--sheet-h-lg)]");
32
+ @source inline("max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)] max-w-[var(--overlay-max-w)] max-w-sm");
33
+ @source inline("rounded-l-xl rounded-r-xl rounded-t-xl w-64");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 109 candidate(s).
21
+ * 108 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -32,12 +32,12 @@
32
32
  @source inline("data-[state=open]:slide-in-from-bottom data-[state=open]:slide-in-from-left");
33
33
  @source inline("data-[state=open]:slide-in-from-right disabled:opacity-0 duration-150 first:pt-1 flex-row");
34
34
  @source inline("group-hover:disabled:opacity-50 h-105 h-40 hover:bg-card-overlay hover:bg-muted-foreground/10");
35
- @source inline("hover:bg-muted/80 hover:border-border-strong hover:border-field-ring hover:shadow-popover italic");
36
- @source inline("left-0 left-2.5 max-h-[var(--sheet-h-lg)] max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)]");
37
- @source inline("max-h-full max-w-sm min-h min-h-20 min-h-8 min-h-9 min-h-dvh min-h-full min-w-20 min-w-40");
38
- @source inline("opacity-100 pb-0.5 pb-[max(env(safe-area-inset-bottom,0px),0.5rem)] peer-focus");
39
- @source inline("peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-field-ring/40 peer-focus:rounded-lg");
40
- @source inline("pl-1 pl-3 pointer-coarse:disabled:opacity-50 pointer-coarse:h-12 pointer-coarse:w-12 pr-16 pr-9");
35
+ @source inline("hover:bg-muted/80 hover:border-field-ring hover:shadow-popover italic left-0 left-2.5");
36
+ @source inline("max-h-[var(--sheet-h-lg)] max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)] max-h-full");
37
+ @source inline("max-w-sm min-h min-h-20 min-h-8 min-h-9 min-h-dvh min-h-full min-w-20 min-w-40 opacity-100");
38
+ @source inline("pb-0.5 pb-[max(env(safe-area-inset-bottom,0px),0.5rem)] peer-focus peer-focus:outline-none");
39
+ @source inline("peer-focus:ring-2 peer-focus:ring-field-ring/40 peer-focus:rounded-lg pl-1 pl-3");
40
+ @source inline("pointer-coarse:disabled:opacity-50 pointer-coarse:h-12 pointer-coarse:w-12 pr-16 pr-9");
41
41
  @source inline("resize-none right-4 ring-1 ring-4 ring-accent/20 ring-accent/30 ring-accent/40 ring-border");
42
42
  @source inline("ring-inset ring-ring rounded-[var(--radius-device)] rounded-l-xl rounded-r-xl rounded-t-xl");
43
43
  @source inline("scale-[1.01] self-stretch shrink-[2] size-7 sm:flex-1 sm:flex-nowrap sm:shrink-0 sm:w-56");
@@ -2135,6 +2135,12 @@
2135
2135
  .bg-field-bg {
2136
2136
  background-color: var(--field-bg);
2137
2137
  }
2138
+ .bg-foreground\/70 {
2139
+ background-color: var(--foreground);
2140
+ @supports (color: color-mix(in lab, red, red)) {
2141
+ background-color: color-mix(in oklab, var(--foreground) 70%, transparent);
2142
+ }
2143
+ }
2138
2144
  .bg-green-100 {
2139
2145
  background-color: var(--color-green-100);
2140
2146
  }
@@ -3540,6 +3546,9 @@
3540
3546
  .group-data-\[density\=compact\]\/properties\:items-start:is(:where(.group\/properties)[data-density="compact"] *) {
3541
3547
  align-items: flex-start;
3542
3548
  }
3549
+ .group-data-\[state\=checked\]\:opacity-100:is(:where(.group)[data-state="checked"] *) {
3550
+ opacity: 100%;
3551
+ }
3543
3552
  .group-data-\[state\=open\]\:rotate-90:is(:where(.group)[data-state="open"] *) {
3544
3553
  rotate: 90deg;
3545
3554
  }
@@ -4273,6 +4282,10 @@
4273
4282
  .data-\[state\=checked\]\:text-white[data-state="checked"] {
4274
4283
  color: var(--color-white);
4275
4284
  }
4285
+ .data-\[state\=checked\]\:ring-1[data-state="checked"] {
4286
+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
4287
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
4288
+ }
4276
4289
  .data-\[state\=checked\]\:ring-2[data-state="checked"] {
4277
4290
  --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
4278
4291
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -4557,6 +4570,9 @@
4557
4570
  .sm\:grid-cols-3 {
4558
4571
  grid-template-columns: repeat(3, minmax(0, 1fr));
4559
4572
  }
4573
+ .sm\:grid-cols-4 {
4574
+ grid-template-columns: repeat(4, minmax(0, 1fr));
4575
+ }
4560
4576
  .sm\:grid-cols-\[minmax\(7rem\,18rem\)_minmax\(4rem\,1fr\)_auto\] {
4561
4577
  grid-template-columns: minmax(7rem,18rem) minmax(4rem,1fr) auto;
4562
4578
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.63.0",
3
+ "version": "5.64.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -689,6 +689,7 @@
689
689
  "default": "./dist/testing/_noBun.js"
690
690
  },
691
691
  "./theme.css": "./src/theme.css",
692
+ "./looks.css": "./src/looks.css",
692
693
  "./workspace.css": "./src/workspace.css"
693
694
  },
694
695
  "files": [
@@ -128,6 +128,8 @@ export const AREAS: readonly Area[] = [
128
128
  'prompt.tsx',
129
129
  'Toast.tsx',
130
130
  'colorScheme',
131
+ // The look store and its picker: the sibling of colorScheme, installed at boot beside it.
132
+ 'look',
131
133
  'constants',
132
134
  'layout',
133
135
  // The engagement beacon draws nothing; it is here because every app beacons (5.2.0).
package/src/looks.css ADDED
@@ -0,0 +1,237 @@
1
+ /*
2
+ * The LOOKS an app can switch between — every one a theme SCOPE in the exact sense `theme.css`
3
+ * gives `.cb-aurora`: a class on <html> that re-binds the semantic surface tokens every component
4
+ * already reads, so nothing downstream takes a variant prop and a component never knows which
5
+ * look it is in. `cb-aurora` itself stays in `theme.css` (ten apps wear it statically); this file
6
+ * holds the rest, and an app imports it AFTER `theme.css` only when it offers a choice:
7
+ *
8
+ * @import "cursedbelt/theme.css";
9
+ * @import "cursedbelt/looks.css";
10
+ *
11
+ * The store that puts the class there is `cursedbelt/react` (`initLook`, `useLook`, `LookPicker`).
12
+ *
13
+ * ── The contract every look keeps (`src/looks.spec.ts` holds it) ─────────────────────────────
14
+ * · Both schemes: a light block (`.cb-<look>`) and a dark one (`.cb-<look>.dark, .dark .cb-<look>`),
15
+ * written for both shapes the scope lands in — on <html> beside `.dark`, or nested inside a
16
+ * `.dark` ancestor (a LookPicker card drawing itself, an AuthCard front door).
17
+ * · The same token set `.cb-aurora` re-binds, scheme for scheme: nothing a look forgets can leak
18
+ * the previous look's value through.
19
+ * · The field aliases and `--rule` re-declared in every block — the one-indirection trap
20
+ * `theme.css` documents at `.cb-aurora`: `var()` is substituted where a property is DECLARED,
21
+ * so a scope that re-binds `--background` must re-run `--field-bg: var(--background)` itself.
22
+ * · Measured, not eyeballed: foreground and muted-foreground clear 4.5:1 on every surface a look
23
+ * defines, border-strong clears 3:1 on the card, and the hairline sits between 1.3 and 1.6:1
24
+ * (quiet, still traceable under office lighting).
25
+ * · The four accent tokens are the APP's, as they are under aurora. A look is surfaces only.
26
+ *
27
+ * ── The three ──────────────────────────────────────────────────────────────────────────────────
28
+ * obsidian The design law from the owner's drafting area (2026-10-06), in the lineage of Linear
29
+ * and Obsidian: a monochrome workspace, near-black by default, hairline borders, no
30
+ * ambient field and no glass — colour on the page is the accent and the status hues,
31
+ * and nothing else. Its light scheme is the same discipline on an off-white canvas.
32
+ * nord arcticicestudio's Polar Night / Snow Storm ramps, softened to the contrast floors.
33
+ * dracula Dracula's grounds with its warm off-white text; the light scheme is the cream of
34
+ * its Alucard variant.
35
+ *
36
+ * Every literal below is a look's token definition — the one place a colour may be spelled.
37
+ */
38
+
39
+ /* ───────────────────────────────── obsidian ───────────────────────────────── */
40
+ .cb-obsidian {
41
+ --background: #f7f7f8; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
42
+ --foreground: #18181b; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
43
+ --surface: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
44
+ --card: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
45
+ --card-foreground: #18181b; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
46
+ --card-raised: #f3f3f5; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
47
+ --card-overlay: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
48
+ --popover: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
49
+ --popover-foreground: #18181b; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
50
+ --muted: #ececef; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
51
+ --muted-foreground: #52525b; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
52
+ --surface-sunken: #f0f0f2; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
53
+ --border: #d4d4d8; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
54
+ --border-strong: #86868f; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
55
+ --rule: color-mix(in srgb, var(--border) 55%, transparent);
56
+ --shadow-raised: 0 1px 2px rgb(9 9 11 / 0.06); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
57
+ --shadow-overlay: 0 6px 24px -6px rgb(9 9 11 / 0.16), 0 2px 8px -2px rgb(9 9 11 / 0.1); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
58
+ --shadow-popover: 0 24px 64px -16px rgb(9 9 11 / 0.28), 0 8px 24px -8px rgb(9 9 11 / 0.14); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
59
+ /* No glass and no ambient field: the chrome is a solid panel, and the backdrop paints nothing. */
60
+ --glass-bg: color-mix(in srgb, var(--card) 88%, transparent);
61
+ --glass-bg-strong: color-mix(in srgb, var(--card) 96%, transparent);
62
+ --glass-edge: var(--border);
63
+ --glass-shadow: var(--shadow-overlay);
64
+ --ambient-strength: 0;
65
+ --field-bg: var(--surface);
66
+ --field-bg-solid: var(--surface);
67
+ --field-border: var(--border-strong);
68
+ --field-fg: var(--foreground);
69
+ --field-placeholder: var(--muted-foreground);
70
+ --field-ring: var(--ring);
71
+ }
72
+
73
+ .cb-obsidian.dark,
74
+ .dark .cb-obsidian {
75
+ --background: #09090b; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
76
+ --foreground: #f4f4f5; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
77
+ --surface: #131316; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
78
+ --card: #131316; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
79
+ --card-foreground: #f4f4f5; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
80
+ --card-raised: #1c1c21; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
81
+ --card-overlay: #27272d; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
82
+ --popover: #1c1c21; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
83
+ --popover-foreground: #f4f4f5; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
84
+ --muted: #1f1f24; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
85
+ --muted-foreground: #a1a1aa; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
86
+ --surface-sunken: #0c0c0e; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
87
+ --border: #303036; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
88
+ --border-strong: #65656d; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
89
+ --rule: color-mix(in srgb, var(--border) 55%, transparent);
90
+ --shadow-raised: 0 1px 2px rgb(0 0 0 / 0.4); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
91
+ --shadow-overlay: 0 6px 24px -4px rgb(0 0 0 / 0.55), 0 2px 8px -2px rgb(0 0 0 / 0.4); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
92
+ --shadow-popover: 0 24px 64px -12px rgb(0 0 0 / 0.75), 0 8px 24px -8px rgb(0 0 0 / 0.5); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
93
+ --glass-bg: color-mix(in srgb, var(--card) 88%, transparent);
94
+ --glass-bg-strong: color-mix(in srgb, var(--card) 96%, transparent);
95
+ --glass-edge: var(--border);
96
+ --glass-shadow: var(--shadow-overlay);
97
+ --ambient-strength: 0;
98
+ /* A field is one step up from the panel it sits on, with the 3:1 edge FND-9 asks of a control. */
99
+ --field-bg: color-mix(in srgb, var(--foreground) 4%, transparent);
100
+ --field-bg-solid: var(--popover);
101
+ --field-border: var(--border-strong);
102
+ --field-fg: var(--foreground);
103
+ --field-placeholder: var(--muted-foreground);
104
+ --field-ring: var(--ring);
105
+ }
106
+
107
+ /* ──────────────────────────────────── nord ─────────────────────────────────── */
108
+ .cb-nord {
109
+ --background: #eceff4; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
110
+ --foreground: #2e3440; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
111
+ --surface: #f5f7fa; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
112
+ --card: #f5f7fa; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
113
+ --card-foreground: #2e3440; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
114
+ --card-raised: #e5e9f0; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
115
+ --card-overlay: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
116
+ --popover: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
117
+ --popover-foreground: #2e3440; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
118
+ --muted: #e5e9f0; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
119
+ --muted-foreground: #4c566a; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
120
+ --surface-sunken: #e5e9f0; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
121
+ --border: #d0d7e2; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
122
+ --border-strong: #7b8699; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
123
+ --rule: color-mix(in srgb, var(--border) 55%, transparent);
124
+ --shadow-raised: 0 1px 2px rgb(46 52 64 / 0.08); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
125
+ --shadow-overlay: 0 6px 24px -6px rgb(46 52 64 / 0.18), 0 2px 8px -2px rgb(46 52 64 / 0.1); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
126
+ --shadow-popover: 0 24px 64px -16px rgb(46 52 64 / 0.3), 0 8px 24px -8px rgb(46 52 64 / 0.14); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
127
+ --glass-bg: color-mix(in srgb, var(--card) 88%, transparent);
128
+ --glass-bg-strong: color-mix(in srgb, var(--card) 96%, transparent);
129
+ --glass-edge: var(--border);
130
+ --glass-shadow: var(--shadow-overlay);
131
+ --ambient-strength: 0;
132
+ --field-bg: var(--surface);
133
+ --field-bg-solid: var(--surface);
134
+ --field-border: var(--border-strong);
135
+ --field-fg: var(--foreground);
136
+ --field-placeholder: var(--muted-foreground);
137
+ --field-ring: var(--ring);
138
+ }
139
+
140
+ .cb-nord.dark,
141
+ .dark .cb-nord {
142
+ --background: #2e3440; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
143
+ --foreground: #eceff4; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
144
+ --surface: #353c4a; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
145
+ --card: #353c4a; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
146
+ --card-foreground: #eceff4; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
147
+ --card-raised: #3f4758; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
148
+ --card-overlay: #4a5367; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
149
+ --popover: #3f4758; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
150
+ --popover-foreground: #eceff4; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
151
+ --muted: #3f4758; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
152
+ --muted-foreground: #c3cbd9; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
153
+ --surface-sunken: #292e38; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
154
+ --border: #4a5264; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
155
+ --border-strong: #8b93a5; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
156
+ --rule: color-mix(in srgb, var(--border) 55%, transparent);
157
+ --shadow-raised: 0 1px 2px rgb(0 0 0 / 0.35); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
158
+ --shadow-overlay: 0 6px 24px -4px rgb(0 0 0 / 0.5), 0 2px 8px -2px rgb(0 0 0 / 0.35); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
159
+ --shadow-popover: 0 24px 64px -12px rgb(0 0 0 / 0.7), 0 8px 24px -8px rgb(0 0 0 / 0.45); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
160
+ --glass-bg: color-mix(in srgb, var(--card) 88%, transparent);
161
+ --glass-bg-strong: color-mix(in srgb, var(--card) 96%, transparent);
162
+ --glass-edge: var(--border);
163
+ --glass-shadow: var(--shadow-overlay);
164
+ --ambient-strength: 0;
165
+ --field-bg: color-mix(in srgb, var(--foreground) 5%, transparent);
166
+ --field-bg-solid: var(--popover);
167
+ --field-border: var(--border-strong);
168
+ --field-fg: var(--foreground);
169
+ --field-placeholder: var(--muted-foreground);
170
+ --field-ring: var(--ring);
171
+ }
172
+
173
+ /* ────────────────────────────────── dracula ────────────────────────────────── */
174
+ .cb-dracula {
175
+ --background: #faf7ed; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
176
+ --foreground: #1f1f1f; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
177
+ --surface: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
178
+ --card: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
179
+ --card-foreground: #1f1f1f; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
180
+ --card-raised: #f4f0e4; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
181
+ --card-overlay: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
182
+ --popover: #ffffff; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
183
+ --popover-foreground: #1f1f1f; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
184
+ --muted: #f1ecdf; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
185
+ --muted-foreground: #5c5848; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
186
+ --surface-sunken: #f4f0e4; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
187
+ --border: #dcd6c3; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
188
+ --border-strong: #8a846f; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
189
+ --rule: color-mix(in srgb, var(--border) 55%, transparent);
190
+ --shadow-raised: 0 1px 2px rgb(31 31 31 / 0.07); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
191
+ --shadow-overlay: 0 6px 24px -6px rgb(31 31 31 / 0.16), 0 2px 8px -2px rgb(31 31 31 / 0.1); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
192
+ --shadow-popover: 0 24px 64px -16px rgb(31 31 31 / 0.28), 0 8px 24px -8px rgb(31 31 31 / 0.14); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
193
+ --glass-bg: color-mix(in srgb, var(--card) 88%, transparent);
194
+ --glass-bg-strong: color-mix(in srgb, var(--card) 96%, transparent);
195
+ --glass-edge: var(--border);
196
+ --glass-shadow: var(--shadow-overlay);
197
+ --ambient-strength: 0;
198
+ --field-bg: var(--surface);
199
+ --field-bg-solid: var(--surface);
200
+ --field-border: var(--border-strong);
201
+ --field-fg: var(--foreground);
202
+ --field-placeholder: var(--muted-foreground);
203
+ --field-ring: var(--ring);
204
+ }
205
+
206
+ .cb-dracula.dark,
207
+ .dark .cb-dracula {
208
+ --background: #1e1f29; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
209
+ --foreground: #f8f8f2; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
210
+ --surface: #282a36; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
211
+ --card: #282a36; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
212
+ --card-foreground: #f8f8f2; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
213
+ --card-raised: #313442; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
214
+ --card-overlay: #3c3f51; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
215
+ --popover: #313442; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
216
+ --popover-foreground: #f8f8f2; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
217
+ --muted: #343747; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
218
+ --muted-foreground: #b3b9d6; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
219
+ --surface-sunken: #1b1c25; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
220
+ --border: #3e4150; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
221
+ --border-strong: #7b7f95; /* guardrails-ignore no-hardcoded-colors: a look's token definition */
222
+ --rule: color-mix(in srgb, var(--border) 55%, transparent);
223
+ --shadow-raised: 0 1px 2px rgb(0 0 0 / 0.4); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
224
+ --shadow-overlay: 0 6px 24px -4px rgb(0 0 0 / 0.55), 0 2px 8px -2px rgb(0 0 0 / 0.4); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
225
+ --shadow-popover: 0 24px 64px -12px rgb(0 0 0 / 0.75), 0 8px 24px -8px rgb(0 0 0 / 0.5); /* guardrails-ignore no-hardcoded-colors: a look's token definition */
226
+ --glass-bg: color-mix(in srgb, var(--card) 88%, transparent);
227
+ --glass-bg-strong: color-mix(in srgb, var(--card) 96%, transparent);
228
+ --glass-edge: var(--border);
229
+ --glass-shadow: var(--shadow-overlay);
230
+ --ambient-strength: 0;
231
+ --field-bg: color-mix(in srgb, var(--foreground) 4%, transparent);
232
+ --field-bg-solid: var(--popover);
233
+ --field-border: var(--border-strong);
234
+ --field-fg: var(--foreground);
235
+ --field-placeholder: var(--muted-foreground);
236
+ --field-ring: var(--ring);
237
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `looks.css` keeps the contract its header states, and the store's registry matches the CSS.
3
+ *
4
+ * ── Why a spec and not a review ─────────────────────────────────────────────
5
+ * A look is a block of thirty declarations that must agree with `.cb-aurora`'s block token for
6
+ * token: forget one and the PREVIOUS look's value shows through on every surface that reads it,
7
+ * and nothing fails — the page simply wears two looks at once. And a theme's colours are a
8
+ * contrast claim ("muted text clears 4.5:1 on the card") that a reviewer cannot see on a
9
+ * screenshot of one scheme. Both are decidable by a machine, so a machine decides them.
10
+ */
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { LOOKS, lookClass } from './react/look/lookStore.js';
15
+
16
+ const ROOT = join(import.meta.dir, '..');
17
+ const THEME = readFileSync(join(ROOT, 'src/theme.css'), 'utf8');
18
+ const LOOKS_CSS = readFileSync(join(ROOT, 'src/looks.css'), 'utf8');
19
+
20
+ type Decls = Map<string, string>;
21
+
22
+ /** The custom-property declarations of every top-level rule whose selector is exactly `selector`. */
23
+ function declarationsOf(css: string, selector: string): Decls | null {
24
+ const plain = css.replace(/\/\*[\s\S]*?\*\//g, '');
25
+ for (const m of plain.matchAll(/(?<=^|[;}])\s*([^{};@]+?)\s*\{([^{}]*)\}/g)) {
26
+ if (m[1]?.replace(/\s+/g, ' ').trim() !== selector) continue;
27
+ const out: Decls = new Map();
28
+ for (const d of (m[2] ?? '').matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) out.set(d[1] as string, (d[2] as string).trim());
29
+ return out;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ const lightSelector = (id: string) => `.${lookClass(id as never)}`;
35
+ const darkSelector = (id: string) => `.${lookClass(id as never)}.dark, .dark .${lookClass(id as never)}`;
36
+
37
+ const auroraLight = declarationsOf(THEME, '.cb-aurora');
38
+ const auroraDark = declarationsOf(THEME, '.cb-aurora.dark, .dark .cb-aurora');
39
+ if (!auroraLight || !auroraDark) throw new Error('theme.css no longer declares .cb-aurora in both schemes');
40
+
41
+ /** The looks this file ships: every registered look except aurora, which theme.css keeps. */
42
+ const SHIPPED = LOOKS.filter((look) => look.id !== 'aurora');
43
+
44
+ // ── contrast, WCAG 2.x ───────────────────────────────────────────────────────
45
+ const channel = (v: number): number => {
46
+ const c = v / 255;
47
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
48
+ };
49
+ function luminance(hex: string): number {
50
+ const m = /^#([0-9a-f]{6})$/i.exec(hex);
51
+ if (!m) throw new Error(`not a 6-digit hex colour: ${hex}`);
52
+ const n = Number.parseInt(m[1] as string, 16);
53
+ return 0.2126 * channel(n >> 16) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255);
54
+ }
55
+ const contrast = (a: string, b: string): number => {
56
+ const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
57
+ return (l1 + 0.05) / (l2 + 0.05);
58
+ };
59
+
60
+ /** The surfaces text is drawn on, and the text tokens drawn on them. */
61
+ const SURFACES = ['--background', '--surface', '--card', '--card-raised', '--card-overlay', '--popover', '--muted'] as const;
62
+ const TEXT = ['--foreground', '--muted-foreground'] as const;
63
+
64
+ describe('looks.css', () => {
65
+ test('every registered look except aurora is declared here, in both schemes, and nothing else is', () => {
66
+ for (const look of SHIPPED) {
67
+ expect(declarationsOf(LOOKS_CSS, lightSelector(look.id))).not.toBeNull();
68
+ expect(declarationsOf(LOOKS_CSS, darkSelector(look.id))).not.toBeNull();
69
+ }
70
+ expect(declarationsOf(LOOKS_CSS, '.cb-aurora')).toBeNull();
71
+ const plain = LOOKS_CSS.replace(/\/\*[\s\S]*?\*\//g, '');
72
+ const scopes = new Set([...plain.matchAll(/\.cb-([a-z]+)/g)].map((m) => m[1]));
73
+ expect([...scopes].sort()).toEqual(SHIPPED.map((l) => l.id).sort());
74
+ });
75
+
76
+ test('each look re-binds every token .cb-aurora re-binds, scheme for scheme (nothing leaks through)', () => {
77
+ for (const look of SHIPPED) {
78
+ const light = declarationsOf(LOOKS_CSS, lightSelector(look.id)) as Decls;
79
+ const dark = declarationsOf(LOOKS_CSS, darkSelector(look.id)) as Decls;
80
+ const missingLight = [...auroraLight.keys()].filter((k) => !light.has(k));
81
+ const missingDark = [...auroraDark.keys()].filter((k) => !dark.has(k));
82
+ expect({ look: look.id, missingLight }).toEqual({ look: look.id, missingLight: [] });
83
+ expect({ look: look.id, missingDark }).toEqual({ look: look.id, missingDark: [] });
84
+ // The one-indirection trap: an alias must be re-run inside the scope that re-binds what it reads.
85
+ for (const decls of [light, dark]) {
86
+ for (const alias of ['--rule', '--field-bg', '--field-bg-solid', '--field-border', '--field-fg', '--field-placeholder', '--field-ring'])
87
+ expect(decls.has(alias)).toBe(true);
88
+ }
89
+ }
90
+ });
91
+
92
+ test('text clears 4.5:1 on every surface, border-strong 3:1 on the card, and the hairline is quiet but traceable', () => {
93
+ const failures: string[] = [];
94
+ for (const look of SHIPPED) {
95
+ for (const [scheme, decls] of [
96
+ ['light', declarationsOf(LOOKS_CSS, lightSelector(look.id)) as Decls],
97
+ ['dark', declarationsOf(LOOKS_CSS, darkSelector(look.id)) as Decls],
98
+ ] as const) {
99
+ const hex = (token: string): string => {
100
+ const v = decls.get(token);
101
+ if (!v || !/^#[0-9a-f]{6}$/i.test(v)) failures.push(`${look.id}/${scheme}: ${token} must be a 6-digit hex here, got ${v ?? 'nothing'}`);
102
+ return v ?? '#000000';
103
+ };
104
+ for (const text of TEXT) {
105
+ for (const surface of SURFACES) {
106
+ const ratio = contrast(hex(text), hex(surface));
107
+ if (ratio < 4.5) failures.push(`${look.id}/${scheme}: ${text} on ${surface} is ${ratio.toFixed(2)}:1 (< 4.5)`);
108
+ }
109
+ }
110
+ const strong = contrast(hex('--border-strong'), hex('--card'));
111
+ if (strong < 3) failures.push(`${look.id}/${scheme}: --border-strong on --card is ${strong.toFixed(2)}:1 (< 3)`);
112
+ const hairline = contrast(hex('--border'), hex('--card'));
113
+ if (hairline < 1.3 || hairline > 1.6) failures.push(`${look.id}/${scheme}: --border on --card is ${hairline.toFixed(2)}:1 (want 1.3–1.6)`);
114
+ if (decls.get('--ambient-strength') !== '0') failures.push(`${look.id}/${scheme}: a monochrome look paints no ambient field (--ambient-strength: 0)`);
115
+ }
116
+ }
117
+ expect(failures).toEqual([]);
118
+ });
119
+
120
+ test('the registry names a label and a description for every look, and ids are the class suffixes', () => {
121
+ for (const look of LOOKS) {
122
+ expect(look.label.length).toBeGreaterThan(0);
123
+ expect(look.description.length).toBeGreaterThan(0);
124
+ expect(lookClass(look.id)).toBe(`cb-${look.id}`);
125
+ }
126
+ expect(new Set(LOOKS.map((l) => l.id)).size).toBe(LOOKS.length);
127
+ });
128
+ });
@@ -10,7 +10,6 @@ import {
10
10
  useRef,
11
11
  useState,
12
12
  } from 'react';
13
- import { toast } from 'sonner';
14
13
  import { cn } from '../lib/cn.js';
15
14
  import { Tooltip, type TooltipMobile, type TooltipPlacement } from './Tooltip.js';
16
15
  import { TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
@@ -213,6 +212,18 @@ export interface ButtonProps
213
212
  ref?: Ref<HTMLButtonElement>;
214
213
  }
215
214
 
215
+ /**
216
+ * 🔴 sonner is loaded when a toast is about to be SHOWN, never at import.
217
+ *
218
+ * `Button` is on every app's entry chunk, and a static `import { toast } from 'sonner'` here put
219
+ * the whole of sonner there with it — and, because Rollup colours a module into the chunk of its
220
+ * static importers, every app that mounted `Toaster` anywhere (even lazily) paid for the stack on
221
+ * first paint too. Measured in family, 2026-10-06 (forge task 2900): 36 kB minified of sonner on
222
+ * the entry, 15.5 kB of it the `Toaster` a lazy mount could not move. A toast follows an async
223
+ * click that already awaited the network, so the one-time chunk fetch here is never felt.
224
+ */
225
+ const toastModule = () => import('sonner').then((m) => m.toast);
226
+
216
227
  export function Button({
217
228
  className,
218
229
  variant,
@@ -250,13 +261,13 @@ export function Button({
250
261
  await onAsyncClick(e);
251
262
  setJustSucceeded(true);
252
263
  if (successMessage !== false) {
253
- toast.success(typeof successMessage === 'string' ? successMessage : 'Done');
264
+ (await toastModule()).success(typeof successMessage === 'string' ? successMessage : 'Done');
254
265
  }
255
266
  resetTimer.current = setTimeout(() => setJustSucceeded(false), resetMs);
256
267
  } catch (err) {
257
268
  if (errorMessage !== false) {
258
269
  // OVL-9: an error toast carries the action that resolves it.
259
- toast.error(
270
+ (await toastModule()).error(
260
271
  typeof errorMessage === 'string' ? errorMessage : err instanceof Error ? err.message : String(err),
261
272
  { action: { label: 'Retry', onClick: () => void handleClick?.(e) } },
262
273
  );
@@ -1,6 +1,5 @@
1
1
  import { Check, Copy } from 'lucide-react';
2
2
  import type { MouseEvent, ReactNode } from 'react';
3
- import { toast } from 'sonner';
4
3
  import { type ClipboardWriter, useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
5
4
  import { cn } from '../lib/cn.js';
6
5
  import { Button } from './Button.js';
@@ -81,6 +80,8 @@ export function CopyButton({
81
80
  const ok = await copy(text);
82
81
  if (ok) {
83
82
  if (confirmToast !== false) {
83
+ // Loaded at the moment of use — `Button.tsx` says why sonner must not be a static import.
84
+ const { toast } = await import('sonner');
84
85
  toast.success(typeof confirmToast === 'string' ? confirmToast : 'Copied to clipboard');
85
86
  }
86
87
  onCopied?.(text);