+ {/* Optional. With a DialogBody only the body scrolls; the header and
+ footer stay put around it. Without one, everything scrolls together. */}
+
+ Your projects, files and settings are deleted with the account.
+
+
+
+ Cancel
+
+ Delete account
+
)
@@ -988,6 +1009,158 @@ export function Example() {
)
}`;
+ case "kbd":
+ return `import { Kbd, KbdGroup } from "@/components/ui/kbd"
+
+export function Example() {
+ return (
+
+ Search with{" "}
+
+ ⌘
+ K
+
+
+ )
+}`;
+
+ case "empty":
+ return `import { FolderOpenIcon } from "@phosphor-icons/react"
+import { Button } from "@/components/ui/button"
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
+
+export function Example() {
+ return (
+ // Plain layout by default (it usually sits inside a card); variant="glass" gives it its own surface.
+
+
+
+
+
+ No projects yet
+ Create your first project to get started.
+
+
+ Create project
+
+
+ )
+}`;
+
+ case "progress":
+ return `import { Progress } from "@/components/ui/progress"
+
+export function Example() {
+ return
+}`;
+
+ case "item":
+ return `import { BellIcon } from "@phosphor-icons/react"
+import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"
+import { Switch } from "@/components/ui/switch"
+
+export function Example() {
+ return (
+ // variant: "default" (plain row) · "outline" (bordered glass) · "muted" (borderless glass)
+ -
+
+
+
+
+ Notifications
+ Mentions, replies and invitations.
+
+
+
+
+
+ )
+}`;
+
+ case "field":
+ return `import { Checkbox } from "@/components/ui/checkbox"
+import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+
+export function Example() {
+ return (
+
+ Profile
+ How you appear to others.
+
+
+ Name
+
+
+
+
+
+ Show my profile publicly
+
+
+
+ )
+}`;
+
+ case "menubar":
+ return `import {
+ Menubar,
+ MenubarContent,
+ MenubarItem,
+ MenubarMenu,
+ MenubarSeparator,
+ MenubarShortcut,
+ MenubarSub,
+ MenubarSubContent,
+ MenubarSubTrigger,
+ MenubarTrigger,
+} from "@/components/ui/menubar"
+
+export function Example() {
+ return (
+
+
+ File
+
+
+ New tab ⌘T
+
+
+
+ Share
+
+ Email link
+
+
+
+
+
+ )
+}`;
+
+ case "combobox":
+ return `import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"
+
+const fruits = ["Apple", "Banana", "Blueberry", "Cherry", "Grape"]
+
+export function Example() {
+ return (
+
+
+
+ No fruit found.
+
+ {(item: string) => (
+
+ {item}
+
+ )}
+
+
+
+ )
+}`;
+
case "mode-toggle":
return `import { ModeToggle } from "@/components/ui/mode-toggle"
diff --git a/lib/material.ts b/lib/material.ts
index c57efebc..14ec9c0c 100644
--- a/lib/material.ts
+++ b/lib/material.ts
@@ -4,7 +4,7 @@ import type * as React from "react";
* The Sistine material system — the single source of the material → markup mapping.
*
* FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in
- * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)
+ * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)
* over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding
* recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).
*
@@ -25,6 +25,11 @@ export interface MaterialProps {
/** Material border. Three weights: `true`/"hairline" = the material's edge (1px; 0.5px under
* frosted), "rim" = 2px, "frame" = 4px. */
border?: boolean | "hairline" | "rim" | "frame";
+ /** The border's COLOUR. "glass" (the default) = the material's own edge, a near-white rim at the
+ * material's alpha, which reads as glass but disappears on light themes at any weight. "ink" = the
+ * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.
+ * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */
+ borderColor?: "glass" | "ink";
/** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */
veil?: boolean;
/** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop
@@ -60,11 +65,12 @@ export function splitAxisProps(
MaterialAxisProps,
SplitAxisRest
,
] {
- const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;
+ const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;
return [
{
material,
border,
+ borderColor,
veil,
diffuse,
gradient,
@@ -85,7 +91,7 @@ export interface MaterialAttrs {
}
export function glassMaterial(props: MaterialProps = {}): MaterialAttrs {
- const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;
+ const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;
if (material === "none") {
return {
className: "",
@@ -98,6 +104,7 @@ export function glassMaterial(props: MaterialProps = {}): MaterialAttrs {
border && "glass-border",
border === "rim" && "glass-border-rim",
border === "frame" && "glass-border-frame",
+ borderColor === "ink" && "glass-border-ink",
veil && "glass-veil",
size === "sm" && "glass-sm",
size === "lg" && "glass-lg",
@@ -127,6 +134,7 @@ export function materialSurface(role: MaterialProps | null, props: MaterialProps
return glassMaterial({
material: props.material,
border: props.border ?? role.border,
+ borderColor: props.borderColor ?? role.borderColor,
veil: props.veil ?? role.veil,
size: props.size ?? role.size,
diffuse: props.diffuse ?? role.diffuse,
diff --git a/lib/oklch-utils.ts b/lib/oklch-utils.ts
index f079ce36..d97c102e 100644
--- a/lib/oklch-utils.ts
+++ b/lib/oklch-utils.ts
@@ -1224,6 +1224,10 @@ export const TONAL_MIN_L = 18;
* dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,
* while near white a single 3-point step is worth 6–8 Lc. */
export const TONAL_MAX_L = 97;
+/** How far under the body floor a tonal pick may sit and still be kept over an extreme that does not
+ * clear the floor either. 72 Lc and 74.8 Lc are not a difference a reader can use in body text; ink-blue
+ * versus #000002 is one they see. */
+export const TONAL_HUE_SLACK_LC = 3;
/** Uncertainty margin at a fully-sheer floor, decaying to 0 as the floor becomes exactly modelable. */
export const LC_MARGIN = 12;
/** Baseline aim for a floor that IS modelled exactly, so certainty is not a reason to aim at the bare
@@ -1313,6 +1317,14 @@ export function foregroundRamp(o: ForegroundRampOptions): ForegroundRamp {
* satisfy its FLOOR, legibility outranks hue and the full ramp comes back. Guard the FLOOR, not each
* band's aspiration: the `small` band asks Lc 90, which no light surface reaches at all, so testing
* against band.floor there would hand the extreme straight back on every light preset.
+ *
+ * The trade is bounded from the other side too. When the tonal pick is within {@link TONAL_HUE_SLACK_LC}
+ * of the floor and the extreme does NOT clear it either, the extreme is only "less unreadable" by under
+ * that much, and the hue stays: that was body text solving to #000002 on a dark-wash preset (lapis) over
+ * a darker backing, where black bought 1.8 Lc and still missed the floor. A fallback that DOES clear the
+ * floor always wins, so nothing that was readable becomes less so. And a tonal pick far under the floor
+ * (the mid-tone dead zone, ~43 Lc) still hands back the extreme: there the guard's job is only to take
+ * whatever contrast is left.
*/
export function pickTonalInBand(ramp: ForegroundRamp, surface: OklchColor | string, band: ContrastBand): OklchColor {
const best = pickInBand(ramp.tonal, surface, band);
@@ -1320,6 +1332,7 @@ export function pickTonalInBand(ramp: ForegroundRamp, surface: OklchColor | stri
const lc = (c: OklchColor) => Math.abs(apcaContrast(c, surface));
if (lc(best) >= READABLE_USAGE.body.floor) return best;
const full = pickInBand(ramp.raw, surface, band);
+ if (lc(best) >= READABLE_USAGE.body.floor - TONAL_HUE_SLACK_LC && lc(full) < READABLE_USAGE.body.floor) return best;
return lc(full) > lc(best) ? full : best;
}
diff --git a/lib/snap-popper.ts b/lib/snap-popper.ts
index 149c2e8a..90c021c5 100644
--- a/lib/snap-popper.ts
+++ b/lib/snap-popper.ts
@@ -6,20 +6,23 @@ import * as React from "react";
* Keeps text crisp on Radix popper surfaces (dropdown-menu, popover, tooltip, select, hover-card,
* context-menu).
*
- * THE DEFECT: Radix positions those surfaces by writing an inline `transform: translate(x, y)` onto a
- * wrapper element. Floating UI does not round x/y, so they land on fractions whenever the trigger does
- * — under browser zoom, fractional DPR, or any flex/grid trigger whose box isn't on a whole pixel. On
- * its own that would be harmless, but sistine's popper content carries `backdrop-filter` (it's glass),
- * which promotes it to its own compositing layer. A promoted layer at a fractional offset has its
- * glyphs rasterized off the device pixel grid and the text renders visibly soft.
+ * THE DEFECT: on a high-density display the text in a popper rendered visibly soft. Radix positions the
+ * surface with an inline `transform: translate(x, y)` on a wrapper element, written from Floating UI's
+ * `floatingStyles`, and at a device pixel ratio of 1.5 or more Floating UI adds `will-change: transform`
+ * to the same style (`@floating-ui/react-dom`, `floatingStyles`). `will-change` promotes the wrapper to
+ * its own compositing layer, and WebKit drops subpixel (LCD) antialiasing for text on a composited layer;
+ * sistine's popper content is glass, so a blurred backdrop sits right behind that text. Resetting
+ * `will-change` to `auto` is the fix. The cost is a repaint on reposition instead of a layer move, for a
+ * surface that is open for seconds.
*
- * This is the same defect the dialogs had. There the fix was to stop using a transform to center; a
- * popper can't do that, because its position IS the transform. So instead: round the translate to whole
- * pixels. Sub-pixel placement is imperceptible; blurred text is not.
+ * The ROUNDING below predates that diagnosis. Its premise was that unrounded offsets put glyphs off the
+ * pixel grid, but Floating UI already rounds x/y to the device pixel grid (`roundByDPR`) before writing
+ * them. It is kept for browser zoom, where the device grid and the CSS-pixel grid diverge, which has not
+ * been verified either way; it is cheap and harmless, and removing it is a separate decision.
*
- * Radix rewrites that transform on scroll, resize and collision flips, so a MutationObserver re-snaps
- * after each write. The observer is re-entrant — our own write retriggers it — so the write is skipped
- * when the value is already integral, which terminates the loop on the first pass.
+ * Radix rewrites the style on scroll, resize and collision flips, so a MutationObserver re-applies both
+ * after each write. The observer is re-entrant (our own write retriggers it), so each write is skipped
+ * when the value is already what we want, which terminates the loop on the second pass at the latest.
*/
/**
* The whole snap, as a pure string transform: given the wrapper's SPECIFIED inline `transform`, return
@@ -30,7 +33,7 @@ import * as React from "react";
* - the value is already integral, so there is nothing to do and the loop terminates on pass one;
* - the value isn't a translate we recognise (Radix hasn't positioned yet), so we must not invent one.
*
- * Split out of the hook so it can be checked without a DOM — see scripts/check-units.mjs.
+ * Split out of the hook so it can be checked without a DOM — see tests/snap-popper.test.tsx.
*/
export function snapTransform(specified: string): string | null {
const parts = /^translate(?:3d)?\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px/.exec(specified);
@@ -57,6 +60,10 @@ export function useSnappedPopper(): React.RefCallback
// re-enter forever.
const next = snapTransform(wrapper.style.transform);
if (next !== null) wrapper.style.transform = next;
+ // Floating UI's will-change (DPR >= 1.5) is what costs the text its antialiasing. Only written when
+ // something else is set, so the pass our own write triggers sees `auto` and stops.
+ const willChange = wrapper.style.willChange;
+ if (willChange !== "" && willChange !== "auto") wrapper.style.willChange = "auto";
};
snap();
diff --git a/lib/storybook-url.ts b/lib/storybook-url.ts
index dba518e9..9549cf12 100644
--- a/lib/storybook-url.ts
+++ b/lib/storybook-url.ts
@@ -5,6 +5,13 @@ import { siteUrl } from "@/lib/site-url";
* In production, uses NEXT_PUBLIC_STORYBOOK_URL env var
* In development, uses localhost:6006
*/
+/* Registry names whose hyphen-stripped form collides with another's: `menu-bar` (the legacy button row)
+ and `menubar` (the Radix menu system) both become "menubar", so the legacy one's story is titled
+ Sistine/MenuBarLegacy. */
+const STORY_ID_OVERRIDES: Record = {
+ "menu-bar": "menubarlegacy",
+};
+
export function getStorybookUrl(componentName: string): string {
/* Storybook ships inside the site at /storybook (scripts/export-storybook.js copies it into out/), so
its default follows the site origin rather than restating a domain. NEXT_PUBLIC_STORYBOOK_URL still
@@ -14,6 +21,6 @@ export function getStorybookUrl(componentName: string): string {
// Storybook converts "Sistine/AlertDialog" to "sistine-alertdialog"
// Component names from registry are kebab-case (e.g., "alert-dialog")
// But Storybook URLs use lowercase without hyphens (e.g., "alertdialog")
- const storybookComponentName = componentName.toLowerCase().replace(/-/g, "");
+ const storybookComponentName = STORY_ID_OVERRIDES[componentName] ?? componentName.toLowerCase().replace(/-/g, "");
return `${baseUrl}/?path=/docs/sistine-${storybookComponentName}--docs`;
}
diff --git a/package.json b/package.json
index dac9735b..d23cecfa 100644
--- a/package.json
+++ b/package.json
@@ -29,52 +29,53 @@
"Edge >= 120"
],
"dependencies": {
+ "@base-ui/react": "^1.9.0",
"@phosphor-icons/react": "^2.1.10",
- "@tanstack/react-table": "^9.1.2",
+ "@tanstack/react-table": "^9.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
- "framer-motion": "^13.1.1",
+ "framer-motion": "^13.5.1",
"input-otp": "^1.5.0",
- "next": "^16.3.3",
+ "next": "^16.4.2",
"next-themes": "^0.4.6",
- "radix-ui": "^1.6.7",
- "react": "^19.2.8",
- "react-day-picker": "^10.0.1",
- "react-dom": "^19.2.8",
- "react-easy-crop": "^6.2.3",
- "react-resizable-panels": "^4.12.3",
+ "radix-ui": "^1.8.1",
+ "react": "^19.3.1",
+ "react-day-picker": "^10.0.2",
+ "react-dom": "^19.3.1",
+ "react-easy-crop": "^6.2.4",
+ "react-resizable-panels": "^4.14.6",
"recharts": "^3.10.1",
- "shadcn": "^4.19.0",
+ "shadcn": "^4.22.1",
"sonner": "^2.0.8",
- "tailwind-merge": "^3.6.0",
+ "tailwind-merge": "^3.7.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
- "zod": "^4.4.3"
+ "zod": "^4.6.7"
},
"devDependencies": {
- "@biomejs/biome": "^2.5.10",
- "@chromatic-com/storybook": "^5.3.0",
- "@storybook/addon-a11y": "^10.5.10",
- "@storybook/addon-docs": "^10.5.10",
- "@storybook/addon-onboarding": "^10.5.10",
- "@storybook/addon-vitest": "^10.5.10",
- "@storybook/nextjs": "^10.5.10",
+ "@biomejs/biome": "^2.5.15",
+ "@chromatic-com/storybook": "^5.5.1",
+ "@storybook/addon-a11y": "^10.6.2",
+ "@storybook/addon-docs": "^10.6.2",
+ "@storybook/addon-onboarding": "^10.6.2",
+ "@storybook/addon-vitest": "^10.6.2",
+ "@storybook/nextjs": "^10.6.2",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
- "@testing-library/react": "^16.3.2",
- "@testing-library/user-event": "^14.6.6",
- "@types/node": "^24.13.3",
- "@types/react": "^19.2.18",
- "@types/react-dom": "^19.2.5",
- "@vitejs/plugin-react": "^6.1.0",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.8",
+ "@types/node": "^24.19.2",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
"apca-w3": "^0.1.9",
- "jsdom": "^30.0.1",
- "storybook": "^10.5.10",
+ "jsdom": "^30.1.3",
+ "storybook": "^10.6.2",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
- "vitest": "^4.1.11"
+ "vitest": "^4.1.12"
},
"overrides": {
"@types/react": "^19.2.18",
diff --git a/public/r/accordion.json b/public/r/accordion.json
index d875ef05..43f33db5 100644
--- a/public/r/accordion.json
+++ b/public/r/accordion.json
@@ -22,7 +22,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/alert-dialog.json b/public/r/alert-dialog.json
index e0fe392e..f2ecb195 100644
--- a/public/r/alert-dialog.json
+++ b/public/r/alert-dialog.json
@@ -22,7 +22,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/alert.json b/public/r/alert.json
index 046141c0..69cce48d 100644
--- a/public/r/alert.json
+++ b/public/r/alert.json
@@ -20,7 +20,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json
index 93004192..bf5fc1d5 100644
--- a/public/r/auto-foreground.json
+++ b/public/r/auto-foreground.json
@@ -11,7 +11,7 @@
"files": [
{
"path": "components/auto-foreground.tsx",
- "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n showThrough as backdropShowThrough,\n boostBand,\n type ContrastBand,\n compositeSurface,\n foregroundRamp,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n LC_AIM_KNOWN,\n LC_MARGIN,\n pickTonalInBand,\n READABLE_USAGE,\n readableTonal,\n type ThemeForegroundOptions,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/* The tonal clip bounds, the margins and the pick/solve themselves now live in lib/oklch-utils — see\n the \"Foreground solve\" section there. They were duplicated into components/foreground-tester.tsx and\n scripts/check-contrast.mjs, and every copy had drifted from this one in a way no test could see. */\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — ` ` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n // (LC_MARGIN / LC_AIM_KNOWN are exported from lib/oklch-utils so every consumer aims the same.)\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const normalLcBoost = LC_MARGIN * backdropShowThrough(solidA, tintA, glassOpacity);\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n /* Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral gray —\n each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. Only the\n READABLE half (count + 1 levels) is built: the far side of the base is display-only, and picking\n from it would offer steps the surface can never make legible.\n foregroundRamp also drops both achromatic ends on tinted themes — see lib/oklch-utils for why the\n two thresholds are not symmetric. Before that clip, --foreground-strong was #000000 on 20 of 21\n presets in light and #ffffff on 18 of 21 in dark, and body text ALSO went black on 10 of 21. */\n const ramp = foregroundRamp({\n palette,\n count,\n base,\n dark,\n });\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: ContrastBand) => boostBand(band, lcBoost, lcAim);\n /* Pick from the TONAL ramp, never at the cost of the band's floor — see pickTonalInBand in\n lib/oklch-utils. Today no band needs the fallback (light body bottoms out at 75.4 Lc on the\n clipped ramp), but /colors lets a consumer re-base the ramp entirely, so the guard exists so\n the hue preference can never become a readability regression. */\n /* The adaptive twin, readableTonal, also lives in lib/oklch-utils: an unreachable aim makes\n readableForeground return the lightness EXTREME, where the gamut annihilates chroma, so every\n jewel's --foreground-opaque solved to pure black in light. Same clip, same ~1 Lc cost. */\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: ContrastBand) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(surface, band, {\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY — the one case where the background CAN hold the kept chroma, so the\n // minChroma search does not bail to the extreme (see readableTonal).\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonalInBand(ramp, surface, band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(surface, boost(READABLE_USAGE[usage]), {\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableTonal(surface, boost(READABLE_USAGE.ui), {\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same\n // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason\n // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the\n backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the\n gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an\n UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known\n than the model claimed. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n showThrough as backdropShowThrough,\n boostBand,\n type ContrastBand,\n compositeSurface,\n foregroundRamp,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n LC_AIM_KNOWN,\n LC_MARGIN,\n pickTonalInBand,\n READABLE_USAGE,\n readableTonal,\n type ThemeForegroundOptions,\n} from \"@/lib/oklch-utils\";\n\n/** localStorage key holding the persisted {@link FgConfig}. Exported so an app that reads or seeds the\n * config (a settings screen, a pre-hydration script) never retypes the string. */\nexport const FG_STORAGE_KEY = \"sistine-fg\";\n/** localStorage key holding the persisted {@link RampConfig}. */\nexport const RAMP_KEY = \"sistine-ramp\";\n/** Window event AutoForeground listens for to re-band: dispatch it after changing the tint, the mode or\n * the stored config (`window.dispatchEvent(new Event(FG_EVENT))`). */\nexport const FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/* The tonal clip bounds, the margins and the pick/solve themselves now live in lib/oklch-utils — see\n the \"Foreground solve\" section there. They were duplicated into components/foreground-tester.tsx and\n scripts/check-contrast.mjs, and every copy had drifted from this one in a way no test could see. */\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — ` ` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n // (LC_MARGIN / LC_AIM_KNOWN are exported from lib/oklch-utils so every consumer aims the same.)\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const normalLcBoost = LC_MARGIN * backdropShowThrough(solidA, tintA, glassOpacity);\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n /* Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral gray —\n each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. Only the\n READABLE half (count + 1 levels) is built: the far side of the base is display-only, and picking\n from it would offer steps the surface can never make legible.\n foregroundRamp also drops both achromatic ends on tinted themes — see lib/oklch-utils for why the\n two thresholds are not symmetric. Before that clip, --foreground-strong was #000000 on 20 of 21\n presets in light and #ffffff on 18 of 21 in dark, and body text ALSO went black on 10 of 21. */\n const ramp = foregroundRamp({\n palette,\n count,\n base,\n dark,\n });\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: ContrastBand) => boostBand(band, lcBoost, lcAim);\n /* Pick from the TONAL ramp, never at the cost of the band's floor — see pickTonalInBand in\n lib/oklch-utils. Today no band needs the fallback (light body bottoms out at 75.4 Lc on the\n clipped ramp), but /colors lets a consumer re-base the ramp entirely, so the guard exists so\n the hue preference can never become a readability regression. */\n /* The adaptive twin, readableTonal, also lives in lib/oklch-utils: an unreachable aim makes\n readableForeground return the lightness EXTREME, where the gamut annihilates chroma, so every\n jewel's --foreground-opaque solved to pure black in light. Same clip, same ~1 Lc cost. */\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: ContrastBand) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(surface, band, {\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY — the one case where the background CAN hold the kept chroma, so the\n // minChroma search does not bail to the extreme (see readableTonal).\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonalInBand(ramp, surface, band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(surface, boost(READABLE_USAGE[usage]), {\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableTonal(surface, boost(READABLE_USAGE.ui), {\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same\n // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason\n // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the\n backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the\n gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an\n UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known\n than the model claimed. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n",
"type": "registry:component",
"target": "components/auto-foreground.tsx"
}
diff --git a/public/r/avatar.json b/public/r/avatar.json
index a5b03605..07da483b 100644
--- a/public/r/avatar.json
+++ b/public/r/avatar.json
@@ -21,7 +21,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/background-controller.json b/public/r/background-controller.json
index 034ccecf..ec9b2e47 100644
--- a/public/r/background-controller.json
+++ b/public/r/background-controller.json
@@ -22,7 +22,7 @@
},
{
"path": "components/pattern-background.tsx",
- "content": "import type { CSSProperties } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora\n * drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n];\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur/--pat-play pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nconst TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body} `)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts.\nconst GHOST = svgMask(\n \" \",\n);\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n >\n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n \n
\n {PAC_WALLS.map((w) => (\n
\n ))}\n {PAC_LANES.map((lane) => (\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n ))}\n
\n \n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n );\n }\n return (\n
\n );\n}\n",
+ "content": "import type { CSSProperties } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora\n * drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n];\n\n/**\n * The two ways a scene stops: reduced motion, and Speed 0.\n *\n * Both REMOVE the animation rather than pausing it. `animation-play-state: paused`\n * looks like it should be free — the transform never advances — but a paused\n * animation still leaves the element in an animating state, and WebKit keeps\n * re-rendering its layer. On `.au-curtain` that layer carries a `filter: blur()`\n * and a two-layer `mask-composite`, six of them at full-viewport size, so \"paused\"\n * was measurably NOT free (AMXSF-559). `animation: none` also drops `will-change`,\n * releasing the promoted layer — `will-change` on a stopped animation is a\n * promoted layer doing no work.\n *\n * Emitted from one helper so the two conditions cannot drift apart: a scene that\n * respects reduced motion but not Speed 0 was exactly the bug.\n */\nexport function stopRules(...selectors: string[]): string {\n const stop = \"{ animation: none; will-change: auto; }\";\n return `@media (prefers-reduced-motion: reduce) { ${selectors.join(\", \")} ${stop} }\n${selectors.map((s) => `[data-pat-static] ${s}`).join(\", \")} ${stop}`;\n}\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nexport const TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body} `)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts.\nconst GHOST = svgMask(\n \" \",\n);\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n >\n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n \n
\n {PAC_WALLS.map((w) => (\n
\n ))}\n {PAC_LANES.map((lane) => (\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n ))}\n
\n \n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n );\n }\n return (\n
\n );\n}\n",
"type": "registry:component",
"target": "components/pattern-background.tsx"
},
diff --git a/public/r/badge.json b/public/r/badge.json
index 0a741a65..b3ebd5ef 100644
--- a/public/r/badge.json
+++ b/public/r/badge.json
@@ -21,7 +21,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/breadcrumb.json b/public/r/breadcrumb.json
index bd6dbdd4..bf16d7cd 100644
--- a/public/r/breadcrumb.json
+++ b/public/r/breadcrumb.json
@@ -22,7 +22,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/button-group.json b/public/r/button-group.json
index 082bf0e5..03e45d57 100644
--- a/public/r/button-group.json
+++ b/public/r/button-group.json
@@ -22,7 +22,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/button.json b/public/r/button.json
index 01146da3..455a7421 100644
--- a/public/r/button.json
+++ b/public/r/button.json
@@ -21,7 +21,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
@@ -33,7 +33,7 @@
},
{
"path": "components/ui/button.tsx",
- "content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { Slot as SlotPrimitive } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { type HoverEffect, hoverEffects } from \"@/lib/hover-effects\";\nimport { type MaterialAxisProps, type MaterialProps, materialSurface, splitAxisProps } from \"@/lib/material\";\nimport { cn } from \"@/lib/utils\";\n\n/* SEMANTIC variants only — each carries BEHAVIOR (text, hover, press shadows); the SURFACE comes from\n the material system (materialSurface below). The five materials + axes are the material/border/veil/\n gradient/glow/sheen props, not variants. */\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.96] active:transition-transform\",\n {\n variants: {\n variant: {\n glass: \"text-foreground hover:opacity-90 transition active:opacity-80 active:shadow-[var(--press-shadow-strong)]\",\n /* The one FILLED action (better-colors: one primary per view, colour on the background not the\n label). It now follows the page material: --srf-bg-image / --srf-shadow are inherited custom\n properties that styles.css sets on per [data-glass] and materials.css sets on any\n [data-material] ancestor, so reading them with the glass stack as fallback makes this button\n wear crystal / chakra / opaque like everything else. It previously hardcoded var(--glass-bg)\n and var(--glass-shadow), pinning the GLASS sheet no matter what the page was wearing.\n The FILL stays --primary/--primary-foreground rather than moving to the gradient accent: that\n pair is contrast-solved (measured worst Lc 87.4 light / 60.9 dark across all 17 presets),\n where the gradient sits at L 60–68 — a mid-tone where text of EITHER polarity runs out of\n room. Measured worst there was Lc 55.5 with text-foreground and no better with any other\n tier or alpha, against an ARC Bronze body floor of 60.\n It deliberately does NOT take the `glass` utility: that utility writes background-color, which\n would override bg-primary and leave the fill fighting it for nothing. */\n default:\n \"bg-primary text-primary-foreground [background-image:var(--srf-bg-image,var(--glass-stack-bg))] shadow-[var(--srf-shadow,var(--glass-shadow))] hover:brightness-105 dark:hover:brightness-95 transition active:opacity-90 active:shadow-[var(--press-shadow)]\",\n destructive:\n \"text-destructive border border-destructive/60 hover:opacity-90 transition active:opacity-80 focus-visible:ring-destructive/20 active:shadow-[var(--press-shadow-strong)]\",\n outline:\n \"text-foreground border-2 border-foreground/20 hover:border-foreground/40 dark:border-white/40 dark:hover:border-white/60 dark:text-white transition active:border-foreground/50 active:shadow-[var(--press-shadow-sm)]\",\n secondary: \"text-foreground hover:opacity-90 transition active:opacity-80 active:shadow-[var(--press-shadow-strong)]\",\n ghost:\n \"border border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 active:bg-accent/80 dark:active:bg-accent/60 active:shadow-[var(--press-shadow-sm)]\",\n link: \"text-primary underline-offset-4 hover:underline active:opacity-80\",\n },\n size: {\n xs: \"h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3\",\n default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n icon: \"size-9\",\n \"icon-xs\": \"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3\",\n \"icon-sm\": \"size-8\",\n \"icon-lg\": \"size-10\",\n },\n },\n defaultVariants: {\n variant: \"glass\",\n size: \"default\",\n },\n },\n);\n\n/* Each semantic variant's surface role: glass = borderless adaptive glass; destructive/outline the\n same (their chrome is their own border); secondary a bordered surface; default/ghost/link render no\n glass surface. */\nconst SURFACE_ROLE: Record = {\n glass: {},\n destructive: {},\n outline: {\n size: \"sm\",\n },\n secondary: {\n border: true,\n },\n /* null = no `glass` utility, deliberately (see the variant above: it would overwrite bg-primary's\n background-color). `default` still tracks the page material, but through the INHERITED --srf-*\n tokens rather than the utility. The trade is that a per-instance material prop does not apply to\n this one variant — it follows the page, not the button. */\n default: null,\n ghost: null,\n link: null,\n};\n\nfunction Button({\n className,\n variant = \"glass\",\n size = \"default\",\n asChild = false,\n effect,\n ...props\n}: React.ComponentProps<\"button\"> &\n VariantProps & {\n asChild?: boolean;\n } & MaterialAxisProps & {\n effect?: HoverEffect;\n }) {\n const Comp = asChild ? SlotPrimitive.Slot : \"button\";\n\n const [axes, rest] = splitAxisProps(props);\n const m = materialSurface(SURFACE_ROLE[variant ?? \"glass\"] ?? null, axes);\n\n return (\n \n );\n}\n\nexport { Button, buttonVariants };\n",
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { Slot as SlotPrimitive } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { type HoverEffect, hoverEffects } from \"@/lib/hover-effects\";\nimport { type MaterialAxisProps, type MaterialProps, materialSurface, splitAxisProps } from \"@/lib/material\";\nimport { cn } from \"@/lib/utils\";\n\n/* SEMANTIC variants only — each carries BEHAVIOR (text, hover, press shadows); the SURFACE comes from\n the material system (materialSurface below). The five materials + axes are the material/border/veil/\n gradient/glow/sheen props, not variants. */\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.96] active:transition-transform\",\n {\n variants: {\n variant: {\n glass: \"text-foreground hover:opacity-90 transition active:opacity-80 active:shadow-[var(--press-shadow-strong)]\",\n /* The one FILLED action (better-colors: one primary per view, colour on the background not the\n label). It now follows the page material: --srf-bg-image / --srf-shadow are inherited custom\n properties that styles.css sets on per [data-glass] and materials.css sets on any\n [data-material] ancestor, so reading them with the glass stack as fallback makes this button\n wear crystal / chakra / opaque like everything else. It previously hardcoded var(--glass-bg)\n and var(--glass-shadow), pinning the GLASS sheet no matter what the page was wearing.\n The FILL stays --primary/--primary-foreground rather than moving to the gradient accent: that\n pair is contrast-solved (measured worst Lc 87.4 light / 60.9 dark across all 17 presets),\n where the gradient sits at L 60–68 — a mid-tone where text of EITHER polarity runs out of\n room. Measured worst there was Lc 55.5 with text-foreground and no better with any other\n tier or alpha, against an ARC Bronze body floor of 60.\n It deliberately does NOT take the `glass` utility: that utility writes background-color, which\n would override bg-primary and leave the fill fighting it for nothing. */\n default:\n \"bg-primary text-primary-foreground [background-image:var(--srf-bg-image,var(--glass-stack-bg))] shadow-[var(--srf-shadow,var(--glass-shadow))] hover:brightness-105 dark:hover:brightness-95 transition active:opacity-90 active:shadow-[var(--press-shadow)]\",\n destructive:\n \"text-destructive border border-destructive/60 hover:opacity-90 transition active:opacity-80 focus-visible:ring-destructive/20 active:shadow-[var(--press-shadow-strong)]\",\n outline:\n \"text-foreground border-2 border-foreground/20 hover:border-foreground/40 dark:border-white/40 dark:hover:border-white/60 dark:text-white transition active:border-foreground/50 active:shadow-[var(--press-shadow-sm)]\",\n secondary: \"text-foreground hover:opacity-90 transition active:opacity-80 active:shadow-[var(--press-shadow-strong)]\",\n ghost:\n \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 active:bg-accent/80 dark:active:bg-accent/60 active:shadow-[var(--press-shadow-sm)]\",\n link: \"text-primary underline-offset-4 hover:underline active:opacity-80\",\n },\n size: {\n xs: \"h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3\",\n default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n icon: \"size-9\",\n \"icon-xs\": \"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3\",\n \"icon-sm\": \"size-8\",\n \"icon-lg\": \"size-10\",\n },\n },\n defaultVariants: {\n variant: \"glass\",\n size: \"default\",\n },\n },\n);\n\n/* Each semantic variant's surface role: glass = borderless adaptive glass; destructive/outline the\n same (their chrome is their own border); secondary a bordered surface; default/ghost/link render no\n glass surface. */\nconst SURFACE_ROLE: Record = {\n glass: {},\n destructive: {},\n outline: {\n size: \"sm\",\n },\n secondary: {\n border: true,\n },\n /* null = no `glass` utility, deliberately (see the variant above: it would overwrite bg-primary's\n background-color). `default` still tracks the page material, but through the INHERITED --srf-*\n tokens rather than the utility. The trade is that a per-instance material prop does not apply to\n this one variant — it follows the page, not the button. */\n default: null,\n ghost: null,\n link: null,\n};\n\nfunction Button({\n className,\n variant = \"glass\",\n size = \"default\",\n asChild = false,\n effect,\n ...props\n}: React.ComponentProps<\"button\"> &\n VariantProps & {\n asChild?: boolean;\n } & MaterialAxisProps & {\n effect?: HoverEffect;\n }) {\n const Comp = asChild ? SlotPrimitive.Slot : \"button\";\n\n const [axes, rest] = splitAxisProps(props);\n const m = materialSurface(SURFACE_ROLE[variant ?? \"glass\"] ?? null, axes);\n\n return (\n \n );\n}\n\nexport { Button, buttonVariants };\n",
"type": "registry:component",
"target": "components/ui/button.tsx"
}
diff --git a/public/r/calendar.json b/public/r/calendar.json
index 70b77f73..43f7c756 100644
--- a/public/r/calendar.json
+++ b/public/r/calendar.json
@@ -23,7 +23,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/card.json b/public/r/card.json
index c9b0dc11..8c11340c 100644
--- a/public/r/card.json
+++ b/public/r/card.json
@@ -20,7 +20,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/carousel.json b/public/r/carousel.json
index 4354b0d3..792f46c9 100644
--- a/public/r/carousel.json
+++ b/public/r/carousel.json
@@ -22,7 +22,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
diff --git a/public/r/chart.json b/public/r/chart.json
index b38b870c..e65e66d3 100644
--- a/public/r/chart.json
+++ b/public/r/chart.json
@@ -21,7 +21,7 @@
},
{
"path": "lib/material.ts",
- "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
+ "content": "import type * as React from \"react\";\n\n/**\n * The Sistine material system — the single source of the material → markup mapping.\n *\n * FIVE materials (glass, frosted, crystal, chakra, opaque), each a `[data-material]` token set in\n * app/theme/materials.css, composed with orthogonal AXES (border + its colour, veil, size, gradient, glow, sheen)\n * over the one structural `glass` utility. Components call `glassMaterial()` instead of hardcoding\n * recipe class strings (check-theme bans the legacy `glass-bg`/`glass-surface`/… classes).\n *\n * Semantics:\n * - `material` UNDEFINED = adaptive: no attribute is rendered; the surface follows the page's\n * [data-glass] style (the default everywhere).\n * - An explicit material PINS the element under any page style, and — because the token set is\n * inherited custom properties — also acts as a scoped default for adaptive glass descendants\n * (a dialog set to frosted re-skins the controls inside it; pin a child back with its own material).\n * - `\"none\"` = not a glass surface: the helper returns nothing and the component supplies its plain\n * (shadcn) classes.\n */\nexport type Material = \"glass\" | \"frosted\" | \"crystal\" | \"chakra\" | \"opaque\" | \"none\";\n\nexport interface MaterialProps {\n /** undefined = adaptive (follows the page style); \"none\" = the component's plain fallback. */\n material?: Material;\n /** Material border. Three weights: `true`/\"hairline\" = the material's edge (1px; 0.5px under\n * frosted), \"rim\" = 2px, \"frame\" = 4px. */\n border?: boolean | \"hairline\" | \"rim\" | \"frame\";\n /** The border's COLOUR. \"glass\" (the default) = the material's own edge, a near-white rim at the\n * material's alpha, which reads as glass but disappears on light themes at any weight. \"ink\" = the\n * page's text colour (--foreground, at --glass-border-ink-a), a visible line wherever text is legible.\n * Pairs with `border`; under the opaque page style it also colours the uniform panel edge. */\n borderColor?: \"glass\" | \"ink\";\n /** Element-composed legibility floor for read-through overlays (menus, tooltips, toasts). */\n veil?: boolean;\n /** Blur/elevation tier — INTERNAL: set by a component's ROLE (glass-sm/-lg), not a public prop\n * (\"size\" universally means a component's dimensions; the material tier must never shadow that). */\n size?: \"sm\" | \"lg\" | \"xl\";\n /** Readability blur FLOOR (>= --glass-diffuse, default 12px) for text-dense translucent surfaces;\n * inert under opaque. \"stained\" = the dyed mode: same floor, plus true stained-glass optics — the\n * backdrop renders as TONAL SHADES of the theme hue (grayscale luminance collapse — the tint supplies the color). */\n diffuse?: boolean | \"stained\";\n /** Brand-gradient accent layered over the material. */\n gradient?: boolean;\n /** Resting glow, or the intensified \"lg\" halo. */\n glow?: boolean | \"lg\";\n /** Opt-in hover shimmer (pseudo-element based — avoid on sticky/fixed surfaces). */\n sheen?: boolean;\n}\n\n/** The PUBLIC per-instance axis props a component spreads onto its type. Excludes `size` (the material\n * blur tier is a role-internal detail — a component's own `size` prop owns that name). */\nexport type MaterialAxisProps = Omit;\n\n/** The `rest` side of splitAxisProps. Distributes over union props (DayPicker's mode union, Radix\n * ToggleGroup's single/multiple) so a discriminated union survives the split instead of collapsing\n * under a bare Omit. */\nexport type SplitAxisRest = P extends unknown ? Omit
: never;\n\n/** Split the material axis props off a component's props in ONE place — components never enumerate\n * axis keys, so a NEW axis is zero-touch: extend MaterialProps + this destructure, done. `size` is\n * role-internal (not in MaterialAxisProps) and stays in `rest` for the component's own cva. */\nexport function splitAxisProps
(\n props: P,\n): [\n MaterialAxisProps,\n SplitAxisRest
,\n] {\n const { material, border, borderColor, veil, diffuse, gradient, glow, sheen, ...rest } = props;\n return [\n {\n material,\n border,\n borderColor,\n veil,\n diffuse,\n gradient,\n glow,\n sheen,\n },\n /* The conditional (distributive) rest type can't be proven for an unresolved P — safe by\n * construction: rest is exactly P minus the axis keys. */\n rest as SplitAxisRest
,\n ];\n}\n\nexport interface MaterialAttrs {\n /** Spread-ready: undefined for adaptive and \"none\". */\n \"data-material\"?: Exclude;\n /** \"\" when material === \"none\" (caller supplies its plain classes). */\n className: string;\n}\n\nexport function glassMaterial(props: MaterialProps = {}): MaterialAttrs {\n const { material, border, borderColor, veil, size, diffuse, gradient, glow, sheen } = props;\n if (material === \"none\") {\n return {\n className: \"\",\n };\n }\n return {\n \"data-material\": material,\n className: [\n \"glass\",\n border && \"glass-border\",\n border === \"rim\" && \"glass-border-rim\",\n border === \"frame\" && \"glass-border-frame\",\n borderColor === \"ink\" && \"glass-border-ink\",\n veil && \"glass-veil\",\n size === \"sm\" && \"glass-sm\",\n size === \"lg\" && \"glass-lg\",\n size === \"xl\" && \"glass-xl\",\n diffuse && \"glass-diffuse\",\n diffuse === \"stained\" && \"glass-stained\",\n gradient && \"glass-gradient\",\n glow === \"lg\" ? \"glass-glow-lg\" : glow ? \"glass-glow\" : false,\n sheen && \"glass-sheen\",\n ]\n .filter(Boolean)\n .join(\" \"),\n };\n}\n\n/**\n * The component-facing resolver: merge a component's ROLE (its default axes for a given semantic\n * variant) with explicit per-instance material/axis props, and return the surface markup — or null\n * when the element is NOT a glass surface (role === null, or material === \"none\").\n *\n * Explicit props override the role per-axis; unspecified axes fall back to the role.\n */\nexport function materialSurface(role: MaterialProps | null, props: MaterialProps): MaterialAttrs | null {\n if (props.material === \"none\" || role === null) {\n return null;\n }\n return glassMaterial({\n material: props.material,\n border: props.border ?? role.border,\n borderColor: props.borderColor ?? role.borderColor,\n veil: props.veil ?? role.veil,\n size: props.size ?? role.size,\n diffuse: props.diffuse ?? role.diffuse,\n gradient: props.gradient ?? role.gradient,\n glow: props.glow ?? role.glow,\n sheen: props.sheen ?? role.sheen,\n });\n}\n\n/**\n * Typed per-element knobs → CSS custom properties ONLY (the cascade-native successor to the old\n * `glass={{…}}` inline-style prop). Everything routes through the token system, so nothing here can\n * fight a material, a page style, or the theme.\n *\n * The tint* keys recolor engine-composed tokens, which re-resolve at grouped declarations — pair\n * them with a `data-glass-tint` attribute on the same element so the engine re-composes there.\n */\nexport interface GlassVars {\n /** → --glass-tint-h (surface hue) */\n tintH?: number;\n /** → --glass-tint-c (surface chroma — the colorfulness master) */\n tintC?: number;\n /** → --glass-tint-a (tint film alpha) */\n tintA?: number;\n /** → --glass-opacity (0..1 solidify dial; element-composed in `glass`) */\n opacity?: number;\n /** → --srf-blur (number → px; glass material only) */\n blur?: number | string;\n /** → --glass-solid-a (veil floor alpha; element-composed in `glass-veil`) */\n solidA?: number;\n}\n\nexport function glassVars(v: GlassVars = {}): React.CSSProperties {\n const s: Record = {};\n if (v.tintH !== undefined) {\n s[\"--glass-tint-h\"] = String(v.tintH);\n }\n if (v.tintC !== undefined) {\n s[\"--glass-tint-c\"] = String(v.tintC);\n }\n if (v.tintA !== undefined) {\n s[\"--glass-tint-a\"] = String(v.tintA);\n }\n if (v.opacity !== undefined) {\n s[\"--glass-opacity\"] = String(v.opacity);\n }\n if (v.blur !== undefined) {\n s[\"--srf-blur\"] = typeof v.blur === \"number\" ? `${v.blur}px` : v.blur;\n }\n if (v.solidA !== undefined) {\n s[\"--glass-solid-a\"] = String(v.solidA);\n }\n return s as React.CSSProperties;\n}\n",
"type": "registry:lib",
"target": "lib/material.ts"
},
@@ -33,7 +33,7 @@
},
{
"path": "components/ui/chart.tsx",
- "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as RechartsPrimitive from \"recharts\";\n\nimport { type MaterialAxisProps, materialSurface, splitAxisProps } from \"@/lib/material\";\nimport { cn } from \"@/lib/utils\";\n\n// Format: { THEME_NAME: CSS_SELECTOR }\nconst THEMES = {\n light: \"\",\n dark: \".dark\",\n} as const;\n\n/* Roles: the container and legend are bordered adaptive glass;\n the tooltip is bordered + veiled. */\nconst SURFACE_ROLE = {\n border: true,\n};\nconst TOOLTIP_ROLE = {\n border: true,\n veil: true,\n};\n\nexport type ChartConfig = {\n [key in string]: {\n label?: React.ReactNode;\n icon?: React.ComponentType;\n } & (\n | {\n color?: string;\n theme?: never;\n }\n | {\n color?: never;\n theme: {\n light?: string;\n dark?: string;\n };\n }\n );\n};\n\ntype ChartContextProps = {\n config: ChartConfig;\n};\n\nconst ChartContext = React.createContext(null);\n\nfunction useChart() {\n const context = React.useContext(ChartContext);\n\n if (!context) {\n throw new Error(\"useChart must be used within a \");\n }\n\n return context;\n}\n\nconst ChartContainer = React.forwardRef<\n HTMLDivElement,\n React.ComponentProps<\"div\"> & {\n config: ChartConfig;\n children: React.ComponentProps[\"children\"];\n } & MaterialAxisProps & {\n variant?: \"default\" | \"glass\";\n }\n>(({ id, className, children, config, variant = \"glass\", ...props }, ref) => {\n const [axes, rest] = splitAxisProps(props);\n const uniqueId = React.useId();\n const chartId = `chart-${id || uniqueId.replace(/:/g, \"\")}`;\n\n /* Variant classes carry BEHAVIOR only; the surface comes from materialSurface. */\n const variants = {\n default: \"\",\n glass: \"rounded-lg p-4\",\n };\n\n const m = materialSurface(variant === \"default\" ? null : SURFACE_ROLE, axes);\n\n return (\n \n \n // .recharts-cartesian-axis-tick-label > text). The old descendant selector matched nothing after\n // the upgrade, so every tick kept Recharts' own `fill=\"#666\"` — grey in all 17 themes, in both\n // modes, which is what made the axes unreadable. .recharts-cartesian-axis-tick-value sits ON the\n // in both Recharts 2 and 3, so it survives the structure change. (A CSS `fill` beats the\n // fill presentation attribute Recharts sets, so no !important is needed.)\n \"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick-value]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none\",\n variants[variant],\n className,\n )}\n {...rest}\n >\n \n {children} \n
\n \n );\n});\nChartContainer.displayName = \"Chart\";\n\nconst ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {\n const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);\n\n if (!colorConfig.length) {\n return null;\n }\n\n return (\n
+ 0 ? speed : 0}
+ disc={pick(params.get("disc"), PATTERN_DISCS, "right")}
+ sand={pick(params.get("sand"), PATTERN_SANDS, "breeze")}
+ />
+
+ );
+}
diff --git a/components/background-provider.tsx b/components/background-provider.tsx
index 1e3b9881..ec70ecd0 100644
--- a/components/background-provider.tsx
+++ b/components/background-provider.tsx
@@ -104,7 +104,7 @@ export const CANVAS_SPEEDS = [
] as const;
/** Animated-pattern pace: loop/scroll duration in SECONDS (2s fastest … 16s slowest); 0 = static.
- * Shared by every animated pattern (synthwave, moonrise, clouds, dune, aurora, chase) via the --pat-dur CSS var. */
+ * Shared by every animated pattern (synthwave, moonrise, clouds, dune, aurora, forest, chase) via the --pat-dur CSS var. */
export const PATTERN_SPEEDS = [
2,
4,
@@ -114,7 +114,7 @@ export const PATTERN_SPEEDS = [
0,
] as const;
-/** Sun/moon horizontal placement for the horizon scenes (synthwave / moonrise / clouds / dune) — off-center
+/** Sun/moon horizontal placement for the horizon scenes (synthwave / moonrise / clouds / dune / aurora / forest) — off-center
* clears the page's centered content. */
export const PATTERN_DISCS = [
"right",
diff --git a/components/glass-tint-switcher.tsx b/components/glass-tint-switcher.tsx
index ffe09c70..0119e8c4 100644
--- a/components/glass-tint-switcher.tsx
+++ b/components/glass-tint-switcher.tsx
@@ -37,11 +37,12 @@ function applyDiffuse(floor: number) {
}
}
const OPAQUE_L_KEY = "sistine-glass-opaque-l";
+/* Storage keys keep their opaque-era names so a saved outline survives the move to all materials. */
const OUTLINE_KEY = "sistine-glass-opaque-outline";
const OUTLINE_W_KEY = "sistine-glass-opaque-outline-w";
const ACCENT_KEY = "sistine-accent";
-/** Opaque-outline weight → the --glass-opaque-outline-w page var (hairline = unset → 1px default).
+/** Outline weight → the --glass-outline-w page var (hairline = unset → each material's own hairline).
* Same ladder as the border axis (glass-border-rim/-frame); an element-level rim/frame beats this. */
const OUTLINE_WEIGHTS = {
hairline: null,
@@ -122,7 +123,7 @@ const PRESETS = [
label: "Muse",
h: 230,
c: 0.056,
- a: 0.16,
+ a: 0.1,
bespoke: true,
swatch: "linear-gradient(135deg in oklch, oklch(85% 0.1 222), oklch(78% 0.2 326), oklch(84% 0.12 74))",
},
@@ -140,7 +141,7 @@ const PRESETS = [
label: "Gloaming",
h: 32,
c: 0.089,
- a: 0.16,
+ a: 0.12,
bespoke: true,
swatch: "linear-gradient(135deg in oklch, oklch(84% 0.13 62), oklch(78% 0.15 350), oklch(64% 0.14 278))",
},
@@ -344,7 +345,7 @@ function emitFg(v: { h: number; c: number; a: number; base: PresetValue; l: numb
}
/** Per-mode tint-body lightness override ({light?, dark?}), stored as JSON under OPAQUE_L_KEY. Because
- * --glass-opaque-l is mode-aware in CSS (88 light / 36.4 dark), L must be tracked separately per mode — a
+ * --glass-opaque-l is mode-aware in CSS (93 light / 33.4 dark), L must be tracked separately per mode — a
* single global value would wash out the other mode. Old plain-number values parse to a non-object and are
* ignored, self-healing to the mode default. */
function readLmap(): {
@@ -452,11 +453,11 @@ export function GlassTintSwitcher() {
if (df > 0) applyDiffuse(df);
const storedOutline = localStorage.getItem(OUTLINE_KEY) === "1";
setOutline(storedOutline);
- if (storedOutline) document.documentElement.style.setProperty("--glass-opaque-outline", "var(--glass-accent)");
+ if (storedOutline) document.documentElement.dataset.glassOutline = "";
const storedOutlineW = localStorage.getItem(OUTLINE_W_KEY) as OutlineWeight | null;
if (storedOutlineW && OUTLINE_WEIGHTS[storedOutlineW]) {
setOutlineW(storedOutlineW);
- document.documentElement.style.setProperty("--glass-opaque-outline-w", OUTLINE_WEIGHTS[storedOutlineW]);
+ document.documentElement.style.setProperty("--glass-outline-w", OUTLINE_WEIGHTS[storedOutlineW]);
}
try {
const acc = JSON.parse(localStorage.getItem(ACCENT_KEY) ?? "null");
@@ -474,7 +475,7 @@ export function GlassTintSwitcher() {
}
}, []);
- // Tint-body LIGHTNESS is PER-MODE: --glass-opaque-l is mode-aware in CSS (88 light / 36.4 dark), so a single
+ // Tint-body LIGHTNESS is PER-MODE: --glass-opaque-l is mode-aware in CSS (93 light / 33.4 dark), so a single
// global override would clobber the other mode (dark opaque surfaces would render light). (Re-)apply the
// current mode's stored L on mount + on every light/dark toggle; with no stored L for a mode, clear the
// inline override so the CSS default wins. Fires the fg event so AutoForeground re-bands the opaque floor.
@@ -489,7 +490,7 @@ export function GlassTintSwitcher() {
} else {
root.style.removeProperty("--glass-opaque-l");
const computed = Number.parseFloat(getComputedStyle(root).getPropertyValue("--glass-opaque-l"));
- setLightness(Number.isFinite(computed) ? computed : mode === "dark" ? 36.4 : 88);
+ setLightness(Number.isFinite(computed) ? computed : mode === "dark" ? 33.4 : 93);
}
window.dispatchEvent(new Event(FG_EVENT));
};
@@ -607,16 +608,17 @@ export function GlassTintSwitcher() {
}
};
- // Optional accent outline for opaque surfaces — sets --glass-opaque-outline to the theme accent so opaque
- // components gain a colored outline (flair) instead of the flat default border. Applies site-wide.
- // Outline WEIGHT — page-wide width for the same borders the outline toggle colors (opaque material
- // cards + adaptive surfaces on the opaque page style). hairline clears the var (1px default).
+ // Optional accent OUTLINE on every glass surface, whatever its material: sets data-glass-outline on ,
+ // which draws an edge in the theme accent around glass, frosted, crystal, chakra and opaque surfaces,
+ // borderless ones included (styles.css). Applies site-wide.
+ // Outline WEIGHT — page-wide width for every glass edge (the outline's, and the borders surfaces already
+ // draw). hairline clears the var, so each material keeps its own hairline (0.5px under frosted).
const changeOutlineW = (w: OutlineWeight) => {
setOutlineW(w);
const root = document.documentElement;
const px = OUTLINE_WEIGHTS[w];
- if (px) root.style.setProperty("--glass-opaque-outline-w", px);
- else root.style.removeProperty("--glass-opaque-outline-w");
+ if (px) root.style.setProperty("--glass-outline-w", px);
+ else root.style.removeProperty("--glass-outline-w");
try {
localStorage.setItem(OUTLINE_W_KEY, w);
} catch {
@@ -627,8 +629,8 @@ export function GlassTintSwitcher() {
const changeOutline = (on: boolean) => {
setOutline(on);
const root = document.documentElement;
- if (on) root.style.setProperty("--glass-opaque-outline", "var(--glass-accent)");
- else root.style.removeProperty("--glass-opaque-outline");
+ if (on) root.dataset.glassOutline = "";
+ else delete root.dataset.glassOutline;
try {
localStorage.setItem(OUTLINE_KEY, on ? "1" : "0");
} catch {
@@ -812,7 +814,7 @@ export function GlassTintSwitcher() {
/>
-
Opaque outline
+
Outline
changeOutline(!outline)}
diff --git a/components/pattern-background.tsx b/components/pattern-background.tsx
index e9d1d425..03c8fb0f 100644
--- a/components/pattern-background.tsx
+++ b/components/pattern-background.tsx
@@ -1,15 +1,15 @@
-import type { CSSProperties } from "react";
+import { type CSSProperties, useId } from "react";
/**
* Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers
* (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live
* OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a
- * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora
- * drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground
+ * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora /
+ * forest drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground
* (CSS gradient) and CanvasBackground (JS canvas).
*/
-export type PatternStyle = "dots" | "grid" | "mesh" | "starfield" | "synthwave" | "moonrise" | "clouds" | "dune" | "aurora" | "chase";
+export type PatternStyle = "dots" | "grid" | "mesh" | "starfield" | "synthwave" | "moonrise" | "clouds" | "dune" | "aurora" | "forest" | "chase";
export const PATTERN_STYLES: PatternStyle[] = [
"dots",
@@ -21,6 +21,7 @@ export const PATTERN_STYLES: PatternStyle[] = [
"clouds",
"dune",
"aurora",
+ "forest",
"chase",
];
@@ -52,6 +53,7 @@ export const ANIMATED_PATTERNS = new Set([
"clouds",
"dune",
"aurora",
+ "forest",
"chase",
]);
@@ -62,6 +64,7 @@ export const DISC_PATTERNS = new Set([
"clouds",
"dune",
"aurora",
+ "forest",
]);
/** Scenes with a blowing-sand control (still / breeze / storm). */
@@ -103,10 +106,9 @@ export const TILE_SCALE: Record = {
const svgMask = (body: string, viewBox = "0 0 100 100", stretch = false) =>
`url("data:image/svg+xml,${encodeURIComponent(`${body} `)}")`;
-// The ghost silhouette — used by the chase scene's fleeing ghosts.
-const GHOST = svgMask(
- " ",
-);
+// The ghost silhouette — used by the chase scene's fleeing ghosts. Solid: the eyes are painted over it
+// (.pac-ghost::after). Cut out of it, they showed whatever lay behind, so a pellet read as a stray pupil.
+const GHOST = svgMask(" ");
// Scattered "points" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).
// Kept as an array so the density control can render just a slice of them.
@@ -401,6 +403,8 @@ const SKY_STYLES = `
drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))
drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));
opacity: var(--sw-moon-o, 1);
+ /* Its own compositing layer, as .sw-sun below: see the note there. */
+ will-change: transform;
}
/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the
same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene
@@ -432,6 +436,26 @@ const SKY_STYLES = `
linear-gradient(to bottom, #000 0 42%, transparent 42%),
repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));
filter: drop-shadow(0 0 5vh var(--sw-disc-glow));
+ /* Its own compositing LAYER. Moving a disc (the left / center / right control changes \`left\`) on the
+ page's own layer asks WebKit to repaint the old and new spots, and for a disc carrying a drop-shadow
+ filter AND a mask it repainted only part of each: the old sun's top stayed painted at the old spot
+ and the new one showed only its lower half. As a layer, the move just repositions it. */
+ will-change: transform;
+}
+/* Whole-pixel discs. WebKit sizes a filtered element's buffer from its box, and at a fractional width
+ (30vh is 275.4px on a 918px viewport) it clips a sliver off the disc's left edge, which flattens a
+ moon visibly. Rounding to an even pixel count keeps the halves whole. Behind @supports because
+ round() only reached Chrome in 125: inside var() an unsupported function is invalid at computed-value
+ time, which would drop the size outright rather than fall back to the declaration above. */
+@supports (width: round(1px, 2px)) {
+ .scene-moon {
+ width: round(var(--moon-size, 40vh), 2px);
+ height: round(var(--moon-size, 40vh), 2px);
+ }
+ .sw-sun {
+ width: round(var(--sun-size, 46vh), 2px);
+ height: round(var(--sun-size, 46vh), 2px);
+ }
}`;
// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),
@@ -595,13 +619,22 @@ const SYNTHWAVE_STYLES = `
right: -60%;
top: -4vmin;
height: 66vh;
- background-size: 4vmin 4vmin;
+ --sw-cell: 4vmin;
+ background-size: var(--sw-cell) var(--sw-cell);
transform-origin: 50% 0%;
transform: rotateX(78deg);
}
+/* A WHOLE-pixel cell where round() exists. The gradient tile is painted at whole pixels, so at a
+ fractional 4vmin (28.8px on a 720px-tall window) a one-cell scroll ended a fraction of a pixel off the
+ tile it started on, and the floor hitched once per loop, magnified by the tilt. */
+@supports (width: round(1px, 2px)) {
+ .sw-grid {
+ --sw-cell: round(4vmin, 1px);
+ }
+}
/* Only the RUNGS scroll, via a COMPOSITOR-ONLY transform (translateY one cell) — GPU-composited, no
per-frame paint (animating background-position re-rasterizes the gradient every frame; transform does
- not). Seamless because: (a) the tile repeats every cell, so translateY(4vmin) is pixel-identical to 0 —
+ not). Seamless because: (a) the tile repeats every cell, so translateY(one cell) is pixel-identical to 0 —
no loop jump; and (b) the plane overhangs far past what's visible (tall height + the -4vmin top), so the
sliding edges stay beyond the floor's overflow-clip + fade mask and never surface — which is what the
earlier translate lacked. Rails stay static: a floor moving toward you slides the cross-rungs past, but
@@ -616,7 +649,7 @@ const SYNTHWAVE_STYLES = `
}
@keyframes sw-grid-scroll {
from { transform: rotateX(78deg) translateY(0); }
- to { transform: rotateX(78deg) translateY(4vmin); }
+ to { transform: rotateX(78deg) translateY(var(--sw-cell, 4vmin)); }
}
${stopRules(".sw-grid-rungs")}`;
/* The moonrise variant — a purple-blue moon over a midnight sky, no scanlines — shares every structural
@@ -644,16 +677,18 @@ const CLOUDS_STYLES = `
--cl-sky-b: color-mix(in oklch, oklch(0.66 0.1 248), oklch(0.66 0.09 var(--glass-tint-h, 248)) var(--sw-cast));
--cl-sky-c: color-mix(in oklch, oklch(0.79 0.06 241), oklch(0.79 0.055 var(--glass-tint-h, 241)) var(--sw-cast));
--cl-sky-d: oklch(0.9 0.025 235); /* the pale stop the deck emerges from — authored, no cast */
- --cl-haze: oklch(0.96 0.02 234 / 0.75);
+ /* Was / 0.75: over the far and mid deck that much near-white turned the whole lower view into fog, so
+ the deck read as a white wash with grey smudges rather than as cloud. */
+ --cl-haze: oklch(0.96 0.02 234 / 0.4);
/* Deck tones — authored white/blue-gray (no theme cast: tinted snow reads dirty). Far is hazier,
near is the brightest tops with the deepest bellies — but the whole ladder sits CLOSE together:
cumulus shadows in the reference are far subtler than a poster's. */
--cl-f-hi: oklch(0.955 0.014 234);
- --cl-f-lo: oklch(0.9 0.022 240);
+ --cl-f-lo: oklch(0.87 0.03 240);
--cl-m-hi: oklch(0.975 0.01 233);
- --cl-m-lo: oklch(0.88 0.026 243);
+ --cl-m-lo: oklch(0.83 0.036 243);
--cl-n-hi: oklch(0.99 0.006 230);
- --cl-n-lo: oklch(0.855 0.028 245);
+ --cl-n-lo: oklch(0.79 0.04 245);
/* daytime SUN — the shared outrun disc, low on the deck. Warmer and less magenta than synthwave's,
because it has to sit against a deep blue altitude sky rather than a violet one. */
--sw-sun-o: 1;
@@ -674,10 +709,12 @@ const CLOUDS_STYLES = `
}
.dark [data-pattern="clouds"] {
/* NIGHT — a moonlit slate deck under deep navy, and the moon comes forward */
- --cl-sky-a: oklch(0.08 0.03 262);
- --cl-sky-b: oklch(0.11 0.04 266);
- --cl-sky-c: oklch(0.15 0.05 270);
- --cl-sky-d: oklch(0.21 0.05 276);
+ /* The same theme cast as the day sky (--sw-cast, from the tint's chroma): the night sky ignored the tint
+ entirely, the one horizon scene that looked the same in every theme after dark. */
+ --cl-sky-a: color-mix(in oklch, oklch(0.08 0.03 262), oklch(0.08 0.03 var(--glass-tint-h, 262)) var(--sw-cast));
+ --cl-sky-b: color-mix(in oklch, oklch(0.11 0.04 266), oklch(0.11 0.04 var(--glass-tint-h, 266)) var(--sw-cast));
+ --cl-sky-c: color-mix(in oklch, oklch(0.15 0.05 270), oklch(0.15 0.05 var(--glass-tint-h, 270)) var(--sw-cast));
+ --cl-sky-d: color-mix(in oklch, oklch(0.21 0.05 276), oklch(0.21 0.05 var(--glass-tint-h, 276)) var(--sw-cast));
--cl-haze: oklch(0.45 0.05 272 / 0.35);
--cl-f-hi: oklch(0.4 0.04 268);
--cl-f-lo: oklch(0.345 0.04 266);
@@ -745,7 +782,8 @@ const CLOUDS_STYLES = `
to { transform: translateX(calc(-1 * var(--cl-tile))); }
}
/* Each deck strip's tile = a bottom linear-gradient ramp (the solid deck) topped by overlapping dome
- ellipses with WIDE, feathered color stops — reference cumulus tops are soft, not cut — and each of
+ ellipses with feathered color stops — reference cumulus tops are soft, not cut, but feathered over a
+ third of the radius they dissolved into the base and the deck lost its form — and each of
the bigger domes carries a --*-lo SHADED BELLY tucked directly beneath it (listed after the domes so
it paints under them). Bellies attached to a puff are what read as volume; the old free-floating
crevices between puffs read as dirt. Domes overlap x-wise so the deck is continuous; far domes are
@@ -761,19 +799,19 @@ const CLOUDS_STYLES = `
--cl-tile: 120vh;
--cl-k: 10;
background-image:
- radial-gradient(7% 34% at 7% 46%, var(--cl-f-hi) 0 40%, transparent 72%),
- radial-gradient(5% 26% at 16% 52%, var(--cl-f-hi) 0 38%, transparent 70%),
- radial-gradient(9% 36% at 27% 44%, var(--cl-f-hi) 0 42%, transparent 74%),
- radial-gradient(6% 28% at 41% 54%, var(--cl-f-hi) 0 38%, transparent 70%),
- radial-gradient(10% 38% at 52% 46%, var(--cl-f-hi) 0 42%, transparent 74%),
- radial-gradient(5% 24% at 60% 56%, var(--cl-f-hi) 0 38%, transparent 70%),
- radial-gradient(8% 34% at 71% 48%, var(--cl-f-hi) 0 40%, transparent 72%),
- radial-gradient(6% 30% at 86% 52%, var(--cl-f-hi) 0 38%, transparent 70%),
- radial-gradient(5% 32% at 95% 46%, var(--cl-f-hi) 0 40%, transparent 72%),
- radial-gradient(4% 18% at 12% 50%, var(--cl-f-hi) 0 36%, transparent 70%),
- radial-gradient(5% 20% at 33% 48%, var(--cl-f-hi) 0 36%, transparent 70%),
- radial-gradient(3% 14% at 47% 56%, var(--cl-f-hi) 0 34%, transparent 68%),
- radial-gradient(6% 22% at 78% 50%, var(--cl-f-hi) 0 36%, transparent 70%),
+ radial-gradient(7% 34% at 7% 46%, var(--cl-f-hi) 0 40%, transparent 58%),
+ radial-gradient(5% 26% at 16% 52%, var(--cl-f-hi) 0 38%, transparent 56%),
+ radial-gradient(9% 36% at 27% 44%, var(--cl-f-hi) 0 42%, transparent 60%),
+ radial-gradient(6% 28% at 41% 54%, var(--cl-f-hi) 0 38%, transparent 56%),
+ radial-gradient(10% 38% at 52% 46%, var(--cl-f-hi) 0 42%, transparent 60%),
+ radial-gradient(5% 24% at 60% 56%, var(--cl-f-hi) 0 38%, transparent 56%),
+ radial-gradient(8% 34% at 71% 48%, var(--cl-f-hi) 0 40%, transparent 58%),
+ radial-gradient(6% 30% at 86% 52%, var(--cl-f-hi) 0 38%, transparent 56%),
+ radial-gradient(5% 32% at 95% 46%, var(--cl-f-hi) 0 40%, transparent 58%),
+ radial-gradient(4% 18% at 12% 50%, var(--cl-f-hi) 0 36%, transparent 54%),
+ radial-gradient(5% 20% at 33% 48%, var(--cl-f-hi) 0 36%, transparent 54%),
+ radial-gradient(3% 14% at 47% 56%, var(--cl-f-hi) 0 34%, transparent 52%),
+ radial-gradient(6% 22% at 78% 50%, var(--cl-f-hi) 0 36%, transparent 54%),
radial-gradient(5% 16% at 7% 62%, var(--cl-f-lo) 0 30%, transparent 76%),
radial-gradient(7% 20% at 27% 62%, var(--cl-f-lo) 0 30%, transparent 76%),
radial-gradient(8% 22% at 52% 64%, var(--cl-f-lo) 0 30%, transparent 76%),
@@ -786,19 +824,19 @@ const CLOUDS_STYLES = `
--cl-tile: 170vh;
--cl-k: 6.5;
background-image:
- radial-gradient(11% 40% at 9% 48%, var(--cl-m-hi) 0 42%, transparent 74%),
- radial-gradient(6% 26% at 18% 56%, var(--cl-m-hi) 0 38%, transparent 70%),
- radial-gradient(14% 44% at 31% 46%, var(--cl-m-hi) 0 44%, transparent 76%),
- radial-gradient(8% 32% at 44% 56%, var(--cl-m-hi) 0 40%, transparent 72%),
- radial-gradient(12% 42% at 58% 46%, var(--cl-m-hi) 0 44%, transparent 76%),
- radial-gradient(5% 22% at 66% 58%, var(--cl-m-hi) 0 36%, transparent 70%),
- radial-gradient(13% 40% at 78% 48%, var(--cl-m-hi) 0 44%, transparent 75%),
- radial-gradient(9% 34% at 91% 52%, var(--cl-m-hi) 0 40%, transparent 72%),
- radial-gradient(7% 28% at 24% 56%, var(--cl-m-hi) 0 38%, transparent 70%),
- radial-gradient(6% 24% at 15% 52%, var(--cl-m-hi) 0 36%, transparent 70%),
- radial-gradient(8% 28% at 38% 50%, var(--cl-m-hi) 0 38%, transparent 72%),
- radial-gradient(5% 20% at 52% 56%, var(--cl-m-hi) 0 34%, transparent 68%),
- radial-gradient(7% 26% at 85% 52%, var(--cl-m-hi) 0 36%, transparent 70%),
+ radial-gradient(11% 40% at 9% 48%, var(--cl-m-hi) 0 42%, transparent 60%),
+ radial-gradient(6% 26% at 18% 56%, var(--cl-m-hi) 0 38%, transparent 56%),
+ radial-gradient(14% 44% at 31% 46%, var(--cl-m-hi) 0 44%, transparent 62%),
+ radial-gradient(8% 32% at 44% 56%, var(--cl-m-hi) 0 40%, transparent 58%),
+ radial-gradient(12% 42% at 58% 46%, var(--cl-m-hi) 0 44%, transparent 62%),
+ radial-gradient(5% 22% at 66% 58%, var(--cl-m-hi) 0 36%, transparent 54%),
+ radial-gradient(13% 40% at 78% 48%, var(--cl-m-hi) 0 44%, transparent 62%),
+ radial-gradient(9% 34% at 91% 52%, var(--cl-m-hi) 0 40%, transparent 58%),
+ radial-gradient(7% 28% at 24% 56%, var(--cl-m-hi) 0 38%, transparent 56%),
+ radial-gradient(6% 24% at 15% 52%, var(--cl-m-hi) 0 36%, transparent 54%),
+ radial-gradient(8% 28% at 38% 50%, var(--cl-m-hi) 0 38%, transparent 56%),
+ radial-gradient(5% 20% at 52% 56%, var(--cl-m-hi) 0 34%, transparent 52%),
+ radial-gradient(7% 26% at 85% 52%, var(--cl-m-hi) 0 36%, transparent 54%),
radial-gradient(9% 22% at 9% 66%, var(--cl-m-lo) 0 30%, transparent 76%),
radial-gradient(11% 26% at 31% 68%, var(--cl-m-lo) 0 30%, transparent 76%),
radial-gradient(10% 24% at 58% 68%, var(--cl-m-lo) 0 30%, transparent 76%),
@@ -811,18 +849,18 @@ const CLOUDS_STYLES = `
--cl-tile: 230vh;
--cl-k: 4;
background-image:
- radial-gradient(15% 46% at 15% 54%, var(--cl-n-hi) 0 44%, transparent 76%),
- radial-gradient(8% 32% at 28% 64%, var(--cl-n-hi) 0 40%, transparent 72%),
- radial-gradient(18% 48% at 44% 52%, var(--cl-n-hi) 0 46%, transparent 78%),
- radial-gradient(10% 38% at 58% 62%, var(--cl-n-hi) 0 42%, transparent 74%),
- radial-gradient(16% 46% at 73% 54%, var(--cl-n-hi) 0 46%, transparent 78%),
- radial-gradient(7% 28% at 83% 64%, var(--cl-n-hi) 0 38%, transparent 72%),
- radial-gradient(8% 42% at 92% 56%, var(--cl-n-hi) 0 40%, transparent 73%),
- radial-gradient(6% 26% at 66% 70%, var(--cl-n-hi) 0 36%, transparent 70%),
- radial-gradient(9% 30% at 22% 60%, var(--cl-n-hi) 0 38%, transparent 72%),
- radial-gradient(12% 36% at 36% 56%, var(--cl-n-hi) 0 40%, transparent 74%),
- radial-gradient(7% 26% at 51% 62%, var(--cl-n-hi) 0 36%, transparent 70%),
- radial-gradient(10% 32% at 86% 60%, var(--cl-n-hi) 0 38%, transparent 72%),
+ radial-gradient(15% 46% at 15% 54%, var(--cl-n-hi) 0 44%, transparent 62%),
+ radial-gradient(8% 32% at 28% 64%, var(--cl-n-hi) 0 40%, transparent 58%),
+ radial-gradient(18% 48% at 44% 52%, var(--cl-n-hi) 0 46%, transparent 64%),
+ radial-gradient(10% 38% at 58% 62%, var(--cl-n-hi) 0 42%, transparent 60%),
+ radial-gradient(16% 46% at 73% 54%, var(--cl-n-hi) 0 46%, transparent 64%),
+ radial-gradient(7% 28% at 83% 64%, var(--cl-n-hi) 0 38%, transparent 56%),
+ radial-gradient(8% 42% at 92% 56%, var(--cl-n-hi) 0 40%, transparent 58%),
+ radial-gradient(6% 26% at 66% 70%, var(--cl-n-hi) 0 36%, transparent 54%),
+ radial-gradient(9% 30% at 22% 60%, var(--cl-n-hi) 0 38%, transparent 56%),
+ radial-gradient(12% 36% at 36% 56%, var(--cl-n-hi) 0 40%, transparent 58%),
+ radial-gradient(7% 26% at 51% 62%, var(--cl-n-hi) 0 36%, transparent 54%),
+ radial-gradient(10% 32% at 86% 60%, var(--cl-n-hi) 0 38%, transparent 56%),
radial-gradient(13% 26% at 15% 74%, var(--cl-n-lo) 0 30%, transparent 76%),
radial-gradient(15% 28% at 44% 72%, var(--cl-n-lo) 0 30%, transparent 76%),
radial-gradient(13% 26% at 73% 74%, var(--cl-n-lo) 0 30%, transparent 76%),
@@ -1262,6 +1300,208 @@ ${stopRules(".du-grain-tile", ".du-veil")}`;
// The chosen color rides both gradients: the --au-3 band takes var(--accent-h, var(--glass-tint-h)) in
// both modes, and by day it also carries the cream mid-sky stop — at LOW chroma, which is what that band
// is in the reference photographs, so the theme reads without fighting them.
+/* The three curtains' ray tiles, written "0, --au-1 4 9, 13, …": transparent marks (%) between
+ `color from to` bands, across a tile `vh` wide. One source for the sky's gradients (rays()) and the day
+ reflection's streaks (AuroraStreaks), so the reflection always starts under its own ray. */
+const AU_TILES = {
+ a: {
+ vh: 110,
+ seed: 7,
+ tile: "0, --au-1 4 9, 13, --au-2 16 18.5, 24, --au-1 30 37, 42, --au-3 47 49, 54, --au-1 59 66, 71, --au-2 76 78, 83, --au-1 88 91, 96",
+ },
+ b: {
+ vh: 150,
+ seed: 19,
+ tile: "0, --au-3 6 13, 20, --au-4 27 30, 36, --au-3 45 54, 61, --au-4 68 70, 76, --au-3 83 87, 93",
+ },
+ c: {
+ vh: 70,
+ seed: 31,
+ tile: "0, --au-1 10 11.5, 16, --au-2 27 28, 33, --au-4 44 46.5, 52, --au-1 63 64, 70, --au-2 81 83, 89",
+ },
+} as const;
+type AuLayer = keyof typeof AU_TILES;
+type RayStop =
+ | number
+ | [
+ color: string,
+ from: number,
+ to: number,
+ ];
+
+function rayStops(tile: string): RayStop[] {
+ return tile.split(",").map((t) => {
+ const [a, from, to] = t.trim().split(/\s+/);
+ return to === undefined
+ ? Number(a)
+ : [
+ a,
+ Number(from),
+ Number(to),
+ ];
+ });
+}
+
+/* A tile as the curtain's gradient. --au-crisp scales every band's ramp in and out (as it scales the
+ blur), so at night's 1 this is exactly the authored tile, and by day the ramps shrink toward the band,
+ which leaves clean edges and clear sky between the rays. */
+function rays(tile: string): string {
+ const stops = rayStops(tile);
+ const out: string[] = [];
+ stops.forEach((s, i) => {
+ if (typeof s !== "number") {
+ out.push(`var(${s[0]}) ${s[1]}% ${s[2]}%`);
+ return;
+ }
+ const prev = stops[i - 1];
+ const next = stops[i + 1];
+ if (Array.isArray(prev)) out.push(`transparent calc(${prev[2]}% + ${s - prev[2]}% * var(--au-crisp, 1))`);
+ if (Array.isArray(next)) out.push(`transparent calc(${next[1]}% - ${next[1] - s}% * var(--au-crisp, 1))`);
+ });
+ return `linear-gradient(to right, ${out.join(", ")})`;
+}
+
+/** The day reflection of one curtain tile: every ray as a straight STREAK down the water. Coordinates are
+ * pre-flip (the mirror is scaleY(-1)): y 100 is the horizon end, y 0 the far end toward the viewer; x is
+ * in tenths of a vh. A streak starts under its ray and leans as the mirrored ray does (the curtain's own
+ * skew), plus a slight tilt of its own, and widens toward the viewer as a reflection does in perspective;
+ * a lighter CORE runs down its middle, the specular shine of calm water by day. The streaks it replaced
+ * wavered and broke into glints, and as the lights swayed that read as a belt carrying them along. */
+function auroraStreaks({ vh, seed, tile }: (typeof AU_TILES)[AuLayer]) {
+ const rand = mulberry32(seed);
+ const stops = rayStops(tile);
+ const unit = 10;
+ const at = (pct: number) => (pct / 100) * vh * unit;
+ const quad = (a: number, b: number, c: number, d: number) => `M${f1(a)} 100 L${f1(b)} 100 L${f1(d)} 0 L${f1(c)} 0 Z`;
+ const streaks: {
+ color: string;
+ body: string;
+ core: string;
+ }[] = [];
+ stops.forEach((s, i) => {
+ if (typeof s === "number") return;
+ const [color, from, to] = s;
+ // Hard edges stand in for the day tile's ramps (0.3 of the authored ones), so each edge takes half.
+ const x0 = at(from - (from - Number(stops[i - 1])) * 0.15);
+ const x1 = at(to + (Number(stops[i + 1]) - to) * 0.15);
+ const width = x1 - x0;
+ const tilt = (rand() - 0.5) * 4 * unit;
+ const spread = width * (0.2 + rand() * 0.45);
+ const mid = (x0 + x1) / 2;
+ const core = width * 0.24;
+ streaks.push({
+ color,
+ body: quad(x0, x1, x0 + tilt - spread / 2, x1 + tilt + spread / 2),
+ core: quad(mid - core / 2, mid + core / 2, mid + tilt - core * 0.7, mid + tilt + core * 0.7),
+ });
+ });
+ return {
+ width: vh * unit,
+ streaks,
+ };
+}
+
+/* Built on first render, not at module load: the helpers it shares with the forest are declared below. */
+let auStreaks: Record> | undefined;
+const auroraStreakTiles = () =>
+ (auStreaks ??= {
+ a: auroraStreaks(AU_TILES.a),
+ b: auroraStreaks(AU_TILES.b),
+ c: auroraStreaks(AU_TILES.c),
+ });
+
+const AU_COLORS = [
+ "--au-1",
+ "--au-2",
+ "--au-3",
+ "--au-4",
+] as const;
+
+/** One curtain's day reflection, inside the same .au-curtain box as the gradient mirror it stands in for,
+ * so it leans, sways, fades and blurs exactly as that one does, and tiled the same way (from the
+ * curtain's left edge), so each streak starts under its own ray. Each colour's streaks are brightest at
+ * the waterline and fade with distance (a gradient per colour); the cores fade faster. */
+function AuroraStreaks({ layer }: { layer: AuLayer }) {
+ const { vh } = AU_TILES[layer];
+ const { width, streaks } = auroraStreakTiles()[layer];
+ const id = `${useId().replace(/[^\w-]/g, "")}au-${layer}`;
+ // The curtain is 140vw wide: enough tiles for a viewport up to 5× as wide as it is tall, plus one.
+ const tiles = Math.ceil(700 / vh) + 1;
+ return (
+
+
+
+ {AU_COLORS.map((c) => (
+
+
+
+
+
+ ))}
+ {AU_COLORS.map((c) => (
+
+
+
+
+ ))}
+
+ {streaks.map((st) => (
+
+ ))}
+ {streaks.map((st) => (
+
+ ))}
+
+
+ {Array.from(
+ {
+ length: tiles,
+ },
+ (_, k) => (
+
+ ),
+ )}
+
+
+ );
+}
+
const AURORA_STYLES = `
[data-pattern="aurora"] {
/* DAY — GOLDEN HOUR on the same ramp as dune, from the aerial references: a full blue up top
@@ -1269,13 +1509,21 @@ const AURORA_STYLES = `
chosen color still rides the sunrise gradient, but at LOW chroma — that is exactly what the
reference's desaturated mid-sky is, so it carries the theme without fighting the photograph.
The curtains stay vivid and opaque (no see-through wisps): they are the subject, the sky is not. */
- --au-sky-a: oklch(0.58 0.1 252);
- --au-sky-b: oklch(0.76 0.04 var(--accent-h, var(--glass-tint-h, 310))); /* the chosen color, in the cream band */
- --au-sky-c: oklch(0.85 0.035 60);
- --au-sky-d: oklch(0.84 0.06 52); /* apricot… */
- --au-sky-e: oklch(0.76 0.075 40); /* …into the deeper orange the water meets */
- --au-water-a: oklch(0.68 0.055 42);
- --au-water-b: oklch(0.48 0.04 34);
+ /* BLUE HOUR, so the lights can show by day. They glow at night because they land far brighter than the
+ sky; under the old pale golden-hour sky (L 0.76–0.85 below the zenith, a white cirrus veil and a
+ haze over it) they could only sit BELOW it and read as muddy bands, and the whole sky read washed
+ out. So the upper sky deepens to a saturated twilight blue and the curtains sit ABOVE it in
+ lightness and glow, while the warmth stays a band at the horizon, where the low sun is and where the
+ lights fade into its brightness as real ones do. */
+ --au-sky-a: oklch(0.42 0.14 262);
+ --au-sky-b: oklch(0.54 0.11 var(--accent-h, var(--glass-tint-h, 285))); /* the chosen color, at twilight */
+ --au-sky-c: oklch(0.74 0.09 50);
+ --au-sky-d: oklch(0.82 0.11 62); /* gold… */
+ --au-sky-e: oklch(0.74 0.12 42); /* …into the deeper orange the water meets */
+ /* Lighter than night's by a good way: by day calm water mirrors the bright sky, warm near the line and
+ blue toward the viewer, where it reflects the zenith. */
+ --au-water-a: oklch(0.64 0.1 45);
+ --au-water-b: oklch(0.34 0.09 260);
--au-rip-hi: oklch(1 0 0 / 0.05);
--au-rip-lo: oklch(0.35 0.05 45 / 0.04);
/* Curtains sit BELOW the day sky, not above it. Night works because its lights land ~38 L brighter
@@ -1286,30 +1534,40 @@ const AURORA_STYLES = `
as bands again. Chroma is ~88% of each hue's own ceiling AT ITS lightness rather than one shared
number — green and teal bottom out fast below L 0.6 (ceilings 0.132 / 0.101), while violet and pink
still have 0.28 / 0.23 to spend, so a single value would either mud the first two or clip the rest. */
- --au-1: oklch(0.6 0.116 160 / 0.66);
- --au-2: oklch(0.6 0.089 190 / 0.6);
- --au-3: oklch(0.55 0.2 var(--accent-h, var(--glass-tint-h, 305)) / 0.6);
- --au-4: oklch(0.55 0.2 345 / 0.56);
- --au-glow: oklch(0.82 0.11 var(--au-h) / 0.55); /* horizon takes the chosen colour too */
+ /* Lighter than the blue-hour sky they hang in, so they glow out of it as the night ones do, and thinner
+ than night's, so deep blue shows between the rays: haze is low contrast, and three overlapping
+ curtains at night's strength left no clear sky anywhere. */
+ --au-1: oklch(0.82 0.17 158 / 0.7);
+ --au-2: oklch(0.82 0.12 190 / 0.6);
+ --au-3: oklch(0.72 0.17 var(--accent-h, var(--glass-tint-h, 305)) / 0.52);
+ --au-4: oklch(0.74 0.16 345 / 0.46);
+ /* By day the rays are CRISP (blur and ray ramps × 0.3, see rays(): soft edges were most of the haze)
+ and the horizon haze band thin. Night resets both. */
+ --au-crisp: 0.3;
+ --au-haze-o: 0.22;
+ --au-glow: oklch(0.84 0.11 var(--au-h) / 0.35); /* the haze band's colour: the chosen colour, thin */
/* Was 1.15. Day is the mode that already reads hazy, so it was the one mode adding 15% blur on top
of everything else; the mirror still sets its own 1.5 below. */
--au-blur-m: 1;
/* Was / 0.5. This veil is gated on --sw-sun-o, so it exists ONLY by day — at 0.85 opacity an L-0.96
near-white wash lifted the upper sky +4.7 L toward white, i.e. literal haze that night never had.
0.3 halves it to +2.8 and it reads as thin cirrus instead of a scrim. */
- --au-cirrus: oklch(0.96 0.05 var(--au-h) / 0.3);
+ /* Off by day now: the old 0.3 veil was half the wash-out, and over glowing curtains even a trace read
+ as scan lines. */
+ --au-cirrus: transparent;
+ /* By day the curtains' bright lip LIFTS a quarter of the way up their height (and the reflection's
+ drops to match), so the warm band and the sun's glare show beneath the lights, where real aurora
+ fades into the brightness of a low sun; with the lip on the horizon they buried it. */
+ --au-fade: linear-gradient(to top, transparent 3%, rgb(0 0 0 / 0.45) 17%, #000 29%, rgb(0 0 0 / 0.45) 48%, rgb(0 0 0 / 0.1) 69%, transparent 85%);
+ --au-mirror-fade: linear-gradient(to top, transparent 5%, rgb(0 0 0 / 0.6) 14%, #000 22%, #000 40%, rgb(0 0 0 / 0.7) 62%, transparent 96%);
/* daytime SUN — new to this scene. Aurora stays MOONLESS at night on purpose (a moonless sky is when
the lights are most vivid), so the moon gate is off in both modes and only the sun ever shows. */
--sw-sun-o: 1;
--sw-moon-o: 0;
--sun-size: 38vh;
- /* SOLID disc — no outrun banding in this scene. .sw-sun's mask unions a solid crown with a repeating
- band gradient, and the gaps between those bands are TRANSPARENT: in synthwave that reveals the sky,
- but here it revealed the aurora curtains behind the disc, so the lights read straight through the
- sun. Making the band run longer than the disc collapses the repeat into one opaque pass, so the sun
- occludes the curtains the way a sun should. The banding is a synthwave signature anyway — this
- scene is a golden-hour horizon, not an outrun grid. */
- --sun-band: 100vh;
+ /* The same banded outrun sun as every other scene. Its band gaps are transparent, and here they used to
+ show the curtains through the disc, so the sun was made solid; .au-sunsky now paints the sky into
+ the disc's footprint first, so the gaps show sky, as they do everywhere else. */
/* Sun ramp, synthwave's structure with the ACCENT standing in for its magenta: the chosen colour sits
at the BOTTOM of the disc — the end nearest the horizon — and mixes through to a warm yellow at the
top. Recolouring the whole disc made a green sun when the tint was green; keeping the crown warm
@@ -1344,11 +1602,27 @@ const AURORA_STYLES = `
night inherits, so dropping it to 1 there to de-haze the day would have quietly sharpened night
too — and night is the mode that already reads right. */
--au-blur-m: 1.15;
- /* Must be LIGHTER than the curtains it washes over (L ~0.75) — a glow that sits below them in
- lightness reads as a dull band ruled across the waterline, not as light. */
+ /* The reflection takes the SKY's blur at night, not the softer 1.5 it has by day. Sky and reflection meet
+ at the waterline, and blurred by different amounts every column changed profile there, bright ones
+ stepping down and gaps stepping up: a hard edge along the horizon, worst in Safari (6.5 levels per
+ column across one px). Matched, the step is the same as between any two rows. By day the reflection
+ starts below the line (the lip is lifted), so the softer mirror never meets the sky. */
+ --au-mirror-blur-m: 1.15;
+ /* The haze band's colour. Must be LIGHTER than the curtains it washes over (L ~0.75): a haze below them
+ in lightness reads as a dull band ruled across the waterline, not as light. */
--au-glow: oklch(0.84 0.1 180 / 0.28);
--au-h: var(--accent-h, var(--glass-tint-h, 35));
--sw-sun-o: 0; /* moonless AND sunless at night — the curtains carry the scene */
+ /* The lips sit on the horizon again at night: there is no sun for them to fade into. Night keeps its own
+ softness and haze too. */
+ --au-fade: initial;
+ --au-mirror-fade: initial;
+ --au-crisp: 1;
+ /* The horizon's full-width washes (a glow strip above the line, and a reflection strip below it that is
+ gone now) peaked ON the line at every x, so they ruled a cyan line across the whole view, through the
+ dark gaps between the curtains as much as under them. The curtains' own lips and their reflections
+ carry the horizon, and between them it stays dark; the soft haze band just blends sky into water. */
+ --au-haze-o: 0.4;
--sw-moon-o: 0;
--sw-stars-o: 1;
}
@@ -1371,9 +1645,14 @@ const AURORA_STYLES = `
.au-curtain {
position: absolute;
top: -6%;
- /* Runs PAST the waterline (the water sheet and haze are drawn over it). Stopping short of 58% left a
- 1% strip of bare scene gradient reading as a dark bar straight across the horizon. */
- bottom: 41%;
+ /* The box runs 3vh PAST the waterline and is CLIPPED exactly on it. Ending the box at 58% let the blur
+ eat its last few px, a dark bar of bare scene gradient along the horizon; running it past and leaving
+ it visible stacked it under the reflection, a bright strip; feathering it under the water sheet
+ crossfaded three layers at once, a dark one. Clipped, the blurred lip runs at full strength right to
+ the line and the reflection takes over there. (The clip is pre-transform, but sway is a skewX and a
+ translateX, neither of which moves a horizontal edge.) */
+ bottom: 39%;
+ clip-path: inset(0 0 3vh 0);
left: -20vw;
right: -20vw;
background-repeat: repeat-x;
@@ -1381,9 +1660,9 @@ const AURORA_STYLES = `
transform-origin: 50% 100%;
transform: skewX(var(--au-lean, -6deg));
opacity: calc(var(--au-lo, 1) * var(--au-dim, 1));
- filter: blur(calc(var(--au-blur, 1.2vh) * var(--au-blur-m, 1)));
- -webkit-mask-image: linear-gradient(to top, #000 10%, rgb(0 0 0 / 0.62) 45%, rgb(0 0 0 / 0.2) 80%, transparent 97%), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%);
- mask-image: linear-gradient(to top, #000 10%, rgb(0 0 0 / 0.62) 45%, rgb(0 0 0 / 0.2) 80%, transparent 97%), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%);
+ filter: blur(calc(var(--au-blur, 1.2vh) * var(--au-blur-m, 1) * var(--au-crisp, 1)));
+ -webkit-mask-image: var(--au-fade, linear-gradient(to top, #000 13%, rgb(0 0 0 / 0.62) 47%, rgb(0 0 0 / 0.2) 81%, transparent 97%)), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%);
+ mask-image: var(--au-fade, linear-gradient(to top, #000 13%, rgb(0 0 0 / 0.62) 47%, rgb(0 0 0 / 0.2) 81%, transparent 97%)), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%);
-webkit-mask-composite: source-in;
mask-composite: intersect;
animation:
@@ -1401,7 +1680,7 @@ const AURORA_STYLES = `
to { opacity: calc(var(--au-lo, 1) * var(--au-dim, 1) * 0.5); }
}
.au-a {
- --au-tile: 110vh;
+ --au-tile: ${AU_TILES.a.vh}vh;
--au-ks: 2.4;
--au-kp: 1.7;
--au-amp: 2.5vw;
@@ -1409,27 +1688,10 @@ const AURORA_STYLES = `
--au-sway-to: 3deg;
--au-blur: 1.2vh;
--au-lo: 1;
- background-image: linear-gradient(
- to right,
- transparent 0%,
- var(--au-1) 4% 9%,
- transparent 13%,
- var(--au-2) 16% 18.5%,
- transparent 24%,
- var(--au-1) 30% 37%,
- transparent 42%,
- var(--au-3) 47% 49%,
- transparent 54%,
- var(--au-1) 59% 66%,
- transparent 71%,
- var(--au-2) 76% 78%,
- transparent 83%,
- var(--au-1) 88% 91%,
- transparent 96%
- );
+ background-image: ${rays(AU_TILES.a.tile)};
}
.au-b {
- --au-tile: 150vh;
+ --au-tile: ${AU_TILES.b.vh}vh;
--au-ks: 3.6;
--au-kp: 2.5;
--au-amp: 4vw;
@@ -1438,23 +1700,10 @@ const AURORA_STYLES = `
--au-blur: 2vh;
--au-lo: 0.75;
--au-delay: -7s;
- background-image: linear-gradient(
- to right,
- transparent 0%,
- var(--au-3) 6% 13%,
- transparent 20%,
- var(--au-4) 27% 30%,
- transparent 36%,
- var(--au-3) 45% 54%,
- transparent 61%,
- var(--au-4) 68% 70%,
- transparent 76%,
- var(--au-3) 83% 87%,
- transparent 93%
- );
+ background-image: ${rays(AU_TILES.b.tile)};
}
.au-c {
- --au-tile: 70vh;
+ --au-tile: ${AU_TILES.c.vh}vh;
--au-ks: 4.8;
--au-kp: 3.1;
--au-amp: 5.5vw;
@@ -1463,28 +1712,15 @@ const AURORA_STYLES = `
--au-blur: 0.7vh;
--au-lo: 0.85;
--au-delay: -13s;
- background-image: linear-gradient(
- to right,
- transparent 0%,
- var(--au-1) 10% 11.5%,
- transparent 16%,
- var(--au-2) 27% 28%,
- transparent 33%,
- var(--au-4) 44% 46.5%,
- transparent 52%,
- var(--au-1) 63% 64%,
- transparent 70%,
- var(--au-2) 81% 83%,
- transparent 89%
- );
+ background-image: ${rays(AU_TILES.c.tile)};
}
-.au-glow {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 42%;
- height: 13%;
- background: linear-gradient(to top, var(--au-glow), transparent);
+/* By day the wide violet-pink veil layer (B) all but steps back: it filled every gap between the other
+ two layers' rays, so no clear sky showed anywhere and the lights read as a haze, not as rays. */
+[data-pattern="aurora"] .au-b {
+ --au-lo: 0.3;
+}
+.dark [data-pattern="aurora"] .au-b {
+ --au-lo: 0.75;
}
/* The water sheet. Like the cloud sea and the sand ground, its top edge is masked to nothing so the
sheet never draws the line the scene gradient's dissolve is there to avoid. */
@@ -1496,9 +1732,10 @@ const AURORA_STYLES = `
height: 46%;
background: linear-gradient(to bottom, var(--au-water-a) 0%, var(--au-water-b) 100%);
/* Held fully transparent until the waterline itself: any dark water reaching ABOVE 58% shades the
- sky curtains in a strip the reflection does not yet cover, which draws a thin dark rule. */
- -webkit-mask-image: linear-gradient(to bottom, transparent 0 8%, #000 15%);
- mask-image: linear-gradient(to bottom, transparent 0 8%, #000 15%);
+ sky curtains in a strip the reflection does not yet cover, which draws a thin dark rule. In vh, from
+ this sheet's 54vh top: as 8% of its 46vh height the edge sat at 57.7%, and drew exactly that rule. */
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0 4vh, #000 7vh);
+ mask-image: linear-gradient(to bottom, transparent 0 4vh, #000 7vh);
}
/* The reflection: an overflow-clipped water box holding a scaleY(-1) mirror of the three curtains.
Same classes → same animations, so the reflection dances in sync with the sky and its skews mirror
@@ -1519,9 +1756,12 @@ const AURORA_STYLES = `
}
.au-mirror .au-curtain {
top: -24%;
- bottom: 0;
+ /* Past the waterline by the same 3vh as the sky curtains (the water box clips it there), so the blur
+ cannot eat the reflection's lip at the line, and its skew pivot mirrors theirs through it. */
+ bottom: -3vh;
+ clip-path: none;
--au-dim: 1;
- --au-blur-m: 1.5;
+ --au-blur-m: var(--au-mirror-blur-m, 1.5);
/* Same lip fade + side feathers as the sky curtains, INTERSECTED with a GENTLE wavelet ripple —
The mirror runs at --au-dim 1 with a brighter vertical falloff to hold the reflection at full
strength while the SKY curtains were thinned to let stars and sky through — same colours feed both,
@@ -1529,11 +1769,42 @@ const AURORA_STYLES = `
dips of only 5-7% at an uneven two-stripe rhythm, so the reflection reads as water catching the
lights, not as scanline stripes. Deeper dips than this and the mirror turns into a CRT. Strength
lives in --au-dim (0.8 = a strong mirror), never in the ripple contrast. */
- -webkit-mask-image: linear-gradient(to top, #000 8%, rgb(0 0 0 / 0.82) 50%, transparent 98%), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%), repeating-linear-gradient(to bottom, #000 0 9px, rgb(0 0 0 / 0.93) 9px 13px, #000 13px 27px, rgb(0 0 0 / 0.95) 27px 31px);
- mask-image: linear-gradient(to top, #000 8%, rgb(0 0 0 / 0.82) 50%, transparent 98%), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%), repeating-linear-gradient(to bottom, #000 0 9px, rgb(0 0 0 / 0.93) 9px 13px, #000 13px 27px, rgb(0 0 0 / 0.95) 27px 31px);
+ -webkit-mask-image: var(--au-mirror-fade, linear-gradient(to top, #000 8%, rgb(0 0 0 / 0.82) 50%, transparent 98%)), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%), repeating-linear-gradient(to bottom, #000 0 9px, rgb(0 0 0 / 0.93) 9px 13px, #000 13px 27px, rgb(0 0 0 / 0.95) 27px 31px);
+ mask-image: var(--au-mirror-fade, linear-gradient(to top, #000 8%, rgb(0 0 0 / 0.82) 50%, transparent 98%)), linear-gradient(to right, transparent 3%, #000 14% 86%, transparent 97%), repeating-linear-gradient(to bottom, #000 0 9px, rgb(0 0 0 / 0.93) 9px 13px, #000 13px 27px, rgb(0 0 0 / 0.95) 27px 31px);
-webkit-mask-composite: source-in;
mask-composite: intersect;
}
+/* By day AuroraStreaks' streaks stand in for the gradient mirror: with crisp day rays, a plain mirror of them
+ read as identical bars ruled down the water. Night keeps the soft gradient mirror, where the blur
+ already hides the edges. */
+.au-curtain.au-streaks {
+ background-image: none;
+ overflow: hidden;
+}
+.au-streaks svg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ display: block;
+}
+[data-pattern="aurora"] .au-flat,
+.dark [data-pattern="aurora"] .au-streaks {
+ display: none;
+}
+.dark [data-pattern="aurora"] .au-flat {
+ display: block;
+}
+/* The ripples FADE IN below the waterline, from nothing at the line to full 6vh down: real ripple lines
+ compress to nothing toward the horizon, and starting at full strength they ruled it, a 1px highlight
+ exactly on the waterline and a dark stripe 14px under it. The fade is on this still wrapper, not on the
+ drifting ripples, where it would move with them and jump back a stripe at every loop. */
+.au-ripple-fade {
+ position: absolute;
+ inset: 0;
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 6vh);
+ mask-image: linear-gradient(to bottom, transparent 0, #000 6vh);
+}
/* Water-surface shimmer — faint light/dark ripple bands drifting down the water, looped by translating
exactly one stripe period (12px), so the restart is invisible. Compositor-only. */
.au-ripples {
@@ -1554,15 +1825,6 @@ const AURORA_STYLES = `
from { transform: translateY(0); }
to { transform: translateY(24px); }
}
-.au-reflect {
- position: absolute;
- left: 0;
- right: 0;
- top: 58%;
- height: 10%;
- background: linear-gradient(to bottom, var(--au-glow), transparent);
- opacity: 0.6;
-}
/* SUN PATH — the glitter column a low sun throws across water. Everything else in this scene reflects
(the curtains are literally mirrored), so a sun with no reflection was the one element that looked
pasted on. Not a mirrored disc: on real water the reflection smears into a vertical column that
@@ -1598,6 +1860,33 @@ const AURORA_STYLES = `
-webkit-mask-composite: source-in;
mask-composite: intersect;
}
+/* The sky behind the sun: the scene's own sky gradient, full-viewport so its stops line up exactly, masked
+ to the sun's disc. Painted after the curtains and before the sun, it hides the lights in the disc's
+ footprint, so the gaps between the sun's bands show sky rather than aurora through the sun. The
+ centre is where .sw-sun puts it: on the 58% horizon, dipped by a third of its size. */
+.au-sunsky {
+ position: absolute;
+ inset: 0;
+ opacity: var(--sw-sun-o, 0);
+ background: linear-gradient(
+ to bottom,
+ var(--au-sky-a) 0%,
+ var(--au-sky-b) 30%,
+ var(--au-sky-c) 46%,
+ var(--au-sky-d) 54%,
+ var(--au-sky-e, var(--au-sky-d)) 58%
+ );
+ -webkit-mask-image: radial-gradient(
+ circle calc(var(--sun-size, 38vh) / 2) at var(--sw-disc-x, 50%) calc(58vh - 0.17 * var(--sun-size, 38vh)),
+ #000 99%,
+ transparent 100%
+ );
+ mask-image: radial-gradient(
+ circle calc(var(--sun-size, 38vh) / 2) at var(--sw-disc-x, 50%) calc(58vh - 0.17 * var(--sun-size, 38vh)),
+ #000 99%,
+ transparent 100%
+ );
+}
/* HIGH CIRRUS — thin wisps catching the low sun, day only. The upper sky went empty once the curtains
were thinned; these fill it without competing, because they run HORIZONTAL against the curtains'
verticals. Same tapered-ellipse trick as the cloud decks, at a much lower alpha. */
@@ -1626,11 +1915,1172 @@ const AURORA_STYLES = `
right: 0;
top: 48%;
height: 24%;
- background: linear-gradient(to bottom, transparent 0%, var(--au-glow) 42%, transparent 100%);
- opacity: 0.75;
+ /* Flat-topped with soft shoulders: two straight ramps meeting at a point on the waterline drew a crease. */
+ background: linear-gradient(
+ to bottom,
+ transparent 0%,
+ color-mix(in oklab, var(--au-glow) 55%, transparent) 24%,
+ var(--au-glow) 36%,
+ var(--au-glow) 48%,
+ color-mix(in oklab, var(--au-glow) 55%, transparent) 62%,
+ transparent 100%
+ );
+ opacity: var(--au-haze-o, 0.75);
}
${stopRules(".au-curtain", ".au-ripples")}`;
+// Forest — a coastal redwood forest at sunrise (by night, under the moon), seen from INSIDE it, from a
+// hill. Dense is a matter of where you stand, not of how many trees there are: the near ridge holds a few
+// giants whose crowns climb out of the top of the frame on bare red trunks, every ridge back holds more,
+// smaller trees, and the three nearest ridges leave a CLEARING that is kept under the sun or moon, so you
+// look out at the disc through a gap in the giants, with the hill falling away into it. Same grammar as
+// dune: AERIAL PERSPECTIVE carries the depth (far ridges sit within a few L of the sky, and each ridge's
+// foot dissolves into the fog pooled below it, so there is no horizon LINE anywhere), and the shapes are
+// cut, not drawn: dune's corners and lit / slip faces, carried into the trees.
+// THE TREES are inline SVG built by a seeded generator, one per ridge (see ForestRidge for why
+// not masks). Every crown is a stack of tiers, each a triangle with a FLAT underside and upper edges that
+// SCOOP in, so the green sweeps out to its tips like a drooping bough; each tier overlaps the one below
+// by more than half its height, which leaves a DEEP CUT running almost to the trunk between tiers. The
+// outline itself stays straight-sided: columnar through the lower crown, then tapering to a spire or, on
+// a quarter of the trees (old growth), to a flat top. Trees vary in scoop, in which side reaches further
+// and in how strongly their tiers alternate long and short. Every tier splits into two FACETS, off
+// the trunk line (each tree TURNED a little toward or away from the viewer), each painted
+// between shadow and light by how squarely the disc faces it, so the lighting follows the left / center
+// / right placement the way dune's faces follow --du-light. The ground is angular, in straight runs,
+// with dune's sand detail in forest form: a lit rim along the near crests, and on the near floor a
+// LITTER of wood streaks and duff specks, in browns only; the vertical FURROWS down every trunk splay into
+// the buttress.
+// Ridge undulations run in WHOLE cycles per tile, and a tree crossing a tile edge simply overlaps the
+// next tile (all the tiles sit in one SVG), so each ridge repeats seamlessly; the generator is seeded,
+// so server and client draw the identical forest (SSR-safe, like the stars). By day a fan of
+// crepuscular RAYS breaks through the fog in the clearing. MOTION is the fog alone: three strips drift on compositor-only translateX
+// loops, one tile per loop (seamless), at different speeds for parallax.
+
+/** Seeded PRNG (mulberry32): deterministic, so the forest is the same on every render. */
+function mulberry32(seed: number) {
+ let s = seed;
+ return () => {
+ s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+type Ridge = {
+ seed: number;
+ /** Tile width in viewBox units; the tile is 100 units tall and stretched to its element's box. */
+ width: number;
+ /** Mean ridge line, 0 = top of the element, 100 = bottom. */
+ ridge: number;
+ /** Ridge undulations as [amplitude, cycles per tile]. Whole cycles keep the tile seamless. */
+ roll: [
+ number,
+ number,
+ ][];
+ /** A multiple of `layers` on a ridge without an opening: the layers take trees in turn, and any other
+ * count would put tree 0 and tree N−1, neighbours across the tile's wrap, in the same layer, where
+ * they merge. (An opening separates them, so there any count works.) */
+ trees: number;
+ /** How many layers the trees are dealt into (default 2). Trees in one layer must never overlap, so a
+ * dense ridge, where a tree's crown reaches the one after its neighbour, needs 3. */
+ layers?: number;
+ /** Tree height range in viewBox units (past 100 a crown climbs out of the top of the frame). */
+ height: [
+ number,
+ number,
+ ];
+ /** Crown half-width as a fraction of tree height. */
+ spread: number;
+ /** Stacked tiers per crown. The near trees carry the most: that is the definition a close view has. */
+ tiers: number;
+ /** Bare trunk below the crown, as a fraction of tree height (0 = crown down to the ground). */
+ trunk: number;
+ /** Vertical furrows striping each trunk. */
+ furrows: number;
+ /** A furrow's width in viewBox units: about one screen pixel at this ridge's scale. */
+ hair: number;
+ /** Each tier casts a shadow band on the tier below (off on the farthest ridge: haze erases it). */
+ cast: boolean;
+ /** A clearing in the trees, `half` units either side of the tile's middle, kept under the sun / moon:
+ * the ridge is shifted so it sits at the disc. `dip` lowers the ground there, so the hill falls
+ * away toward the view. */
+ opening?: {
+ half: number;
+ dip: number;
+ };
+ /** Litter on the forest floor (needles, specks, striation), as an opacity; 0 for none. */
+ duff: number;
+};
+
+type Pt = [
+ number,
+ number,
+];
+
+const f1 = (n: number) => String(Math.round(n * 10) / 10);
+
+/** One closed subpath, always wound clockwise on screen. Every shape in a mask has to wind the same way:
+ * under the default nonzero rule an overlap of opposite windings cancels and punches a hole. */
+function poly(pts: Pt[]): string {
+ let area = 0;
+ for (let i = 0; i < pts.length; i++) {
+ const [x1, y1] = pts[i];
+ const [x2, y2] = pts[(i + 1) % pts.length];
+ area += x1 * y2 - x2 * y1;
+ }
+ const ring =
+ area < 0
+ ? [
+ ...pts,
+ ].reverse()
+ : pts;
+ return `M${ring.map(([x, y]) => `${f1(x)} ${f1(y)}`).join("L")}Z`;
+}
+
+/** The control point of a quadratic curve from a to b: the chord's midpoint pushed out by `bow` × the
+ * chord length along (vy, -vx), which is the outward side of an edge of a clockwise (on-screen) shape.
+ * A negative bow scoops the edge in. */
+function bend(a: Pt, b: Pt, bow: number): Pt {
+ const vx = b[0] - a[0];
+ const vy = b[1] - a[1];
+ return [
+ (a[0] + b[0]) / 2 + vy * bow,
+ (a[1] + b[1]) / 2 - vx * bow,
+ ];
+}
+
+/** A curved outline: each segment runs to `to`, as a quadratic curve when it has a control point `via`.
+ * Built clockwise by construction (poly() cannot reorder a curve), so every subpath winds the same way. */
+type Seg = {
+ to: Pt;
+ via?: Pt;
+};
+const pathOf = (segs: Seg[]) => {
+ const at = ([x, y]: Pt) => `${f1(x)} ${f1(y)}`;
+ return `M${segs.map((g, i) => (i === 0 ? "" : g.via ? `Q${at(g.via)} ` : "L") + at(g.to)).join("")}Z`;
+};
+/** How far the right facet reaches under the left one, in viewBox units (about a pixel). */
+const SEAM = 0.15;
+
+/** The parts of one LAYER of a ridge, as path data. Each ridge deals its trees into layers in turn (two,
+ * or three on a dense ridge), painted back to front: each part is ONE path, the union of that part
+ * across the layer's trees, with no stacking order inside it, so two overlapping trees sharing a layer
+ * would merge, the back tree's lit facet or trunk showing through the front one and its shadow bands
+ * laying dark bars across it. Dealt in turn, trees in one layer sit two (or three) apart and never
+ * touch, across the tile's repeat too when the count is a multiple of the layers. Back to front within a
+ * layer: the trunks (two halves, rounded edges, furrows), then the crowns (two facets, the cast shadow
+ * of each tier). The halves and facets each paint their own mix of shadow and light, so no separate
+ * silhouette sits under them. */
+const LAYER_KEYS = [
+ "barkLeft",
+ "barkRight",
+ "barkEdge",
+ "barkGrooves",
+ "left",
+ "right",
+ "cast",
+] as const;
+type LayerKey = (typeof LAYER_KEYS)[number];
+type LayerPaths = Record;
+type RidgePaths = {
+ ground: string;
+ /** The crest as an open line, for the lit rim. */
+ rim: string;
+ /** The floor's litter, light and dark, each drawn in its own pattern cell [width, height]. */
+ litter: string;
+ darkLitter: string;
+ lightCell: [
+ number,
+ number,
+ ];
+ darkCell: [
+ number,
+ number,
+ ];
+ layers: LayerPaths[];
+};
+
+function forestRidge({
+ seed,
+ width: w,
+ ridge,
+ roll,
+ trees,
+ layers: layerCount = 2,
+ height,
+ spread,
+ tiers,
+ trunk,
+ furrows,
+ hair,
+ cast,
+ opening,
+ duff,
+}: Ridge): RidgePaths {
+ const rand = mulberry32(seed);
+ const phases = roll.map(() => rand() * Math.PI * 2);
+ // A ridge with an opening also DIPS there (one whole cycle, lowest at the tile's middle), so the hill
+ // falls away toward the clearing and rises at the sides.
+ const ridgeY = (x: number) =>
+ roll.reduce((y, [amp, cycles], i) => y + amp * Math.sin((2 * Math.PI * cycles * x) / w + phases[i]), ridge) -
+ (opening ? opening.dip * Math.cos((2 * Math.PI * x) / w) : 0);
+ // The ground is ANGULAR, a dozen straight runs per tile: a smooth sine read as a park lawn. Jitter
+ // wraps (point N reuses point 0's), so the tile still repeats seamlessly; it reaches a unit past each
+ // tile edge, so neighbouring tiles overlap rather than meeting on an anti-aliased seam.
+ const runs = 12;
+ const jitter = Array.from(
+ {
+ length: runs,
+ },
+ () => (rand() - 0.5) * 3,
+ );
+ const crest: Pt[] = Array.from(
+ {
+ length: runs + 1,
+ },
+ (_, i): Pt => [
+ (i * w) / runs,
+ ridgeY((i * w) / runs) + jitter[i % runs],
+ ],
+ );
+ const ground = poly([
+ [
+ -1,
+ 100,
+ ],
+ [
+ -1,
+ crest[0][1],
+ ],
+ ...crest,
+ [
+ w + 1,
+ crest[runs][1],
+ ],
+ [
+ w + 1,
+ 100,
+ ],
+ ]);
+ // The crest as an open line, for the lit rim along the top of the ground.
+ const rim = `M-1 ${f1(crest[0][1])}${crest.map(([x, y]) => `L${f1(x)} ${f1(y)}`).join("")}L${f1(w + 1)} ${f1(crest[runs][1])}`;
+ // Trees stand on the crest as drawn (straight runs), not on the smooth curve under it.
+ const groundY = (x: number) => {
+ const xx = ((x % w) + w) % w;
+ const i = Math.min(runs - 1, Math.floor((xx / w) * runs));
+ const f = (xx - crest[i][0]) / (w / runs);
+ return crest[i][1] + (crest[i + 1][1] - crest[i][1]) * f;
+ };
+ const blank = () =>
+ Object.fromEntries(
+ LAYER_KEYS.map((k) => [
+ k,
+ "",
+ ]),
+ ) as LayerPaths;
+ const layers = Array.from(
+ {
+ length: layerCount,
+ },
+ blank,
+ );
+ // Trees fill the tile except the opening: they are laid out along the span from the opening's right
+ // edge round to its left edge (wrapping across the tile's repeat), so the clearing stays clear.
+ const gap = opening ? opening.half * 2 : 0;
+ const span = w - gap;
+ for (let i = 0; i < trees; i++) {
+ const out = layers[i % layerCount];
+ const cx = (w / 2 + gap / 2 + ((i + 0.2 + rand() * 0.6) * span) / trees) % w;
+ const h = height[0] + (height[1] - height[0]) * rand();
+ const hw = h * spread * (0.85 + rand() * 0.3);
+ const foot = groundY(cx) + 1.5;
+ const crownBase = foot - h * trunk;
+ const step = (h * (1 - trunk)) / tiers;
+ const lean = (rand() - 0.5) * hw * 0.3;
+ // TURNED: the line between the lit and shadow sides runs from the tip down to a point off the trunk
+ // axis, up to a third of the width either side. A split straight down the middle read as a flat,
+ // symmetric cut-out; off centre, each tree reads as a cone turned toward or away from the viewer.
+ const turn = (rand() - 0.5) * 0.66;
+ if (trunk > 0) {
+ const tw = hw * 0.22;
+ const neck = foot - h * 0.05;
+ const top = crownBase - step * 1.5; // reaches up inside the lowest tiers, so no gap shows under them
+ // A ROUNDED trunk: straight and faintly tapered up the bole, then a CURVED flare into the buttress
+ // (half-width as a multiple of tw, top to foot).
+ const profile: [
+ number,
+ number,
+ ][] = [
+ [
+ top,
+ 0.7,
+ ],
+ [
+ neck,
+ 1,
+ ],
+ [
+ foot - h * 0.03,
+ 1.1,
+ ],
+ [
+ foot - h * 0.012,
+ 1.35,
+ ],
+ [
+ foot,
+ 1.8,
+ ],
+ ];
+ const halfAt = (y: number) => {
+ for (let p = 1; p < profile.length; p++) {
+ const [y0, f0] = profile[p - 1];
+ const [y1, f1v] = profile[p];
+ if (y <= y1) return tw * (f0 + ((f1v - f0) * (y - y0)) / (y1 - y0));
+ }
+ return tw * 1.8;
+ };
+ const side = (dir: number, k = 1) =>
+ profile.map(
+ ([y, f]): Pt => [
+ cx + dir * tw * f * k,
+ y,
+ ],
+ );
+ // The halves split off centre too, as the crown's facets do.
+ const axis = (y: number) => cx + turn * halfAt(y);
+ const lEdge = side(-1);
+ const rEdge = side(1);
+ out.barkLeft += poly([
+ ...lEdge,
+ ...[
+ ...profile,
+ ]
+ .reverse()
+ .map(
+ ([y]): Pt => [
+ axis(y),
+ y,
+ ],
+ ),
+ ]);
+ // The two halves make the whole trunk; the right reaches a hair under the left, as the facets do.
+ out.barkRight += poly([
+ ...profile.map(
+ ([y]): Pt => [
+ axis(y) - SEAM,
+ y,
+ ],
+ ),
+ ...[
+ ...rEdge,
+ ].reverse(),
+ ]);
+ // The edges turn away from both the light and the viewer: a darker strip down each side, so with
+ // the lit and shadow halves the trunk reads in four bands, a faceted cylinder rather than a board.
+ out.barkEdge += poly([
+ ...lEdge,
+ ...side(-1, 0.72).reverse(),
+ ]);
+ out.barkEdge += poly([
+ ...side(1, 0.72),
+ ...[
+ ...rEdge,
+ ].reverse(),
+ ]);
+ // FURROWS: redwood bark is deep vertical fibre, so each trunk is striped top to foot with hairline
+ // grooves that follow its taper and splay out into the buttress, the way the bark runs into the roots.
+ for (let j = 0; j < furrows; j++) {
+ const fx = (((j + 0.5) / furrows) * 2 - 1) * 0.8 + (rand() - 0.5) * 0.1;
+ const y0 = crownBase + rand() * h * 0.04;
+ const y1 = neck + (foot - neck) * (0.2 + rand() * 0.6);
+ const edge = (y: number, dx: number): Pt => [
+ cx + fx * halfAt(y) + dx,
+ y,
+ ];
+ out.barkGrooves += poly([
+ edge(y0, 0),
+ edge(neck, 0),
+ edge(y1, 0),
+ edge(y1, hair * 0.6),
+ edge(neck, hair),
+ edge(y0, hair),
+ ]);
+ }
+ }
+ // Most crowns end in a SPIRE; about a quarter are old growth with a FLAT top, the leader long gone,
+ // which is how the oldest redwoods actually stand. Both outlines are STRAIGHT-sided above a columnar
+ // lower crown: a convex, domed outline read as puffy, so the curve lives in the branches instead.
+ const flatTop = rand() < 0.25;
+ const shoulder = flatTop ? 0.55 : 0.42;
+ const profile = (t: number) => (t <= shoulder ? 1 : 1 - (flatTop ? 0.55 : 1) * ((t - shoulder) / (1 - shoulder)));
+ // Each tree its own: how deep its branches scoop, which side reaches further, and how strongly its
+ // tiers alternate long and short (a real crown is uneven where a template repeats).
+ const scoop = -(0.07 + rand() * 0.09);
+ const reachL = 0.9 + rand() * 0.2;
+ const reachR = 1.9 - reachL;
+ // The previous (lower) tier's half-width, which bounds this tier's cast shadow.
+ let below = hw;
+ const alternate = 0.06 + rand() * 0.1;
+ for (let k = 0; k < tiers; k++) {
+ const t = k / tiers;
+ // Each tier rises 2.3 steps, so it overlaps the next tier's base by more than half its own height:
+ // that overlap is the DEEP CUT running almost to the trunk between tiers. A flat top's upper tiers
+ // squat, so the crown ends blunt instead of in a spire.
+ const th = step * 2.3 * (flatTop && t > shoulder ? 0.7 : 1);
+ const by = crownBase - k * step;
+ const ww = hw * Math.max(0.12, profile(t)) * (1 + (k % 2 ? -alternate : alternate)) * (0.9 + rand() * 0.2);
+ const xc = cx + lean * t;
+ const apex: Pt = [
+ xc,
+ by - th,
+ ];
+ const droop = th * (0.06 + rand() * 0.06);
+ const lt: Pt = [
+ xc - ww * reachL,
+ by + droop,
+ ];
+ const rt: Pt = [
+ xc + ww * reachR,
+ by + droop,
+ ];
+ // A FLAT underside, straight across from tip to tip (an underside rising to a point at the trunk
+ // stacked every tier into a column of chevrons, a bow in the middle of every tree), split where
+ // the tree is TURNED.
+ const split: Pt = [
+ xc + turn * ww,
+ by + droop,
+ ];
+ // CURVED GREEN: the upper edges SCOOP in (a negative bow), so each tier sweeps out to its tips like
+ // a drooping bough instead of bulging (convex) or cutting straight (a wedge).
+ const upR = bend(apex, rt, scoop);
+ const upL = bend(lt, apex, scoop);
+ // The two FACETS make the whole tier between them. The right one reaches a hair past the split,
+ // under the left, so the two never leave an anti-aliased seam.
+ out.left += pathOf([
+ {
+ to: apex,
+ },
+ {
+ to: split,
+ },
+ {
+ to: lt,
+ },
+ {
+ to: apex,
+ via: upL,
+ },
+ ]);
+ out.right += pathOf([
+ {
+ to: [
+ apex[0] - SEAM,
+ apex[1],
+ ],
+ },
+ {
+ to: apex,
+ },
+ {
+ to: rt,
+ via: upR,
+ },
+ {
+ to: [
+ split[0] - SEAM,
+ split[1],
+ ],
+ },
+ ]);
+ // CAST SHADOW, the third tone: each tier shades a band of the tier below, just under its own
+ // underside, the way dune's crests cast onto the ridge behind. Clipped to the layer's crowns, so
+ // under the deep-cut tips it never hangs over sky, and kept to the width the tier below has at that
+ // height (about 0.6 of its reach): run out to this tier's tips, a band in a dense ridge reached a
+ // neighbour in the same layer and laid a dark bar across its crown. Not on the narrow spire tiers:
+ // clipped to a crown that slim, the band was a dark tab on the axis.
+ // Only where this tier is about as wide as the one it shades: a narrow spire tier resting on a broad
+ // tier cast a short band with square ends floating mid-crown, which read as a dark bar. The ends
+ // taper inward, the way a shadow under a cone does, rather than stopping square.
+ if (cast && k > 0 && profile(t) > 0.4 && ww > below * 0.75) {
+ const d = th * 0.16;
+ const x0 = Math.max(lt[0], xc - below * reachL * 0.62);
+ const x1 = Math.min(rt[0], xc + below * reachR * 0.62);
+ const taper = (x1 - x0) * 0.2;
+ out.cast += poly([
+ [
+ x0,
+ lt[1],
+ ],
+ [
+ x1,
+ rt[1],
+ ],
+ [
+ x1 - taper,
+ rt[1] + d,
+ ],
+ [
+ x0 + taper,
+ lt[1] + d,
+ ],
+ ]);
+ }
+ below = ww;
+ }
+ }
+ // LITTER on the forest floor, dune's grains and striation in redwood form: wood streaks running with
+ // the slope and specks of duff, in browns only (light and deep, never the scene's teal ink). Two
+ // patterns, as dune runs two striation passes at different periods: the light litter in cells a
+ // twelfth of the tile wide and 23 units tall, the dark in cells a seventh wide and 17 tall. Both
+ // widths divide the tile, so each repeats a whole number of times per tile; because the two grids
+ // never line up, neither reads as rows. (One small cell made its strokes line up across the screen.)
+ const lightCell: [
+ number,
+ number,
+ ] = [
+ w / 12,
+ 23,
+ ];
+ const darkCell: [
+ number,
+ number,
+ ] = [
+ w / 7,
+ 17,
+ ];
+ let litter = "";
+ let darkLitter = "";
+ const dash = (
+ [cw, ch]: [
+ number,
+ number,
+ ],
+ len: number,
+ slant: number,
+ thick: number,
+ ) => {
+ const x = rand() * (cw - len);
+ const y = 0.5 + rand() * (ch - 1);
+ const a = (rand() - 0.5) * slant;
+ return poly([
+ [
+ x,
+ y,
+ ],
+ [
+ x + Math.cos(a) * len,
+ y + Math.sin(a) * len,
+ ],
+ [
+ x + Math.cos(a) * len,
+ y + Math.sin(a) * len + thick * 0.5,
+ ],
+ [
+ x,
+ y + thick,
+ ],
+ ]);
+ };
+ if (duff > 0) {
+ // Duff specks. (No needles: short dashes at every slant read as green pine needles scattered on the
+ // floor; the floor is wood and duff, so its marks are the streaks below.)
+ for (let n = 0; n < 26; n++) {
+ const x = 0.3 + rand() * (darkCell[0] - 0.6);
+ const y = 0.3 + rand() * (darkCell[1] - 0.6);
+ const r = 0.12 + rand() * 0.14;
+ darkLitter += poly([
+ [
+ x - r,
+ y,
+ ],
+ [
+ x,
+ y - r,
+ ],
+ [
+ x + r,
+ y,
+ ],
+ [
+ x,
+ y + r,
+ ],
+ ]);
+ }
+ // WOOD STREAKS, dune's striation in forest form: strokes at a slight slant, from slivers to longer
+ // grain, never the width of a cell (a line that ran the full cell repeated edge to edge, ruled like
+ // notebook paper: dune's own warning about contour lines).
+ for (let n = 0; n < 22; n++) litter += dash(lightCell, lightCell[0] * (0.06 + rand() * 0.3), 0.1, 0.11 + rand() * 0.06);
+ for (let n = 0; n < 16; n++) darkLitter += dash(darkCell, darkCell[0] * (0.05 + rand() * 0.22), 0.1, 0.11 + rand() * 0.06);
+ }
+ return {
+ ground,
+ rim,
+ litter,
+ darkLitter,
+ lightCell,
+ darkCell,
+ layers,
+ };
+}
+
+/* Five ridges, far → near, and the perspective is what makes the forest DENSE: not more trees but
+ closer ones. The near ridge holds a few giants whose crowns climb out of the top of the frame, and
+ each ridge back has more, smaller trees with fewer tiers. `vh` is the ridge's share of the viewport,
+ so a tile is vh × width / 100 vh wide and the trees keep their proportions at any viewport. */
+const FOREST_RIDGES = [
+ {
+ cls: "fo-r0",
+ vh: 50,
+ spec: {
+ seed: 11,
+ width: 240,
+ ridge: 22,
+ roll: [
+ [
+ 1.6,
+ 2,
+ ],
+ [
+ 0.8,
+ 5,
+ ],
+ ],
+ trees: 34,
+ height: [
+ 16,
+ 30,
+ ],
+ spread: 0.22,
+ tiers: 4,
+ trunk: 0,
+ furrows: 0,
+ hair: 0.2,
+ cast: false,
+ duff: 0,
+ },
+ },
+ {
+ cls: "fo-r1",
+ vh: 54,
+ spec: {
+ seed: 23,
+ width: 230,
+ ridge: 34,
+ roll: [
+ [
+ 2,
+ 2,
+ ],
+ [
+ 1,
+ 4,
+ ],
+ ],
+ trees: 24,
+ layers: 3,
+ height: [
+ 24,
+ 42,
+ ],
+ spread: 0.22,
+ tiers: 6,
+ trunk: 0,
+ furrows: 0,
+ hair: 0.2,
+ cast: true,
+ duff: 0,
+ },
+ },
+ {
+ cls: "fo-r2",
+ vh: 62,
+ spec: {
+ seed: 37,
+ width: 210,
+ ridge: 50,
+ roll: [
+ [
+ 2.4,
+ 1,
+ ],
+ [
+ 1.2,
+ 3,
+ ],
+ ],
+ trees: 16,
+ layers: 3,
+ height: [
+ 38,
+ 60,
+ ],
+ spread: 0.2,
+ tiers: 8,
+ trunk: 0.12,
+ furrows: 3,
+ hair: 0.18,
+ cast: true,
+ opening: {
+ half: 10,
+ dip: 1.5,
+ },
+ duff: 0,
+ },
+ },
+ {
+ cls: "fo-r3",
+ vh: 80,
+ spec: {
+ seed: 41,
+ width: 230,
+ ridge: 72,
+ roll: [
+ [
+ 1.6,
+ 2,
+ ],
+ ],
+ trees: 10,
+ layers: 3,
+ height: [
+ 80,
+ 104,
+ ],
+ spread: 0.16,
+ tiers: 12,
+ trunk: 0.26,
+ furrows: 5,
+ hair: 0.15,
+ cast: true,
+ opening: {
+ half: 18,
+ dip: 3,
+ },
+ duff: 0.5,
+ },
+ },
+ {
+ cls: "fo-r4",
+ vh: 100,
+ spec: {
+ seed: 59,
+ width: 260,
+ ridge: 88,
+ roll: [
+ [
+ 1.2,
+ 2,
+ ],
+ ],
+ trees: 4,
+ height: [
+ 125,
+ 150,
+ ],
+ spread: 0.16,
+ tiers: 14,
+ trunk: 0.34,
+ furrows: 9,
+ hair: 0.13,
+ cast: true,
+ opening: {
+ half: 46,
+ dip: 5,
+ },
+ duff: 0.9,
+ },
+ },
+] satisfies {
+ cls: string;
+ vh: number;
+ spec: Ridge;
+}[];
+
+/** What each part is painted with, as a CSS colour (it reaches the SVG through stop-color, so it reads
+ * the ridge's palette variables and follows the light/dark flip). Facets and trunk halves mix shadow
+ * toward light by how squarely the disc faces them (--fo-lit-l / --fo-lit-r, set inline from the sun /
+ * moon placement), the way dune's faces follow --du-light. */
+const LIT = (side: "l" | "r", shade: string, lit: string) =>
+ `color-mix(in oklch, var(${shade}), var(${lit}) calc(var(--fo-lit-${side}, ${side === "l" ? 0 : 1}) * 100%))`;
+const PART_PAINT: Record = {
+ ground: "var(--fo-earth)",
+ barkLeft: LIT("l", "--fo-bk", "--fo-bkl"),
+ barkRight: LIT("r", "--fo-bk", "--fo-bkl"),
+ barkEdge: "var(--fo-bark-shade)",
+ barkGrooves: "color-mix(in oklch, var(--fo-bk), var(--fo-furrow) 60%)",
+ left: LIT("l", "--fo-c", "--fo-cl"),
+ right: LIT("r", "--fo-c", "--fo-cl"),
+ cast: "var(--fo-shade)",
+};
+
+const FOREST_PATHS = FOREST_RIDGES.map(({ spec }) => forestRidge(spec));
+
+/** Tiles per ridge: enough to cover a viewport up to 5× as wide as it is tall, plus one. */
+const tilesFor = (vh: number, width: number) => Math.ceil(500 / ((vh * width) / 100)) + 1;
+
+/** One ridge as an inline SVG. The tile is drawn once in and placed side by side with ;
+ * each part is filled with a vertical gradient that holds its paint down the forested slope below the
+ * ridge line and then dissolves into the valley fog by --fo-pool. Inline SVG rather than CSS masks
+ * (the other scenes' tool) because a theme flip only refills these paths: as 63 full-viewport masked
+ * layers, every one re-composited its mask on each flip and the switch took ~1.2s against dune's
+ * ~0.2s. The cast shadow is clipped to its layer's two facets, the crowns, so under the deep-cut tips
+ * it never hangs over sky. A ridge with an OPENING is shifted so its clearing (the middle of its third
+ * tile) sits under the sun / moon, wherever the placement control puts it: you look out at the disc
+ * through a gap in the giants. */
+function ForestRidge({ index }: { index: number }) {
+ const { cls, vh, spec } = FOREST_RIDGES[index];
+ const { ground, rim, litter, darkLitter, lightCell, darkCell, layers } = FOREST_PATHS[index];
+ const before = spec.opening ? 2 : 0;
+ const tiles = tilesFor(vh, spec.width) + before;
+ const unit = vh / 100; // one viewBox unit, in vh
+ /* Per INSTANCE, not just per ridge: url(#…) resolves to the first matching id in the document, so a
+ second forest on the page (the /backdrop route over the site's own background) would otherwise paint
+ with the first one's gradients, lit for the first one's sun placement. */
+ const scope = useId().replace(/[^\w-]/g, "");
+ const id = (part: string) => `${scope}fo-${index}-${part}`;
+ const fill = (part: string) => `url(#${id(part)})`;
+ return (
+
+
+ {(Object.keys(PART_PAINT) as (keyof typeof PART_PAINT)[]).map((part) => (
+
+
+
+
+ ))}
+ {spec.duff > 0 && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {layers.map((layer, l) => (
+
+
+
+
+ ))}
+
+
+ {spec.duff > 0 && }
+ {spec.duff > 0 && }
+ {/* The lit rim along the crest, dune's crest-rim twin as a stroke: only on the floors with litter
+ (the near hill and the ridge behind it), and kept close to the brown. On every ridge, at
+ full strength, the rims drew pale contour lines across the whole floor. */}
+ {spec.duff > 0 && (
+
+ )}
+ {layers.flatMap((layer, l) =>
+ LAYER_KEYS.filter((key) => layer[key]).map((key) => (
+
+ )),
+ )}
+
+
+ {Array.from(
+ {
+ length: tiles,
+ },
+ (_, k) => (
+
+ ),
+ )}
+
+ );
+}
+
+const FOREST_STYLES = `
+[data-pattern="forest"] {
+ /* DAY — SUNRISE in the fog. The sky runs from a dusty blue zenith through lavender and rose into a
+ warm gold band at the treeline, chroma kept low the way a hazy dawn is; the top three stops take
+ the theme cast like the other day skies. */
+ --sw-cast: min(calc(var(--glass-tint-c, 0.018) * 250%), 16%);
+ --fo-sky-a: color-mix(in oklch, oklch(0.66 0.06 252), oklch(0.66 0.055 var(--glass-tint-h, 252)) var(--sw-cast));
+ --fo-sky-b: color-mix(in oklch, oklch(0.76 0.05 298), oklch(0.76 0.045 var(--glass-tint-h, 298)) var(--sw-cast));
+ --fo-sky-c: color-mix(in oklch, oklch(0.83 0.05 345), oklch(0.83 0.045 var(--glass-tint-h, 345)) var(--sw-cast));
+ --fo-sky-d: oklch(0.87 0.06 45); /* peach — authored from here down, or the cast muddies the dawn */
+ --fo-sky-e: oklch(0.9 0.07 72); /* the gold band the sun rises out of */
+ /* Ridges, far → near, each a SHADOW / LIT pair (the two facets of every tier): lavender haze to a
+ fog-softened blue-green in shadow, warming toward a sunlit yellow-green on the lit facets as the
+ ridges approach, the way low sun only reads on what is close. Light-mode type is set for a LIGHT page
+ and the hero copy sits right on these ridges, which in this close view means on the giants' crowns:
+ so even the nearest shadow facet stays at L 0.58, a step above dune's middle tiers. The marine layer is the excuse, and it is the true one: coastal
+ redwoods at dawn photograph pale. */
+ --fo-r0: oklch(0.8 0.03 320);
+ --fo-r0-lit: oklch(0.85 0.035 20);
+ --fo-r1: oklch(0.74 0.036 305);
+ --fo-r1-lit: oklch(0.8 0.035 70);
+ --fo-r2: oklch(0.67 0.04 278);
+ --fo-r2-lit: oklch(0.76 0.045 100);
+ --fo-r3: oklch(0.64 0.04 230);
+ --fo-r3-lit: oklch(0.75 0.055 115);
+ --fo-r4: oklch(0.58 0.045 195);
+ --fo-r4-lit: oklch(0.72 0.07 125);
+ --fo-shade: oklch(0.3 0.06 200 / 0.32); /* the cast-shadow glaze: lit green → mid, shadow green → deep */
+ --fo-duff-hi: oklch(0.93 0.05 72); /* what lit litter and the crest rim lean toward: low sun on duff */
+ --fo-bark-shade: oklch(0.24 0.06 30 / 0.45);
+ --fo-furrow: oklch(0.3 0.08 30); /* what a trunk furrow cuts toward: deep red-brown, not the teal ink */
+ /* Forest floor, far → near: BROWN at every distance, a stylistic choice over realism. The trees fade
+ into the haze; the floor stays duff, lighter and warmer far off, deeper up close, and shades to
+ --fo-earth-deep below each crest instead of dissolving into the fog. */
+ --fo-r0-earth: oklch(0.64 0.055 58);
+ --fo-r1-earth: oklch(0.6 0.06 55);
+ --fo-r2-earth: oklch(0.56 0.065 52);
+ --fo-r3-earth: oklch(0.52 0.07 50);
+ --fo-r4-earth: oklch(0.47 0.07 48);
+ --fo-earth-deep: oklch(0.36 0.06 42);
+ /* Redwood bark: the red-brown that names the tree, fogged back on the farther ridges. */
+ --fo-r2-bark: oklch(0.62 0.04 38);
+ --fo-r2-bark-lit: oklch(0.71 0.05 48);
+ --fo-r3-bark: oklch(0.6 0.05 35);
+ --fo-r3-bark-lit: oklch(0.7 0.065 48);
+ --fo-r4-bark: oklch(0.56 0.065 33); /* L 0.56, not darker: the giants' trunks stand behind the hero copy */
+ --fo-r4-bark-lit: oklch(0.68 0.085 45);
+ --fo-fog: oklch(0.91 0.035 55); /* the marine layer, lit warm by the low sun */
+ --fo-fog-a: oklch(0.93 0.03 60 / 0.5);
+ --fo-glow: oklch(0.92 0.09 75 / 0.6); /* the haze bloom around the sun */
+ --fo-ray: oklch(0.97 0.06 82 / 0.16);
+ --fo-rays-o: 1;
+ /* daytime SUN — the shared outrun disc on the horizon, behind every ridge. Sun ramp as dune's: the
+ accent at the bottom of the disc, mixing to a warm yellow crown. */
+ --sw-sun-o: 1;
+ --sw-moon-o: 0;
+ --sun-size: 40vh;
+ --fo-disc-y: 51.2vh; /* the disc's centre: 58vh horizon - 0.17 × 40vh (the sun dips 33% below it) */
+ --sun-h: var(--accent-h, var(--glass-tint-h, 35));
+ --sw-disc-1: oklch(0.6 0.21 var(--sun-h));
+ --sw-disc-2: color-mix(in oklch, oklch(0.68 0.2 var(--sun-h)), oklch(0.74 0.18 48) 30%);
+ --sw-disc-3: color-mix(in oklch, oklch(0.76 0.18 var(--sun-h)), oklch(0.82 0.17 66) 58%);
+ --sw-disc-4: color-mix(in oklch, oklch(0.85 0.15 var(--sun-h)), oklch(0.89 0.15 80) 80%);
+ --sw-disc-5: oklch(0.94 0.13 94);
+ --sw-disc-glow: oklch(0.82 0.16 var(--sun-h) / 0.45);
+ --sw-stars-o: 0;
+}
+.dark [data-pattern="forest"] {
+ /* NIGHT — the same ridges under a cold moon: navy sky, silver fog, and the ladder compressed toward
+ black so the far ridges still sit just under the sky's value. */
+ --fo-sky-a: oklch(0.09 0.035 262);
+ --fo-sky-b: oklch(0.12 0.045 266);
+ --fo-sky-c: oklch(0.16 0.05 272);
+ --fo-sky-d: oklch(0.21 0.05 276);
+ --fo-sky-e: oklch(0.25 0.045 270);
+ --fo-r0: oklch(0.24 0.042 272);
+ --fo-r0-lit: oklch(0.28 0.042 266);
+ --fo-r1: oklch(0.2 0.04 268);
+ --fo-r1-lit: oklch(0.255 0.04 262);
+ --fo-r2: oklch(0.16 0.036 262);
+ --fo-r2-lit: oklch(0.235 0.04 258);
+ --fo-r3: oklch(0.125 0.03 250);
+ --fo-r3-lit: oklch(0.21 0.04 250);
+ --fo-r4: oklch(0.095 0.024 230);
+ --fo-r4-lit: oklch(0.185 0.035 245); /* moonlit facets: cool, lifted just enough to keep the cut */
+ --fo-shade: oklch(0.02 0.01 250 / 0.45);
+ --fo-duff-hi: oklch(0.34 0.035 55); /* moonlight on the floor: dimmer, and still brown */
+ --fo-bark-shade: oklch(0.02 0.01 30 / 0.5);
+ --fo-furrow: oklch(0.06 0.02 30);
+ /* Brown at night too (it was blue-violet far off): moonlit duff, darker near. */
+ --fo-r0-earth: oklch(0.21 0.028 50);
+ --fo-r1-earth: oklch(0.19 0.027 48);
+ --fo-r2-earth: oklch(0.17 0.026 46);
+ --fo-r3-earth: oklch(0.14 0.024 45);
+ --fo-r4-earth: oklch(0.12 0.022 44);
+ --fo-earth-deep: oklch(0.08 0.016 40);
+ --fo-r2-bark: oklch(0.16 0.02 262);
+ --fo-r2-bark-lit: oklch(0.21 0.025 258);
+ --fo-r3-bark: oklch(0.14 0.02 260);
+ --fo-r3-bark-lit: oklch(0.19 0.025 255);
+ --fo-r4-bark: oklch(0.12 0.02 30);
+ --fo-r4-bark-lit: oklch(0.18 0.025 250);
+ --fo-fog: oklch(0.32 0.04 268);
+ --fo-fog-a: oklch(0.5 0.035 268 / 0.3);
+ --fo-glow: oklch(0.6 0.05 262 / 0.35);
+ /* No rays at night: a moon is too dim to throw shafts through fog, and a fan at its rim read as a
+ starburst. */
+ --fo-rays-o: 0;
+ --sw-sun-o: 0;
+ --sw-moon-o: 1;
+ /* Low enough that the tallest spires cross its lower edge: the moon rises BEHIND the forest. */
+ --moon-size: 30vh;
+ --moon-bottom: 47%;
+ --moon-dip: 0%;
+ --fo-disc-y: 38vh; /* the moon's centre: bottom edge at 53vh, minus half of 30vh */
+ --sw-moon-1: oklch(0.95 0.015 255);
+ --sw-moon-2: oklch(0.85 0.035 262);
+ --sw-moon-3: oklch(0.72 0.06 270);
+ --sw-maria: 0.64 0.05 268;
+ --sw-disc-glow: oklch(0.82 0.05 262 / 0.45);
+ --sw-stars-o: 1;
+}
+.fo-scene {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ background: linear-gradient(
+ to bottom,
+ var(--fo-sky-a) 0%,
+ var(--fo-sky-b) 26%,
+ var(--fo-sky-c) 40%,
+ var(--fo-sky-d) 50%,
+ var(--fo-sky-e) 56%,
+ var(--fo-fog) 64%
+ );
+}
+/* A ridge: an inline SVG (see ForestRidge), anchored to the bottom left and as wide as its tiles. The
+ scene clips whatever runs past the right edge. Its parts take their colours from the vars below. The
+ facets and trunk halves mix shadow toward light by the placement, the cast shadow is a translucent
+ glaze (one band reads as a mid green on a lit facet and a deep green on a shadow facet), and the trunk
+ furrows are the bark tone pushed toward a deep red-brown. The forest floor is its own earthy duff brown. The
+ fog pool fades each part to the valley fog below its ridge line (the nearest ridge has none: it runs
+ down to the bottom of the screen, and fogging it out there read as snow). */
+.fo-ridge {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ display: block;
+ overflow: visible;
+}
+${FOREST_RIDGES.map(
+ ({ cls }) => `.${cls} {
+ --fo-c: var(--${cls});
+ --fo-cl: var(--${cls}-lit);
+ --fo-bk: var(--${cls}-bark);
+ --fo-bkl: var(--${cls}-bark-lit);
+ --fo-earth: var(--${cls}-earth);
+}`,
+).join("\n")}
+.fo-r3 {
+ --fo-pool: 50%; /* less fog under the near-middle ridge: at 70% its floor read as snow between the giants */
+}
+.fo-r4 {
+ --fo-pool: 0%;
+}
+/* The bloom around the disc, over the far ridges: aerial perspective is brightest toward the light. */
+.fo-glow {
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(70vh 26vh at var(--sw-disc-x, 50%) calc(var(--fo-disc-y) + 6vh), var(--fo-glow) 0%, transparent 100%);
+}
+/* Crepuscular RAYS through the fog: two repeating conic fans at co-prime periods, so the beams fall at
+ irregular spacing rather than as a ruled sunburst, faded with distance from the disc and shown only
+ BELOW it. A shaft needs something to shade it and haze to show in, which is the fogbound forest; a
+ fan across open sky is a poster's sunburst. */
+.fo-rays {
+ position: absolute;
+ inset: 0;
+ opacity: var(--fo-rays-o, 1);
+ background:
+ repeating-conic-gradient(from 3deg at var(--sw-disc-x, 50%) var(--fo-disc-y), var(--fo-ray) 0deg, transparent 1.6deg 5.3deg),
+ repeating-conic-gradient(from 1deg at var(--sw-disc-x, 50%) var(--fo-disc-y), var(--fo-ray) 0deg, transparent 2.4deg 8.9deg);
+ -webkit-mask-image:
+ radial-gradient(64vh 52vh at var(--sw-disc-x, 50%) var(--fo-disc-y), transparent 0%, #000 22%, transparent 100%),
+ linear-gradient(to bottom, transparent calc(var(--fo-disc-y) + 2vh), #000 calc(var(--fo-disc-y) + 14vh));
+ -webkit-mask-composite: source-in;
+ mask-image:
+ radial-gradient(64vh 52vh at var(--sw-disc-x, 50%) var(--fo-disc-y), transparent 0%, #000 22%, transparent 100%),
+ linear-gradient(to bottom, transparent calc(var(--fo-disc-y) + 2vh), #000 calc(var(--fo-disc-y) + 14vh));
+ mask-composite: intersect;
+}
+/* FOG — strips of long, soft billows that drift one tile per loop (seamless), each overhanging the
+ viewport by a tile per side. Same GEOMETRY RULE as the cloud decks: every ellipse stays inside its
+ tile (cx ± rx within 0–100%) or it clips to a hard edge that repeats with the tile. */
+.fo-fog {
+ position: absolute;
+ left: calc(-1 * var(--fo-ft));
+ right: calc(-1 * var(--fo-ft));
+ background-repeat: repeat-x;
+ background-size: var(--fo-ft) 100%;
+ animation: fo-drift calc(var(--pat-dur, 8s) * var(--fo-fk)) linear infinite;
+ will-change: var(--pat-wc, transform);
+}
+@keyframes fo-drift {
+ from { transform: translateX(0); }
+ to { transform: translateX(var(--fo-ft)); }
+}
+.fo-fog-a {
+ top: 55%;
+ height: 14%;
+ --fo-ft: 150vh;
+ --fo-fk: 12;
+ background-image:
+ radial-gradient(34% 30% at 36% 56%, var(--fo-fog-a) 0 18%, transparent 80%),
+ radial-gradient(18% 22% at 78% 48%, var(--fo-fog-a) 0 16%, transparent 78%),
+ linear-gradient(to bottom, transparent 0, var(--fo-fog-a) 60%, transparent 100%);
+}
+/* The two strips that cross the floor are thinner than the far one, so the floor stays brown under
+ them: pale fog laid across it at full strength washed the duff out to beige. */
+.fo-fog-b {
+ opacity: 0.55;
+ top: 62%;
+ height: 18%;
+ --fo-ft: 190vh;
+ --fo-fk: 8;
+ background-image:
+ radial-gradient(30% 32% at 30% 52%, var(--fo-fog-a) 0 18%, transparent 80%),
+ radial-gradient(22% 26% at 72% 58%, var(--fo-fog-a) 0 16%, transparent 78%),
+ linear-gradient(to bottom, transparent 0, var(--fo-fog-a) 58%, transparent 100%);
+}
+.fo-fog-c {
+ opacity: 0.4;
+ top: 70%;
+ height: 20%;
+ --fo-ft: 230vh;
+ --fo-fk: 5.5;
+ background-image:
+ radial-gradient(36% 34% at 40% 54%, var(--fo-fog-a) 0 16%, transparent 80%),
+ radial-gradient(14% 22% at 86% 48%, var(--fo-fog-a) 0 14%, transparent 76%),
+ linear-gradient(to bottom, transparent 0, var(--fo-fog-a) 62%, transparent 100%);
+}
+${stopRules(".fo-fog")}`;
+
// Starfield — a deep night field by night, a "morning haze" by day: pastel accent sky, the nebulae as
// bright wisps, and the glints SOFTENED but kept (identity over astronomy — hiding the stars would
// leave an empty gradient; the Tron scenes can hide theirs because the sun/grid carry the scene).
@@ -1708,10 +3158,21 @@ const STARFIELD_STYLES = `
// MODE-AWARE like the Tron scenes: the --pac-* palette carries a sunlit theme-tinted maze by day (tonal
// paper field, tonal-dark pellets/walls) and the original CRT arcade values by night, all hue-tracking
// the tint — the light/dark flip is pure CSS. Pac himself keeps his gold in both.
+// STILL: where each lane stands when nothing moves (Speed 0 or reduced motion), as its runners' BASE
+// transforms, which the running animations override. With the animations gone they used to sit at their
+// keyframe origins: every Pac off-screen to the left and every ghost stacked in one column at the edge.
+// `pac` / `ghost` are the sprites' leading positions in vw (vh for the N–S lanes); `scared` ghosts hold the
+// fright blue, about to be eaten. Poses keep clear of the N–S columns where those lanes' own sprites stand
+// (a 30% ghost at 69vw sat on the 74% column's ghost on short windows).
const PAC_LANES = [
{
top: "12%",
factor: 2.3,
+ still: {
+ pac: 34,
+ ghost: 52,
+ scared: false,
+ },
hue: "var(--hue-complement)",
frightDur: "9s",
delay: "0s",
@@ -1720,6 +3181,11 @@ const PAC_LANES = [
{
top: "30%",
factor: 1.9,
+ still: {
+ pac: 52,
+ ghost: 59,
+ scared: true,
+ },
hue: "calc(var(--hue-complement) + 22)",
frightDur: "7.5s",
delay: "-3s",
@@ -1728,6 +3194,11 @@ const PAC_LANES = [
{
top: "50%",
factor: 3,
+ still: {
+ pac: 12,
+ ghost: 36,
+ scared: false,
+ },
hue: "calc(var(--hue-complement) - 24)",
frightDur: "10s",
delay: "-1.5s",
@@ -1736,6 +3207,11 @@ const PAC_LANES = [
{
top: "70%",
factor: 2.1,
+ still: {
+ pac: 44,
+ ghost: 51,
+ scared: true,
+ },
hue: "calc(var(--hue-complement) + 44)",
frightDur: "8.5s",
delay: "-5s",
@@ -1744,6 +3220,11 @@ const PAC_LANES = [
{
top: "88%",
factor: 2.6,
+ still: {
+ pac: 76,
+ ghost: 93,
+ scared: false,
+ },
hue: "calc(var(--hue-complement) - 44)",
frightDur: "11s",
delay: "-2.5s",
@@ -1757,6 +3238,11 @@ const PAC_VLANES = [
{
left: "26%",
factor: 2.4,
+ still: {
+ pac: 58,
+ ghost: 77,
+ scared: false,
+ },
hue: "calc(var(--hue-complement) + 12)",
frightDur: "8s",
delay: "-2s",
@@ -1766,6 +3252,11 @@ const PAC_VLANES = [
{
left: "74%",
factor: 2,
+ still: {
+ pac: 40,
+ ghost: 19,
+ scared: true,
+ },
hue: "calc(var(--hue-complement) - 12)",
frightDur: "9.5s",
delay: "-6s",
@@ -1854,6 +3345,39 @@ const PAC_WALLS = [
},
];
+/** The fright blue a scared ghost turns (pac-fright's peak), held by a STILL scared ghost. */
+const PAC_FRIGHT = "oklch(0.6 0.2 262)";
+
+/** A lane's shared vars, for its wake layer and its sprite layer alike. */
+function pacVars(lane: (typeof PAC_LANES)[number] | (typeof PAC_VLANES)[number]) {
+ return {
+ "--pac-dur": `calc(var(--pat-dur, 8s) * ${lane.factor})`,
+ "--pac-ghost": `oklch(0.68 0.19 ${lane.hue})`,
+ "--pac-ghost-still": lane.still.scared ? PAC_FRIGHT : undefined,
+ "--pac-fright-dur": lane.frightDur,
+ "--pac-delay": lane.delay,
+ };
+}
+
+const pacLane = (lane: (typeof PAC_LANES)[number]) =>
+ ({
+ // Snap the nominal % to the nearest pellet-row CENTER below it (rows sit at (k+0.5)·cell), so Pac
+ // chomps along an actual dot line at every density and viewport.
+ top: `calc(round(down, ${lane.top}, var(--pac-cell, 34px)) + var(--pac-cell, 34px) / 2)`,
+ "--pac-x": `${lane.still.pac}vw`,
+ "--ghost-x": `${lane.still.ghost}vw`,
+ ...pacVars(lane),
+ }) as CSSProperties;
+
+const pacVlane = (lane: (typeof PAC_VLANES)[number]) =>
+ ({
+ // Same pellet-grid snap as the E–W lanes, along the x axis (columns at (k+0.5)·cell).
+ left: `calc(round(down, ${lane.left}, var(--pac-cell, 34px)) + var(--pac-cell, 34px) / 2)`,
+ "--pac-y": `${lane.still.pac}vh`,
+ "--ghost-y": `${lane.still.ghost}vh`,
+ ...pacVars(lane),
+ }) as CSSProperties;
+
const PAC_STYLES = `
[data-pattern="chase"] {
/* DAY — a sunlit arcade: theme-tinted paper field, tonal-dark pellets and walls. The maze recolors
@@ -1877,9 +3401,9 @@ const PAC_STYLES = `
inset: 0;
overflow: hidden;
background: var(--pac-bg);
- /* Sprite box: one pellet cell, capped at the classic 34px — dense shrinks sprites to the cell,
- sparse keeps them 34px centered INSIDE the larger cell. Pac and ghost share it. */
- --pac-sprite: min(var(--pac-cell, 34px), 34px);
+ /* Sprite box: 1.3 pellet cells, capped at 1.3 × the classic 34px: Pac and the ghosts stand out over the
+ dot grid as they do in the arcade, rather than fitting inside one cell. Pac and ghost share it. */
+ --pac-sprite: calc(min(var(--pac-cell, 34px), 34px) * 1.3);
}
.pac-field {
position: absolute;
@@ -1905,15 +3429,15 @@ const PAC_STYLES = `
height: var(--pac-sprite);
transform: translateY(-50%);
}
-/* Pac + his "wake" ride one runner that translates across; the wake is a bg-colored gradient trailing his
- mouth, erasing the pellets he passes. Only transforms animate here. */
+/* Pac rides a runner that translates across. Only transforms animate here. The base transform is the lane's
+ STILL pose, which the animation overrides while it runs. */
.pac-runner {
position: absolute;
top: 50%;
left: 0;
width: var(--pac-sprite);
height: var(--pac-sprite);
- transform: translate(-16vw, -50%);
+ transform: translate(var(--pac-x, 40vw), -50%);
animation: pac-run var(--pac-dur, 9s) linear infinite;
animation-delay: var(--pac-delay, 0s);
will-change: var(--pat-wc, transform);
@@ -1922,20 +3446,43 @@ const PAC_STYLES = `
from { transform: translate(-16vw, -50%); }
to { transform: translate(112vw, -50%); }
}
-.pac-wake {
+/* What Pac has eaten STAYS eaten. His wake is a bg-coloured strip from his mouth back past the left edge, on
+ its own runner in step with his, so the row stays cleared behind him for the whole pass; it used to be a
+ 34vw gradient, so pellets grew back right behind him and the next pass ate them again. It fades only once
+ he is off-screen (the last 10% of the pass), so the board refills between passes, not under anyone.
+ The wakes sit in a layer BELOW every sprite: a lane's wake would otherwise paint over a Pac or ghost
+ crossing it on another lane. */
+.pac-wake-runner {
position: absolute;
- right: calc(var(--pac-sprite) / 2);
top: 50%;
- width: 34vw;
+ left: 0;
+ width: var(--pac-sprite);
height: var(--pac-sprite);
- transform: translateY(-50%);
- background: linear-gradient(to left, var(--pac-bg), var(--pac-bg) 30%, transparent);
+ transform: translate(var(--pac-x, 40vw), -50%);
+ animation: pac-eat var(--pac-dur, 9s) linear infinite;
+ animation-delay: var(--pac-delay, 0s);
+ will-change: var(--pat-wc, transform), var(--pat-wc, opacity);
+}
+@keyframes pac-eat {
+ 0% { transform: translate(-16vw, -50%); opacity: 1; }
+ 90% { opacity: 1; }
+ 100% { transform: translate(112vw, -50%); opacity: 0; }
+}
+.pac-wake {
+ position: absolute;
+ right: calc(var(--pac-sprite) / 2);
+ top: 0;
+ width: 140vw;
+ height: 100%;
+ background: var(--pac-bg);
}
.pac-man {
position: absolute;
inset: 0;
background: oklch(0.86 0.17 96);
border-radius: 50%;
+ /* Mouth open when still: the chomp's widest frame. Without the animation he was a closed yellow disc. */
+ clip-path: polygon(50% 50%, 100% 22%, 100% 0, 0 0, 0 100%, 100% 100%, 100% 78%);
animation: pac-chomp 0.34s linear infinite;
animation-delay: var(--pac-delay, 0s);
}
@@ -1949,7 +3496,7 @@ const PAC_STYLES = `
left: 0;
width: var(--pac-sprite);
height: var(--pac-sprite);
- transform: translate(4vw, -50%);
+ transform: translate(var(--ghost-x, 60vw), -50%);
animation: pac-flee var(--pac-dur, 9s) linear infinite;
animation-delay: var(--pac-delay, 0s);
will-change: var(--pat-wc, transform);
@@ -1967,14 +3514,38 @@ const PAC_STYLES = `
78% { transform: translate(80vw, -50%); opacity: 0; }
100% { transform: translate(80vw, -50%); opacity: 0; }
}
+/* A ghost is three layers in one element: it bobs; ::before is the masked body, which takes the colour (still,
+ a scared lane's ghost holds the fright blue, --pac-ghost-still; running, pac-fright drives it); ::after
+ paints the eyes over it, white with pupils looking where it is going (--eye-x / --eye-y). */
.pac-ghost {
position: absolute;
inset: 0;
- background-color: var(--pac-ghost, oklch(0.68 0.19 320));
+ --eye-x: 2.5%;
+ --eye-y: 0%;
+ animation: pac-bob 1.1s ease-in-out infinite;
+ animation-delay: var(--pac-delay, 0s);
+}
+.pac-ghost::before,
+.pac-vghost::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background-color: var(--pac-ghost-still, var(--pac-ghost, oklch(0.68 0.19 320)));
-webkit-mask: ${GHOST} center / contain no-repeat;
mask: ${GHOST} center / contain no-repeat;
- animation: pac-bob 1.1s ease-in-out infinite, pac-fright var(--pac-fright-dur, 8s) linear infinite;
- animation-delay: var(--pac-delay, 0s), var(--pac-delay, 0s);
+ animation: pac-fright var(--pac-fright-dur, 8s) linear infinite;
+ animation-delay: var(--pac-delay, 0s);
+}
+.pac-ghost::after,
+.pac-vghost::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(4.6% 5.4% at calc(37% + var(--eye-x)) calc(41% + var(--eye-y)), oklch(0.32 0.14 262) 92%, transparent),
+ radial-gradient(4.6% 5.4% at calc(63% + var(--eye-x)) calc(41% + var(--eye-y)), oklch(0.32 0.14 262) 92%, transparent),
+ radial-gradient(8.5% 10% at 37% 40%, oklch(0.97 0.01 262) 94%, transparent),
+ radial-gradient(8.5% 10% at 63% 40%, oklch(0.97 0.01 262) 94%, transparent);
}
@keyframes pac-bob {
0%, 100% { transform: translateY(0); }
@@ -1982,7 +3553,7 @@ const PAC_STYLES = `
}
@keyframes pac-fright {
0%, 76%, 100% { background-color: var(--pac-ghost, oklch(0.68 0.19 320)); }
- 84%, 96% { background-color: oklch(0.6 0.2 262); }
+ 84%, 96% { background-color: ${PAC_FRIGHT}; }
}
/* ── Vertical (N–S) lanes ── same runner idea, turned 90°. Pac faces its travel direction (a static rotate
reusing the chomp), the wake trails behind, the ghost flees ahead. .pac-vup flips a lane to climb north.
@@ -2000,7 +3571,7 @@ const PAC_STYLES = `
top: 0;
width: var(--pac-sprite);
height: var(--pac-sprite);
- transform: translate(-50%, -18vh);
+ transform: translate(-50%, var(--pac-y, 50vh));
animation: pac-vrun-down var(--pac-dur, 18s) linear infinite;
animation-delay: var(--pac-delay, 0s);
will-change: var(--pat-wc, transform);
@@ -2013,24 +3584,45 @@ const PAC_STYLES = `
inset: 0;
background: oklch(0.86 0.17 96);
border-radius: 50%;
+ clip-path: polygon(50% 50%, 100% 22%, 100% 0, 0 0, 0 100%, 100% 100%, 100% 78%);
transform: rotate(90deg);
animation: pac-chomp 0.34s linear infinite;
animation-delay: var(--pac-delay, 0s);
}
.pac-vlane.pac-vup .pac-vman { transform: rotate(-90deg); }
-.pac-vwake {
+.pac-vwake-runner {
position: absolute;
left: 50%;
- bottom: calc(var(--pac-sprite) / 2);
+ top: 0;
width: var(--pac-sprite);
- height: 34vh;
- transform: translateX(-50%);
- background: linear-gradient(to top, var(--pac-bg), var(--pac-bg) 30%, transparent);
+ height: var(--pac-sprite);
+ transform: translate(-50%, var(--pac-y, 50vh));
+ animation: pac-veat-down var(--pac-dur, 18s) linear infinite;
+ animation-delay: var(--pac-delay, 0s);
+ will-change: var(--pat-wc, transform), var(--pat-wc, opacity);
+}
+.pac-vlane.pac-vup .pac-vwake-runner { animation-name: pac-veat-up; }
+@keyframes pac-veat-down {
+ 0% { transform: translate(-50%, -18vh); opacity: 1; }
+ 90% { opacity: 1; }
+ 100% { transform: translate(-50%, 118vh); opacity: 0; }
+}
+@keyframes pac-veat-up {
+ 0% { transform: translate(-50%, 118vh); opacity: 1; }
+ 90% { opacity: 1; }
+ 100% { transform: translate(-50%, -18vh); opacity: 0; }
+}
+.pac-vwake {
+ position: absolute;
+ left: 0;
+ bottom: calc(var(--pac-sprite) / 2);
+ width: 100%;
+ height: 140vh;
+ background: var(--pac-bg);
}
.pac-vlane.pac-vup .pac-vwake {
bottom: auto;
top: calc(var(--pac-sprite) / 2);
- background: linear-gradient(to bottom, var(--pac-bg), var(--pac-bg) 30%, transparent);
}
.pac-vghost-runner {
position: absolute;
@@ -2038,7 +3630,7 @@ const PAC_STYLES = `
top: 0;
width: var(--pac-sprite);
height: var(--pac-sprite);
- transform: translate(-50%, 2vh);
+ transform: translate(-50%, var(--ghost-y, 70vh));
animation: pac-vflee-down var(--pac-dur, 18s) linear infinite;
animation-delay: var(--pac-delay, 0s);
will-change: var(--pat-wc, transform);
@@ -2067,17 +3659,32 @@ const PAC_STYLES = `
.pac-vghost {
position: absolute;
inset: 0;
- background-color: var(--pac-ghost, oklch(0.68 0.19 320));
- -webkit-mask: ${GHOST} center / contain no-repeat;
- mask: ${GHOST} center / contain no-repeat;
- animation: pac-vsway 1.2s ease-in-out infinite, pac-fright var(--pac-fright-dur, 8s) linear infinite;
- animation-delay: var(--pac-delay, 0s), var(--pac-delay, 0s);
+ --eye-x: 0%;
+ --eye-y: 3%;
+ animation: pac-vsway 1.2s ease-in-out infinite;
+ animation-delay: var(--pac-delay, 0s);
+}
+.pac-vlane.pac-vup .pac-vghost {
+ --eye-y: -3%;
}
@keyframes pac-vsway {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(4px); }
}
-${stopRules(".pac-runner", ".pac-man", ".pac-ghost-runner", ".pac-ghost", ".pac-vrunner", ".pac-vman", ".pac-vghost-runner", ".pac-vghost")}`;
+${stopRules(
+ ".pac-runner",
+ ".pac-wake-runner",
+ ".pac-man",
+ ".pac-ghost-runner",
+ ".pac-ghost",
+ ".pac-ghost::before",
+ ".pac-vrunner",
+ ".pac-vwake-runner",
+ ".pac-vman",
+ ".pac-vghost-runner",
+ ".pac-vghost",
+ ".pac-vghost::before",
+)}`;
/** The geometric tiles' size at scale 1, in px: the grid's cell and the dots' spacing. Exported so a
* preview can size its tiles from the same numbers the wallpaper uses (Agent Muse's PatternSwatch). */
@@ -2152,6 +3759,11 @@ function styleFor(style: PatternStyle, density: PatternDensity, tileScale?: numb
return {
backgroundColor: "oklch(0.08 0.02 268)",
};
+ case "forest":
+ // Rendered by its own layered branch in PatternBackground (palettes in FOREST_STYLES); kept exhaustive here.
+ return {
+ backgroundColor: "oklch(0.09 0.035 262)",
+ };
case "chase":
// Pac-Man chase is rendered by its own branch in PatternBackground; kept exhaustive here.
return {
@@ -2221,51 +3833,37 @@ export function PatternBackground({
}}
/>
))}
+ {/* Every wake under every sprite (see .pac-wake-runner), then the sprites. */}
{PAC_LANES.map((lane) => (
-
+
+ ))}
+ {PAC_VLANES.map((lane) => (
+
+ ))}
+ {PAC_LANES.map((lane) => (
+
))}
{PAC_VLANES.map((lane) => (
-
+
@@ -2475,24 +4073,90 @@ export function PatternBackground({
{/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and
it stops reading as a sun. Still before the glow and water, so the haze and waterline
cover it the way they cover everything else at the horizon. */}
+
-
-
);
}
+ if (style === "forest") {
+ const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(", ");
+ // Which facet takes the light: the side facing the disc. Centred, the light still leans left a
+ // little; an even split would erase the facets and leave flat silhouettes.
+ const [litL, litR] = {
+ left: [
+ "1",
+ "0",
+ ],
+ center: [
+ "0.7",
+ "0.25",
+ ],
+ right: [
+ "0",
+ "1",
+ ],
+ }[disc];
+ const ridge = (i: number) => ;
+ return (
+
+
+
+
+
+
+ {/* The disc goes down first: every ridge stands in front of it. */}
+
+
+ {ridge(0)}
+ {ridge(1)}
+
+
+ {ridge(2)}
+
+ {/* Rays between the middle ridges: in front of the far ranks, behind the near trees. */}
+
+ {ridge(3)}
+
+ {ridge(4)}
+
+
+ );
+ }
if (style === "starfield") {
// Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark
// day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own
diff --git a/docs/color-tokens.md b/docs/color-tokens.md
index a33bd8ba..443648a6 100644
--- a/docs/color-tokens.md
+++ b/docs/color-tokens.md
@@ -68,7 +68,7 @@ The recolor controls: the tint presets (`[data-glass-tint="…"]`) and the live
| `--glass-tint-a` | `0` | *(shared)* | Tint **film** alpha, per preset. The former "Wash" slider is retired: chroma is the colorfulness dial |
| `--glass-opaque-l` | `90` | `32` | Tint **body lightness** (OKLCH L, plain number → %); lower = a deeper tint. The opaque floor color + AutoForeground's opaque-card banding both read it |
| `--glass-opacity` | `0.7` | *(shared)* | Component **solidity floor**: `0` = the material's sheer glass, `1` = reads fully opaque. Default 0.7; set inline or via `glassVars({ opacity })`. Note AutoForeground does NOT read this, so text is banded against the sheer floor — measured safe at 0.7 (body 86.5 Lc light / 85.8 dark, floor 75) |
-| `--glass-opaque-outline` | *(unset)* | *(shared)* | Optional **accent outline** for opaque surfaces, falls back to `--glass-border`; set to e.g. `var(--glass-accent)` for a colored edge |
+| `--glass-outline` | *(unset)* | *(shared)* | Optional **outline colour** for every glass surface, any material; falls back to each material's own edge. With `data-glass-outline` on an ancestor, every surface below it (borderless ones included) draws the outline, in the theme accent unless this is set. `--glass-outline-w` sets the weight of every glass edge. The old `--glass-opaque-outline` / `-w` names are still read |
| `--glass-tint-wash` | `oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a))` | *(knobs: L 72%→58%)* | Solid colored floor; lighter in light mode, deeper in dark (via `--glass-wash-l`) |
| `--glass-accent` | `oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)))` | *(knobs: 0.6 0.15 → 0.68 0.14)* | Vivid solid accent in the **foreground** hue (accent knob overrides apply in both modes), switch/slider fill |
| `--glass-glow` | `oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a))` | *(knobs: 0.62 0.2/0.45 → 0.7 0.18/0.5)* | Glow color in the foreground hue, drives the `glow` hover effect + the `glow` prop |
diff --git a/lib/oklch-utils.ts b/lib/oklch-utils.ts
index d97c102e..188790df 100644
--- a/lib/oklch-utils.ts
+++ b/lib/oklch-utils.ts
@@ -777,6 +777,77 @@ function apcaLuminance([r, g, b]: [
return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;
}
+/**
+ * The colour stops of a fresco gradient (`linear-gradient(135deg in oklch, oklch(85% 0.1 222 / 0.32) 0%, …)`)
+ * as compositable layers. Reads the computed value of `--glass-crystal-fresco`, which the four fresco presets
+ * point at their multi-hue sheet and every other preset leaves unset (empty string → no stops).
+ */
+export function parseGradientStops(gradient: string): Array<
+ OklchColor & {
+ a: number;
+ }
+> {
+ const out: Array<
+ OklchColor & {
+ a: number;
+ }
+ > = [];
+ for (const m of gradient.matchAll(/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+)(%?))?\s*\)/g)) {
+ out.push({
+ l: m[2] ? Number(m[1]) : Number(m[1]) * 100,
+ c: Number(m[3]),
+ h: Number(m[4]),
+ a: m[5] === undefined ? 1 : m[6] ? Number(m[5]) / 100 : Number(m[5]),
+ });
+ }
+ return out;
+}
+
+/**
+ * The hardest place on a surface to read text, once a fresco's multi-hue gradient is painted over it.
+ *
+ * A fresco sheet is bold (stops at L78–89, chroma up to 0.2, alpha 0.2–0.46), and nothing modelled it:
+ * every material banded its text against the plain surface, so text sized for the average dipped to
+ * 66–74 Lc where a gradient stop pulled the surface toward it. A linear gradient's extremes are its
+ * stops, so the worst point is the surface with one stop composited over it. Which stop is worst depends
+ * on the text's polarity, so that is decided first, by which of black or white reaches further on the
+ * plain surface. Returns the plain surface when there are no stops.
+ */
+export function limitingSurface(
+ surface: OklchColor,
+ stops: Array<
+ OklchColor & {
+ a: number;
+ }
+ >,
+): OklchColor {
+ if (stops.length === 0) return surface;
+ const black = {
+ l: 0,
+ c: 0,
+ h: 0,
+ };
+ const white = {
+ l: 100,
+ c: 0,
+ h: 0,
+ };
+ const ink = Math.abs(apcaContrast(black, surface)) >= Math.abs(apcaContrast(white, surface)) ? black : white;
+ let worst = surface;
+ let worstLc = Math.abs(apcaContrast(ink, surface));
+ for (const stop of stops) {
+ const lit = compositeSurface(surface, [
+ stop,
+ ]);
+ const lc = Math.abs(apcaContrast(ink, lit));
+ if (lc < worstLc) {
+ worst = lit;
+ worstLc = lc;
+ }
+ }
+ return worst;
+}
+
/**
* APCA lightness contrast (Lc) between a text color and a background color. Signed: positive = dark
* text on a light bg, negative = light text on a dark bg; use |Lc| for the level (~60 headings,
diff --git a/package.json b/package.json
index d23cecfa..f2b0c5d3 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"storybook:export": "node scripts/export-storybook.js",
+ "export:backgrounds": "node scripts/export-backgrounds.mjs",
"test:contrast": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/check-contrast.mjs"
},
"browserslist": [
@@ -36,7 +37,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
- "framer-motion": "^13.5.1",
+ "framer-motion": "^14.4.0",
"input-otp": "^1.5.0",
"next": "^16.4.2",
"next-themes": "^0.4.6",
@@ -45,7 +46,7 @@
"react-day-picker": "^10.0.2",
"react-dom": "^19.3.1",
"react-easy-crop": "^6.2.4",
- "react-resizable-panels": "^4.14.6",
+ "react-resizable-panels": "^4.14.7",
"recharts": "^3.10.1",
"shadcn": "^4.22.1",
"sonner": "^2.0.8",
@@ -75,7 +76,7 @@
"storybook": "^10.6.2",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
- "vitest": "^4.1.12"
+ "vitest": "^5.0.5"
},
"overrides": {
"@types/react": "^19.2.18",
diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json
index bf5fc1d5..a4a91d2a 100644
--- a/public/r/auto-foreground.json
+++ b/public/r/auto-foreground.json
@@ -11,7 +11,7 @@
"files": [
{
"path": "components/auto-foreground.tsx",
- "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n showThrough as backdropShowThrough,\n boostBand,\n type ContrastBand,\n compositeSurface,\n foregroundRamp,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n LC_AIM_KNOWN,\n LC_MARGIN,\n pickTonalInBand,\n READABLE_USAGE,\n readableTonal,\n type ThemeForegroundOptions,\n} from \"@/lib/oklch-utils\";\n\n/** localStorage key holding the persisted {@link FgConfig}. Exported so an app that reads or seeds the\n * config (a settings screen, a pre-hydration script) never retypes the string. */\nexport const FG_STORAGE_KEY = \"sistine-fg\";\n/** localStorage key holding the persisted {@link RampConfig}. */\nexport const RAMP_KEY = \"sistine-ramp\";\n/** Window event AutoForeground listens for to re-band: dispatch it after changing the tint, the mode or\n * the stored config (`window.dispatchEvent(new Event(FG_EVENT))`). */\nexport const FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/* The tonal clip bounds, the margins and the pick/solve themselves now live in lib/oklch-utils — see\n the \"Foreground solve\" section there. They were duplicated into components/foreground-tester.tsx and\n scripts/check-contrast.mjs, and every copy had drifted from this one in a way no test could see. */\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — ` ` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n // (LC_MARGIN / LC_AIM_KNOWN are exported from lib/oklch-utils so every consumer aims the same.)\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const normalLcBoost = LC_MARGIN * backdropShowThrough(solidA, tintA, glassOpacity);\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n /* Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral gray —\n each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. Only the\n READABLE half (count + 1 levels) is built: the far side of the base is display-only, and picking\n from it would offer steps the surface can never make legible.\n foregroundRamp also drops both achromatic ends on tinted themes — see lib/oklch-utils for why the\n two thresholds are not symmetric. Before that clip, --foreground-strong was #000000 on 20 of 21\n presets in light and #ffffff on 18 of 21 in dark, and body text ALSO went black on 10 of 21. */\n const ramp = foregroundRamp({\n palette,\n count,\n base,\n dark,\n });\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: ContrastBand) => boostBand(band, lcBoost, lcAim);\n /* Pick from the TONAL ramp, never at the cost of the band's floor — see pickTonalInBand in\n lib/oklch-utils. Today no band needs the fallback (light body bottoms out at 75.4 Lc on the\n clipped ramp), but /colors lets a consumer re-base the ramp entirely, so the guard exists so\n the hue preference can never become a readability regression. */\n /* The adaptive twin, readableTonal, also lives in lib/oklch-utils: an unreachable aim makes\n readableForeground return the lightness EXTREME, where the gamut annihilates chroma, so every\n jewel's --foreground-opaque solved to pure black in light. Same clip, same ~1 Lc cost. */\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: ContrastBand) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(surface, band, {\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY — the one case where the background CAN hold the kept chroma, so the\n // minChroma search does not bail to the extreme (see readableTonal).\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonalInBand(ramp, surface, band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(surface, boost(READABLE_USAGE[usage]), {\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableTonal(surface, boost(READABLE_USAGE.ui), {\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same\n // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason\n // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the\n backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the\n gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an\n UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known\n than the model claimed. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n showThrough as backdropShowThrough,\n boostBand,\n type ContrastBand,\n compositeSurface,\n foregroundRamp,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n LC_AIM_KNOWN,\n LC_MARGIN,\n limitingSurface,\n parseGradientStops,\n pickTonalInBand,\n READABLE_USAGE,\n readableTonal,\n type ThemeForegroundOptions,\n} from \"@/lib/oklch-utils\";\n\n/** localStorage key holding the persisted {@link FgConfig}. Exported so an app that reads or seeds the\n * config (a settings screen, a pre-hydration script) never retypes the string. */\nexport const FG_STORAGE_KEY = \"sistine-fg\";\n/** localStorage key holding the persisted {@link RampConfig}. */\nexport const RAMP_KEY = \"sistine-ramp\";\n/** Window event AutoForeground listens for to re-band: dispatch it after changing the tint, the mode or\n * the stored config (`window.dispatchEvent(new Event(FG_EVENT))`). */\nexport const FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/* The tonal clip bounds, the margins and the pick/solve themselves now live in lib/oklch-utils — see\n the \"Foreground solve\" section there. They were duplicated into components/foreground-tester.tsx and\n scripts/check-contrast.mjs, and every copy had drifted from this one in a way no test could see. */\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — ` ` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n /* A fresco's multi-hue sheet, if one is active (--glass-crystal-fresco is unset on every other preset).\n Every material paints it over its surface, so every tier set bands against the hardest stop of it\n (see applyTiers). Frescoes always take the DOM path, so the snapshot never has to carry it. */\n const frescoStops = cs ? parseGradientStops(cs.getPropertyValue(\"--glass-crystal-fresco\")) : [];\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n // (LC_MARGIN / LC_AIM_KNOWN are exported from lib/oklch-utils so every consumer aims the same.)\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 0.98 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const normalLcBoost = LC_MARGIN * backdropShowThrough(solidA, tintA, glassOpacity);\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n /* Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral gray —\n each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. Only the\n READABLE half (count + 1 levels) is built: the far side of the base is display-only, and picking\n from it would offer steps the surface can never make legible.\n foregroundRamp also drops both achromatic ends on tinted themes — see lib/oklch-utils for why the\n two thresholds are not symmetric. Before that clip, --foreground-strong was #000000 on 20 of 21\n presets in light and #ffffff on 18 of 21 in dark, and body text ALSO went black on 10 of 21. */\n const ramp = foregroundRamp({\n palette,\n count,\n base,\n dark,\n });\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: ContrastBand) => boostBand(band, lcBoost, lcAim);\n /* What the text is SOLVED against: the surface itself, or, under a fresco, the composite at the\n gradient stop that leaves the least contrast (lib/oklch-utils limitingSurface). Text sized for the\n plain surface dipped to 66–74 Lc at the stops. The HUE still comes from `surface`: ink follows the\n material it sits on, not whichever stop of the sweep happens to be hardest. */\n const bg = limitingSurface(surface, frescoStops);\n /* Pick from the TONAL ramp, never at the cost of the band's floor — see pickTonalInBand in\n lib/oklch-utils. Today no band needs the fallback (light body bottoms out at 75.4 Lc on the\n clipped ramp), but /colors lets a consumer re-base the ramp entirely, so the guard exists so\n the hue preference can never become a readability regression. */\n /* The adaptive twin, readableTonal, also lives in lib/oklch-utils: an unreachable aim makes\n readableForeground return the lightness EXTREME, where the gamut annihilates chroma, so every\n jewel's --foreground-opaque solved to pure black in light. Same clip, same ~1 Lc cost. */\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: ContrastBand) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(bg, band, {\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY — the one case where the background CAN hold the kept chroma, so the\n // minChroma search does not bail to the extreme (see readableTonal).\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonalInBand(ramp, bg, band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(bg, boost(READABLE_USAGE[usage]), {\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableTonal(bg, boost(READABLE_USAGE.ui), {\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg (under the wash), so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n /* The opaque card is its floor PLUS the tint wash (materials.css: the glass stack at full opacity),\n so the tier bands against the composite. The floor's cap is the preset cap times\n --glass-opaque-cap-scale (the light floor sits at L93, where the L88 caps would overshoot). */\n const opaqueFloor = {\n l: num(\"--glass-opaque-l\", dark ? 33.4 : 93),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 0.98 : 0.85), opaqueCMax * num(\"--glass-opaque-cap-scale\", dark ? 1 : 0.55)),\n h: tintH,\n };\n applyTiers(\n compositeSurface(opaqueFloor, [\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n ]),\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same\n // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason\n // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the\n backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the\n gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an\n UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known\n than the model claimed. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n",
"type": "registry:component",
"target": "components/auto-foreground.tsx"
}
diff --git a/public/r/background-controller.json b/public/r/background-controller.json
index ec9b2e47..7d65b7e9 100644
--- a/public/r/background-controller.json
+++ b/public/r/background-controller.json
@@ -2,7 +2,7 @@
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "background-controller",
"title": "Background Controller",
- "description": "Site-wide animated background system — a provider + segmented switcher driving CSS gradient, JS canvas, and pure-CSS pattern backdrops (dots, grid, mesh, starfield, synthwave/moonrise/clouds/dune horizons with a placeable sun or moon, dancing aurora borealis, Pac-Man chase, and more) that recolor with the live glass tint and accent. Exposes density, speed, accent, and a themed \"none\" base-color.",
+ "description": "Site-wide animated background system — a provider + segmented switcher driving CSS gradient, JS canvas, and pure-CSS pattern backdrops (dots, grid, mesh, starfield, synthwave/moonrise/clouds/dune horizons with a placeable sun or moon, a fogbound coastal redwood forest, dancing aurora borealis, Pac-Man chase, and more) that recolor with the live glass tint and accent. Exposes density, speed, accent, and a themed \"none\" base-color.",
"dependencies": [
"@phosphor-icons/react",
"clsx",
@@ -22,7 +22,7 @@
},
{
"path": "components/pattern-background.tsx",
- "content": "import type { CSSProperties } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora\n * drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n];\n\n/**\n * The two ways a scene stops: reduced motion, and Speed 0.\n *\n * Both REMOVE the animation rather than pausing it. `animation-play-state: paused`\n * looks like it should be free — the transform never advances — but a paused\n * animation still leaves the element in an animating state, and WebKit keeps\n * re-rendering its layer. On `.au-curtain` that layer carries a `filter: blur()`\n * and a two-layer `mask-composite`, six of them at full-viewport size, so \"paused\"\n * was measurably NOT free (AMXSF-559). `animation: none` also drops `will-change`,\n * releasing the promoted layer — `will-change` on a stopped animation is a\n * promoted layer doing no work.\n *\n * Emitted from one helper so the two conditions cannot drift apart: a scene that\n * respects reduced motion but not Speed 0 was exactly the bug.\n */\nexport function stopRules(...selectors: string[]): string {\n const stop = \"{ animation: none; will-change: auto; }\";\n return `@media (prefers-reduced-motion: reduce) { ${selectors.join(\", \")} ${stop} }\n${selectors.map((s) => `[data-pat-static] ${s}`).join(\", \")} ${stop}`;\n}\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nexport const TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body} `)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts.\nconst GHOST = svgMask(\n \" \",\n);\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n >\n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n \n
\n {PAC_WALLS.map((w) => (\n
\n ))}\n {PAC_LANES.map((lane) => (\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n ))}\n
\n \n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n );\n }\n return (\n
\n );\n}\n",
+ "content": "import { type CSSProperties, useId } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora /\n * forest drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"forest\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n \"chase\",\n];\n\n/**\n * The two ways a scene stops: reduced motion, and Speed 0.\n *\n * Both REMOVE the animation rather than pausing it. `animation-play-state: paused`\n * looks like it should be free — the transform never advances — but a paused\n * animation still leaves the element in an animating state, and WebKit keeps\n * re-rendering its layer. On `.au-curtain` that layer carries a `filter: blur()`\n * and a two-layer `mask-composite`, six of them at full-viewport size, so \"paused\"\n * was measurably NOT free (AMXSF-559). `animation: none` also drops `will-change`,\n * releasing the promoted layer — `will-change` on a stopped animation is a\n * promoted layer doing no work.\n *\n * Emitted from one helper so the two conditions cannot drift apart: a scene that\n * respects reduced motion but not Speed 0 was exactly the bug.\n */\nexport function stopRules(...selectors: string[]): string {\n const stop = \"{ animation: none; will-change: auto; }\";\n return `@media (prefers-reduced-motion: reduce) { ${selectors.join(\", \")} ${stop} }\n${selectors.map((s) => `[data-pat-static] ${s}`).join(\", \")} ${stop}`;\n}\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"forest\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nexport const TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body} `)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts. Solid: the eyes are painted over it\n// (.pac-ghost::after). Cut out of it, they showed whatever lay behind, so a pellet read as a stray pupil.\nconst GHOST = svgMask(\" \");\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n >\n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n /* Its own compositing layer, as .sw-sun below: see the note there. */\n will-change: transform;\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n /* Its own compositing LAYER. Moving a disc (the left / center / right control changes \\`left\\`) on the\n page's own layer asks WebKit to repaint the old and new spots, and for a disc carrying a drop-shadow\n filter AND a mask it repainted only part of each: the old sun's top stayed painted at the old spot\n and the new one showed only its lower half. As a layer, the move just repositions it. */\n will-change: transform;\n}\n/* Whole-pixel discs. WebKit sizes a filtered element's buffer from its box, and at a fractional width\n (30vh is 275.4px on a 918px viewport) it clips a sliver off the disc's left edge, which flattens a\n moon visibly. Rounding to an even pixel count keeps the halves whole. Behind @supports because\n round() only reached Chrome in 125: inside var() an unsupported function is invalid at computed-value\n time, which would drop the size outright rather than fall back to the declaration above. */\n@supports (width: round(1px, 2px)) {\n .scene-moon {\n width: round(var(--moon-size, 40vh), 2px);\n height: round(var(--moon-size, 40vh), 2px);\n }\n .sw-sun {\n width: round(var(--sun-size, 46vh), 2px);\n height: round(var(--sun-size, 46vh), 2px);\n }\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n \n
\n {PAC_WALLS.map((w) => (\n
\n ))}\n {/* Every wake under every sprite (see .pac-wake-runner), then the sprites. */}\n {PAC_LANES.map((lane) => (\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n ))}\n {PAC_LANES.map((lane) => (\n
\n ))}\n {PAC_VLANES.map((lane) => (\n
\n ))}\n
\n \n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n
\n
\n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. */}\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"forest\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Which facet takes the light: the side facing the disc. Centred, the light still leans left a\n // little; an even split would erase the facets and leave flat silhouettes.\n const [litL, litR] = {\n left: [\n \"1\",\n \"0\",\n ],\n center: [\n \"0.7\",\n \"0.25\",\n ],\n right: [\n \"0\",\n \"1\",\n ],\n }[disc];\n const ridge = (i: number) => ;\n return (\n \n \n \n
\n
\n
\n {/* The disc goes down first: every ridge stands in front of it. */}\n
\n
\n {ridge(0)}\n {ridge(1)}\n
\n
\n {ridge(2)}\n
\n {/* Rays between the middle ridges: in front of the far ranks, behind the near trees. */}\n
\n {ridge(3)}\n
\n {ridge(4)}\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n );\n }\n return (\n
\n );\n}\n",
"type": "registry:component",
"target": "components/pattern-background.tsx"
},
@@ -40,7 +40,7 @@
},
{
"path": "components/background-provider.tsx",
- "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CanvasBackground } from \"@/components/canvas-background\";\nimport { GradientBackground } from \"@/components/gradient-background\";\nimport { PATTERN_DENSITIES, PATTERN_STYLES, PatternBackground, type PatternDensity, type PatternStyle } from \"@/components/pattern-background\";\nimport type { CanvasStyle } from \"@/lib/canvas-background-utils\";\nimport type { GradientGeometry, GradientShape, RampGradientAxis } from \"@/lib/oklch-utils\";\n\nexport type BackgroundType = \"gradient\" | \"canvas\" | \"pattern\" | \"none\";\n\nconst STORAGE_KEY = \"sistine-background\";\n\n/** Canvas styles (lava renamed from \"blobs\", circle from \"circles\"). */\nexport const CANVAS_STYLES: CanvasStyle[] = [\n \"gradient\",\n \"lava\",\n \"circle\",\n];\n\n/** Ramp axes shared by the Gradient background and the Canvas (lightness = the \"linear\" ramp). */\nexport const RAMP_AXES: RampGradientAxis[] = [\n \"tonal\",\n \"hue\",\n \"lightness\",\n \"chroma\",\n];\n\n/** Gradient painting shapes the Gradient background cycles through. */\nexport const GRADIENT_SHAPES: GradientShape[] = [\n \"linear\",\n \"radial\",\n \"conic\",\n];\n\n/** Center positions (radial + conic) the switcher cycles through. */\nexport const GRADIENT_POSITIONS: {\n value: string;\n label: string;\n}[] = [\n {\n value: \"50% 50%\",\n label: \"center\",\n },\n {\n value: \"0% 0%\",\n label: \"top-left\",\n },\n {\n value: \"100% 0%\",\n label: \"top-right\",\n },\n {\n value: \"100% 100%\",\n label: \"bottom-right\",\n },\n {\n value: \"0% 100%\",\n label: \"bottom-left\",\n },\n];\n\n/** The position values the cycler steps through (labels live in GRADIENT_POSITIONS). */\nconst GRADIENT_POSITION_VALUES = GRADIENT_POSITIONS.map((p) => p.value);\n\n/** Radial shape + size options. */\nexport const RADIAL_SHAPES: NonNullable[] = [\n \"circle\",\n \"ellipse\",\n];\nexport const RADIAL_SIZES: NonNullable[] = [\n \"farthest-corner\",\n \"farthest-side\",\n \"closest-corner\",\n \"closest-side\",\n];\n\n/** Angle presets — 45° steps around the circle, shared by the gradient + canvas angle cyclers. */\nconst ANGLES = [\n 0,\n 45,\n 90,\n 135,\n 180,\n 225,\n 270,\n 315,\n];\n\n/** Steps-per-side presets the canvas/ramps cycle through (4–12). */\nexport const CANVAS_STEPS = [\n 4,\n 6,\n 8,\n 10,\n 12,\n] as const;\n\n/** Animation pace presets. */\nexport const CANVAS_SPEEDS = [\n 0.5,\n 1,\n 2,\n] as const;\n\n/** Animated-pattern pace: loop/scroll duration in SECONDS (2s fastest … 16s slowest); 0 = static.\n * Shared by every animated pattern (synthwave, moonrise, clouds, dune, aurora, chase) via the --pat-dur CSS var. */\nexport const PATTERN_SPEEDS = [\n 2,\n 4,\n 8,\n 12,\n 16,\n 0,\n] as const;\n\n/** Sun/moon horizontal placement for the horizon scenes (synthwave / moonrise / clouds / dune) — off-center\n * clears the page's centered content. */\nexport const PATTERN_DISCS = [\n \"right\",\n \"center\",\n \"left\",\n] as const;\n\n/** Blowing-sand intensity for the dune scene — \"still\" is a calm desert (no airborne sand at all),\n * distinct from the speed control's \"static\", which only pauses the motion that is already there. */\nexport const PATTERN_SANDS = [\n \"breeze\",\n \"storm\",\n \"still\",\n] as const;\n\nfunction persistBackground(next: BackgroundType) {\n try {\n localStorage.setItem(STORAGE_KEY, next);\n } catch {\n // ignore storage failures (private mode, etc.)\n }\n}\n\nfunction pickRandom(items: readonly T[]): T {\n return items[Math.floor(Math.random() * items.length)];\n}\n\nfunction cycle(items: readonly T[], current: T): T {\n const i = items.indexOf(current);\n return items[(i + 1) % items.length];\n}\n\n/**\n * State that steps through a fixed preset list, wrapping at the end. Returns the value, a stable\n * advance-to-the-next-preset callback, and the raw setter.\n */\nfunction useCycle(\n items: readonly T[],\n initial: T,\n): [\n T,\n () => void,\n React.Dispatch>,\n] {\n const [value, setValue] = React.useState(initial);\n const next = React.useCallback(\n () => setValue((v) => cycle(items, v)),\n [\n items,\n ],\n );\n return [\n value,\n next,\n setValue,\n ];\n}\n\ninterface BackgroundContextValue {\n background: BackgroundType;\n setBackground: (background: BackgroundType) => void;\n /** The ramp axis driving the gradient background. */\n gradientAxis: RampGradientAxis;\n /** Switch to the gradient background and set its ramp axis. */\n setGradientAxis: (axis: RampGradientAxis) => void;\n /** Gradient angle in degrees (90 = left → right). */\n gradientAngle: number;\n /** Rotate the gradient by 45°. */\n cycleGradientAngle: () => void;\n /** The gradient painting shape (linear | radial | conic). */\n gradientShape: GradientShape;\n /** Advance to the next gradient shape. */\n cycleGradientShape: () => void;\n /** Center position for radial + conic gradients (CSS `at `). */\n gradientPosition: string;\n /** Advance to the next center-position preset. */\n cycleGradientPosition: () => void;\n /** Radial shape (circle | ellipse). */\n radialShape: NonNullable;\n /** Toggle the radial shape. */\n cycleRadialShape: () => void;\n /** Radial size keyword. */\n radialSize: NonNullable;\n /** Advance to the next radial size. */\n cycleRadialSize: () => void;\n // ── Canvas ──\n /** The canvas style (gradient | lava | circle). */\n canvasStyle: CanvasStyle;\n /** Switch to the canvas background and set its style. */\n setCanvasStyle: (style: CanvasStyle) => void;\n /** The ramp axis driving the canvas colors. */\n canvasRamp: RampGradientAxis;\n /** Switch to the canvas background and set its ramp axis. */\n setCanvasRamp: (axis: RampGradientAxis) => void;\n /** Steps per side (4–12). */\n canvasSteps: number;\n /** Advance to the next steps-per-side preset. */\n cycleCanvasSteps: () => void;\n /** Canvas gradient angle in degrees. */\n canvasAngle: number;\n /** Rotate the canvas gradient by 45°. */\n cycleCanvasAngle: () => void;\n /** Canvas animation pace. */\n canvasSpeed: number;\n /** Advance to the next animation-pace preset. */\n cycleCanvasSpeed: () => void;\n /** Whether the canvas is currently animating. */\n canvasAnimated: boolean;\n /** Toggle canvas animation on/off. */\n toggleCanvasAnimated: () => void;\n /** Switch to the canvas background and reshuffle it (new style + ramp + layout seed). */\n shuffleCanvas: () => void;\n // ── Pattern (pure-CSS wallpapers) ──\n /** The active CSS pattern style. */\n patternStyle: PatternStyle;\n /** Switch to the pattern background and set its style. */\n setPatternStyle: (style: PatternStyle) => void;\n /** Advance to the next pattern style. */\n cyclePatternStyle: () => void;\n /** Star-field density (sparse | medium | dense). */\n patternDensity: PatternDensity;\n /** Advance to the next star-field density. */\n cyclePatternDensity: () => void;\n /** Horizon-grid scroll pace (synthwave / moonrise); 0 is static. */\n patternSpeed: number;\n /** Advance to the next grid scroll pace (… → static). */\n cyclePatternSpeed: () => void;\n /** Sun/moon placement for the horizon scenes (right | center | left). */\n patternDisc: (typeof PATTERN_DISCS)[number];\n /** Advance to the next sun/moon placement. */\n cyclePatternDisc: () => void;\n /** Blowing-sand intensity for the dune scene (breeze | storm | still). */\n patternSand: (typeof PATTERN_SANDS)[number];\n /** Advance to the next blowing-sand level. */\n cyclePatternSand: () => void;\n // ── Base color (the \"none\" backdrop) ──\n /** Base color for the \"none\" backdrop; null = follow the theme (tint-tinted, light/dark aware). */\n baseColor: string | null;\n /** Override the \"none\" backdrop color; null clears back to the themed default. */\n setBaseColor: (color: string | null) => void;\n}\n\nconst BackgroundContext = React.createContext(null);\n\nexport function useBackground() {\n const context = React.useContext(BackgroundContext);\n if (!context) {\n throw new Error(\"useBackground must be used within a BackgroundProvider\");\n }\n return context;\n}\n\ninterface RenderArgs {\n gradientAxis: RampGradientAxis;\n gradientAngle: number;\n gradientShape: GradientShape;\n gradientPosition: string;\n radialShape: NonNullable;\n radialSize: NonNullable;\n canvasStyle: CanvasStyle;\n canvasRamp: RampGradientAxis;\n canvasSteps: number;\n canvasAngle: number;\n canvasSpeed: number;\n canvasAnimated: boolean;\n canvasSeed: string;\n patternStyle: PatternStyle;\n patternDensity: PatternDensity;\n patternSpeed: number;\n patternDisc: (typeof PATTERN_DISCS)[number];\n patternSand: (typeof PATTERN_SANDS)[number];\n baseColor: string | null;\n}\n\nfunction renderBackground(background: BackgroundType, args: RenderArgs) {\n switch (background) {\n case \"canvas\":\n // key forces a clean redraw when the style/ramp/steps/seed/animation change\n return (\n \n );\n case \"pattern\":\n return (\n \n );\n case \"none\":\n // Forgo the decorative backdrop — a clean solid that honors the theme (tint hue, light/dark) by\n // default, overridable by the user's chosen base color.\n return (\n <>\n \n
\n >\n );\n default:\n return (\n \n );\n }\n}\n\n/**\n * Holds the site-wide background choice (persisted to localStorage) and renders it\n * behind the app. Pair with to preview + tune each style.\n */\nexport function BackgroundProvider({ children }: { children: React.ReactNode }) {\n // SSR + first client render use \"gradient\" so hydration matches; localStorage is read after mount.\n const [background, setBackgroundState] = React.useState(\"pattern\");\n const [gradientAxis, setGradientAxisState] = React.useState(\"tonal\");\n const [gradientAngle, cycleGradientAngle] = useCycle(ANGLES, 90);\n const [gradientShape, cycleGradientShape] = useCycle(GRADIENT_SHAPES, \"linear\");\n const [gradientPosition, cycleGradientPosition] = useCycle(GRADIENT_POSITION_VALUES, \"50% 50%\");\n const [radialShape, cycleRadialShape] = useCycle(RADIAL_SHAPES, \"circle\");\n const [radialSize, cycleRadialSize] = useCycle(RADIAL_SIZES, \"farthest-corner\");\n const [canvasStyle, setCanvasStyleState] = React.useState(\"gradient\");\n const [canvasRamp, setCanvasRampState] = React.useState(\"tonal\");\n const [canvasSteps, cycleCanvasSteps] = useCycle(CANVAS_STEPS, 6);\n const [canvasAngle, cycleCanvasAngle] = useCycle(ANGLES, 90);\n const [canvasSpeed, cycleCanvasSpeed] = useCycle(CANVAS_SPEEDS, 1);\n const [canvasAnimated, setCanvasAnimated] = React.useState(false);\n const [canvasSeed, setCanvasSeed] = React.useState(\"sistine\");\n const [patternStyle, cyclePatternStyleState, setPatternStyleState] = useCycle(PATTERN_STYLES, \"moonrise\");\n const [patternDensity, cyclePatternDensityState] = useCycle(PATTERN_DENSITIES, \"medium\");\n /* Static by DEFAULT (0 = no animation, the last entry in PATTERN_SPEEDS). The pattern layer is a\n backdrop for reading glass against, and a moving one competes with the content for attention on\n first load — motion should be something you opt into, not something you have to switch off. The\n speed control still cycles 2/4/8/12/16/0, so the animation is one click away. */\n const [patternSpeed, cyclePatternSpeedState] = useCycle(PATTERN_SPEEDS, 0);\n const [baseColor, setBaseColor] = React.useState(null);\n const [patternDisc, cyclePatternDiscState] = useCycle(PATTERN_DISCS, \"right\");\n const [patternSand, cyclePatternSandState] = useCycle(PATTERN_SANDS, \"breeze\");\n\n React.useEffect(() => {\n const stored = localStorage.getItem(STORAGE_KEY);\n if (stored === \"gradient\" || stored === \"canvas\" || stored === \"pattern\" || stored === \"none\") {\n setBackgroundState(stored);\n }\n }, []);\n\n /** The coupling every per-style setter/cycler shares: switch to that background type, apply, persist. */\n const withType =\n (type: BackgroundType, fn: (...args: A) => void) =>\n (...args: A) => {\n setBackgroundState(type);\n fn(...args);\n persistBackground(type);\n };\n\n const value: BackgroundContextValue = {\n background,\n setBackground: (next) => {\n setBackgroundState(next);\n persistBackground(next);\n },\n gradientAxis,\n setGradientAxis: withType(\"gradient\", setGradientAxisState),\n gradientAngle,\n cycleGradientAngle,\n gradientShape,\n cycleGradientShape,\n gradientPosition,\n cycleGradientPosition,\n radialShape,\n cycleRadialShape,\n radialSize,\n cycleRadialSize,\n canvasStyle,\n setCanvasStyle: withType(\"canvas\", setCanvasStyleState),\n canvasRamp,\n setCanvasRamp: withType(\"canvas\", setCanvasRampState),\n canvasSteps,\n cycleCanvasSteps,\n canvasAngle,\n cycleCanvasAngle,\n canvasSpeed,\n cycleCanvasSpeed,\n canvasAnimated,\n toggleCanvasAnimated: () => setCanvasAnimated((on) => !on),\n shuffleCanvas: withType(\"canvas\", () => {\n setCanvasStyleState(pickRandom(CANVAS_STYLES));\n setCanvasRampState(pickRandom(RAMP_AXES));\n setCanvasSeed(Math.random().toString(36).slice(2));\n }),\n patternStyle,\n setPatternStyle: withType(\"pattern\", setPatternStyleState),\n cyclePatternStyle: withType(\"pattern\", cyclePatternStyleState),\n patternDensity,\n cyclePatternDensity: withType(\"pattern\", cyclePatternDensityState),\n patternSpeed,\n cyclePatternSpeed: withType(\"pattern\", cyclePatternSpeedState),\n patternDisc,\n cyclePatternDisc: withType(\"pattern\", cyclePatternDiscState),\n patternSand,\n cyclePatternSand: withType(\"pattern\", cyclePatternSandState),\n baseColor,\n setBaseColor,\n };\n\n return (\n \n {renderBackground(background, {\n gradientAxis,\n gradientAngle,\n gradientShape,\n gradientPosition,\n radialShape,\n radialSize,\n canvasStyle,\n canvasRamp,\n canvasSteps,\n canvasAngle,\n canvasSpeed,\n canvasAnimated,\n canvasSeed,\n patternStyle,\n patternDensity,\n patternSpeed,\n patternDisc,\n patternSand,\n baseColor,\n })}\n {children}\n \n );\n}\n",
+ "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CanvasBackground } from \"@/components/canvas-background\";\nimport { GradientBackground } from \"@/components/gradient-background\";\nimport { PATTERN_DENSITIES, PATTERN_STYLES, PatternBackground, type PatternDensity, type PatternStyle } from \"@/components/pattern-background\";\nimport type { CanvasStyle } from \"@/lib/canvas-background-utils\";\nimport type { GradientGeometry, GradientShape, RampGradientAxis } from \"@/lib/oklch-utils\";\n\nexport type BackgroundType = \"gradient\" | \"canvas\" | \"pattern\" | \"none\";\n\nconst STORAGE_KEY = \"sistine-background\";\n\n/** Canvas styles (lava renamed from \"blobs\", circle from \"circles\"). */\nexport const CANVAS_STYLES: CanvasStyle[] = [\n \"gradient\",\n \"lava\",\n \"circle\",\n];\n\n/** Ramp axes shared by the Gradient background and the Canvas (lightness = the \"linear\" ramp). */\nexport const RAMP_AXES: RampGradientAxis[] = [\n \"tonal\",\n \"hue\",\n \"lightness\",\n \"chroma\",\n];\n\n/** Gradient painting shapes the Gradient background cycles through. */\nexport const GRADIENT_SHAPES: GradientShape[] = [\n \"linear\",\n \"radial\",\n \"conic\",\n];\n\n/** Center positions (radial + conic) the switcher cycles through. */\nexport const GRADIENT_POSITIONS: {\n value: string;\n label: string;\n}[] = [\n {\n value: \"50% 50%\",\n label: \"center\",\n },\n {\n value: \"0% 0%\",\n label: \"top-left\",\n },\n {\n value: \"100% 0%\",\n label: \"top-right\",\n },\n {\n value: \"100% 100%\",\n label: \"bottom-right\",\n },\n {\n value: \"0% 100%\",\n label: \"bottom-left\",\n },\n];\n\n/** The position values the cycler steps through (labels live in GRADIENT_POSITIONS). */\nconst GRADIENT_POSITION_VALUES = GRADIENT_POSITIONS.map((p) => p.value);\n\n/** Radial shape + size options. */\nexport const RADIAL_SHAPES: NonNullable[] = [\n \"circle\",\n \"ellipse\",\n];\nexport const RADIAL_SIZES: NonNullable[] = [\n \"farthest-corner\",\n \"farthest-side\",\n \"closest-corner\",\n \"closest-side\",\n];\n\n/** Angle presets — 45° steps around the circle, shared by the gradient + canvas angle cyclers. */\nconst ANGLES = [\n 0,\n 45,\n 90,\n 135,\n 180,\n 225,\n 270,\n 315,\n];\n\n/** Steps-per-side presets the canvas/ramps cycle through (4–12). */\nexport const CANVAS_STEPS = [\n 4,\n 6,\n 8,\n 10,\n 12,\n] as const;\n\n/** Animation pace presets. */\nexport const CANVAS_SPEEDS = [\n 0.5,\n 1,\n 2,\n] as const;\n\n/** Animated-pattern pace: loop/scroll duration in SECONDS (2s fastest … 16s slowest); 0 = static.\n * Shared by every animated pattern (synthwave, moonrise, clouds, dune, aurora, forest, chase) via the --pat-dur CSS var. */\nexport const PATTERN_SPEEDS = [\n 2,\n 4,\n 8,\n 12,\n 16,\n 0,\n] as const;\n\n/** Sun/moon horizontal placement for the horizon scenes (synthwave / moonrise / clouds / dune / aurora / forest) — off-center\n * clears the page's centered content. */\nexport const PATTERN_DISCS = [\n \"right\",\n \"center\",\n \"left\",\n] as const;\n\n/** Blowing-sand intensity for the dune scene — \"still\" is a calm desert (no airborne sand at all),\n * distinct from the speed control's \"static\", which only pauses the motion that is already there. */\nexport const PATTERN_SANDS = [\n \"breeze\",\n \"storm\",\n \"still\",\n] as const;\n\nfunction persistBackground(next: BackgroundType) {\n try {\n localStorage.setItem(STORAGE_KEY, next);\n } catch {\n // ignore storage failures (private mode, etc.)\n }\n}\n\nfunction pickRandom(items: readonly T[]): T {\n return items[Math.floor(Math.random() * items.length)];\n}\n\nfunction cycle(items: readonly T[], current: T): T {\n const i = items.indexOf(current);\n return items[(i + 1) % items.length];\n}\n\n/**\n * State that steps through a fixed preset list, wrapping at the end. Returns the value, a stable\n * advance-to-the-next-preset callback, and the raw setter.\n */\nfunction useCycle(\n items: readonly T[],\n initial: T,\n): [\n T,\n () => void,\n React.Dispatch>,\n] {\n const [value, setValue] = React.useState(initial);\n const next = React.useCallback(\n () => setValue((v) => cycle(items, v)),\n [\n items,\n ],\n );\n return [\n value,\n next,\n setValue,\n ];\n}\n\ninterface BackgroundContextValue {\n background: BackgroundType;\n setBackground: (background: BackgroundType) => void;\n /** The ramp axis driving the gradient background. */\n gradientAxis: RampGradientAxis;\n /** Switch to the gradient background and set its ramp axis. */\n setGradientAxis: (axis: RampGradientAxis) => void;\n /** Gradient angle in degrees (90 = left → right). */\n gradientAngle: number;\n /** Rotate the gradient by 45°. */\n cycleGradientAngle: () => void;\n /** The gradient painting shape (linear | radial | conic). */\n gradientShape: GradientShape;\n /** Advance to the next gradient shape. */\n cycleGradientShape: () => void;\n /** Center position for radial + conic gradients (CSS `at `). */\n gradientPosition: string;\n /** Advance to the next center-position preset. */\n cycleGradientPosition: () => void;\n /** Radial shape (circle | ellipse). */\n radialShape: NonNullable;\n /** Toggle the radial shape. */\n cycleRadialShape: () => void;\n /** Radial size keyword. */\n radialSize: NonNullable;\n /** Advance to the next radial size. */\n cycleRadialSize: () => void;\n // ── Canvas ──\n /** The canvas style (gradient | lava | circle). */\n canvasStyle: CanvasStyle;\n /** Switch to the canvas background and set its style. */\n setCanvasStyle: (style: CanvasStyle) => void;\n /** The ramp axis driving the canvas colors. */\n canvasRamp: RampGradientAxis;\n /** Switch to the canvas background and set its ramp axis. */\n setCanvasRamp: (axis: RampGradientAxis) => void;\n /** Steps per side (4–12). */\n canvasSteps: number;\n /** Advance to the next steps-per-side preset. */\n cycleCanvasSteps: () => void;\n /** Canvas gradient angle in degrees. */\n canvasAngle: number;\n /** Rotate the canvas gradient by 45°. */\n cycleCanvasAngle: () => void;\n /** Canvas animation pace. */\n canvasSpeed: number;\n /** Advance to the next animation-pace preset. */\n cycleCanvasSpeed: () => void;\n /** Whether the canvas is currently animating. */\n canvasAnimated: boolean;\n /** Toggle canvas animation on/off. */\n toggleCanvasAnimated: () => void;\n /** Switch to the canvas background and reshuffle it (new style + ramp + layout seed). */\n shuffleCanvas: () => void;\n // ── Pattern (pure-CSS wallpapers) ──\n /** The active CSS pattern style. */\n patternStyle: PatternStyle;\n /** Switch to the pattern background and set its style. */\n setPatternStyle: (style: PatternStyle) => void;\n /** Advance to the next pattern style. */\n cyclePatternStyle: () => void;\n /** Star-field density (sparse | medium | dense). */\n patternDensity: PatternDensity;\n /** Advance to the next star-field density. */\n cyclePatternDensity: () => void;\n /** Horizon-grid scroll pace (synthwave / moonrise); 0 is static. */\n patternSpeed: number;\n /** Advance to the next grid scroll pace (… → static). */\n cyclePatternSpeed: () => void;\n /** Sun/moon placement for the horizon scenes (right | center | left). */\n patternDisc: (typeof PATTERN_DISCS)[number];\n /** Advance to the next sun/moon placement. */\n cyclePatternDisc: () => void;\n /** Blowing-sand intensity for the dune scene (breeze | storm | still). */\n patternSand: (typeof PATTERN_SANDS)[number];\n /** Advance to the next blowing-sand level. */\n cyclePatternSand: () => void;\n // ── Base color (the \"none\" backdrop) ──\n /** Base color for the \"none\" backdrop; null = follow the theme (tint-tinted, light/dark aware). */\n baseColor: string | null;\n /** Override the \"none\" backdrop color; null clears back to the themed default. */\n setBaseColor: (color: string | null) => void;\n}\n\nconst BackgroundContext = React.createContext(null);\n\nexport function useBackground() {\n const context = React.useContext(BackgroundContext);\n if (!context) {\n throw new Error(\"useBackground must be used within a BackgroundProvider\");\n }\n return context;\n}\n\ninterface RenderArgs {\n gradientAxis: RampGradientAxis;\n gradientAngle: number;\n gradientShape: GradientShape;\n gradientPosition: string;\n radialShape: NonNullable;\n radialSize: NonNullable;\n canvasStyle: CanvasStyle;\n canvasRamp: RampGradientAxis;\n canvasSteps: number;\n canvasAngle: number;\n canvasSpeed: number;\n canvasAnimated: boolean;\n canvasSeed: string;\n patternStyle: PatternStyle;\n patternDensity: PatternDensity;\n patternSpeed: number;\n patternDisc: (typeof PATTERN_DISCS)[number];\n patternSand: (typeof PATTERN_SANDS)[number];\n baseColor: string | null;\n}\n\nfunction renderBackground(background: BackgroundType, args: RenderArgs) {\n switch (background) {\n case \"canvas\":\n // key forces a clean redraw when the style/ramp/steps/seed/animation change\n return (\n \n );\n case \"pattern\":\n return (\n \n );\n case \"none\":\n // Forgo the decorative backdrop — a clean solid that honors the theme (tint hue, light/dark) by\n // default, overridable by the user's chosen base color.\n return (\n <>\n \n
\n >\n );\n default:\n return (\n \n );\n }\n}\n\n/**\n * Holds the site-wide background choice (persisted to localStorage) and renders it\n * behind the app. Pair with to preview + tune each style.\n */\nexport function BackgroundProvider({ children }: { children: React.ReactNode }) {\n // SSR + first client render use \"gradient\" so hydration matches; localStorage is read after mount.\n const [background, setBackgroundState] = React.useState(\"pattern\");\n const [gradientAxis, setGradientAxisState] = React.useState(\"tonal\");\n const [gradientAngle, cycleGradientAngle] = useCycle(ANGLES, 90);\n const [gradientShape, cycleGradientShape] = useCycle(GRADIENT_SHAPES, \"linear\");\n const [gradientPosition, cycleGradientPosition] = useCycle(GRADIENT_POSITION_VALUES, \"50% 50%\");\n const [radialShape, cycleRadialShape] = useCycle(RADIAL_SHAPES, \"circle\");\n const [radialSize, cycleRadialSize] = useCycle(RADIAL_SIZES, \"farthest-corner\");\n const [canvasStyle, setCanvasStyleState] = React.useState(\"gradient\");\n const [canvasRamp, setCanvasRampState] = React.useState(\"tonal\");\n const [canvasSteps, cycleCanvasSteps] = useCycle(CANVAS_STEPS, 6);\n const [canvasAngle, cycleCanvasAngle] = useCycle(ANGLES, 90);\n const [canvasSpeed, cycleCanvasSpeed] = useCycle(CANVAS_SPEEDS, 1);\n const [canvasAnimated, setCanvasAnimated] = React.useState(false);\n const [canvasSeed, setCanvasSeed] = React.useState(\"sistine\");\n const [patternStyle, cyclePatternStyleState, setPatternStyleState] = useCycle(PATTERN_STYLES, \"moonrise\");\n const [patternDensity, cyclePatternDensityState] = useCycle(PATTERN_DENSITIES, \"medium\");\n /* Static by DEFAULT (0 = no animation, the last entry in PATTERN_SPEEDS). The pattern layer is a\n backdrop for reading glass against, and a moving one competes with the content for attention on\n first load — motion should be something you opt into, not something you have to switch off. The\n speed control still cycles 2/4/8/12/16/0, so the animation is one click away. */\n const [patternSpeed, cyclePatternSpeedState] = useCycle(PATTERN_SPEEDS, 0);\n const [baseColor, setBaseColor] = React.useState(null);\n const [patternDisc, cyclePatternDiscState] = useCycle(PATTERN_DISCS, \"right\");\n const [patternSand, cyclePatternSandState] = useCycle(PATTERN_SANDS, \"breeze\");\n\n React.useEffect(() => {\n const stored = localStorage.getItem(STORAGE_KEY);\n if (stored === \"gradient\" || stored === \"canvas\" || stored === \"pattern\" || stored === \"none\") {\n setBackgroundState(stored);\n }\n }, []);\n\n /** The coupling every per-style setter/cycler shares: switch to that background type, apply, persist. */\n const withType =\n (type: BackgroundType, fn: (...args: A) => void) =>\n (...args: A) => {\n setBackgroundState(type);\n fn(...args);\n persistBackground(type);\n };\n\n const value: BackgroundContextValue = {\n background,\n setBackground: (next) => {\n setBackgroundState(next);\n persistBackground(next);\n },\n gradientAxis,\n setGradientAxis: withType(\"gradient\", setGradientAxisState),\n gradientAngle,\n cycleGradientAngle,\n gradientShape,\n cycleGradientShape,\n gradientPosition,\n cycleGradientPosition,\n radialShape,\n cycleRadialShape,\n radialSize,\n cycleRadialSize,\n canvasStyle,\n setCanvasStyle: withType(\"canvas\", setCanvasStyleState),\n canvasRamp,\n setCanvasRamp: withType(\"canvas\", setCanvasRampState),\n canvasSteps,\n cycleCanvasSteps,\n canvasAngle,\n cycleCanvasAngle,\n canvasSpeed,\n cycleCanvasSpeed,\n canvasAnimated,\n toggleCanvasAnimated: () => setCanvasAnimated((on) => !on),\n shuffleCanvas: withType(\"canvas\", () => {\n setCanvasStyleState(pickRandom(CANVAS_STYLES));\n setCanvasRampState(pickRandom(RAMP_AXES));\n setCanvasSeed(Math.random().toString(36).slice(2));\n }),\n patternStyle,\n setPatternStyle: withType(\"pattern\", setPatternStyleState),\n cyclePatternStyle: withType(\"pattern\", cyclePatternStyleState),\n patternDensity,\n cyclePatternDensity: withType(\"pattern\", cyclePatternDensityState),\n patternSpeed,\n cyclePatternSpeed: withType(\"pattern\", cyclePatternSpeedState),\n patternDisc,\n cyclePatternDisc: withType(\"pattern\", cyclePatternDiscState),\n patternSand,\n cyclePatternSand: withType(\"pattern\", cyclePatternSandState),\n baseColor,\n setBaseColor,\n };\n\n return (\n \n {renderBackground(background, {\n gradientAxis,\n gradientAngle,\n gradientShape,\n gradientPosition,\n radialShape,\n radialSize,\n canvasStyle,\n canvasRamp,\n canvasSteps,\n canvasAngle,\n canvasSpeed,\n canvasAnimated,\n canvasSeed,\n patternStyle,\n patternDensity,\n patternSpeed,\n patternDisc,\n patternSand,\n baseColor,\n })}\n {children}\n \n );\n}\n",
"type": "registry:component",
"target": "components/background-provider.tsx"
},
diff --git a/public/r/oklch-utils.json b/public/r/oklch-utils.json
index 1edce86c..f5ecf597 100644
--- a/public/r/oklch-utils.json
+++ b/public/r/oklch-utils.json
@@ -8,7 +8,7 @@
"files": [
{
"path": "lib/oklch-utils.ts",
- "content": "/**\n * OKLCH color primitives — parse / format an oklch string and generate symmetric ramps\n * (hue or chroma) around an existing color. Dependency-free; shares the {l, c, h} shape with\n * `GradientColor` in gradient-utils.ts.\n */\n\nexport interface OklchColor {\n /** Lightness, 0–100 (%) */\n l: number;\n /** Chroma, 0–~0.37 */\n c: number;\n /** Hue, 0–360 (degrees) */\n h: number;\n /** Optional alpha, 0–1 */\n alpha?: number;\n}\n\n/**\n * Practical oklch chroma ceiling. Both sRGB and Display-P3 colors stay below ~0.37 (per Evil\n * Martians; confirmed by sweeping every L×hue with the gamut math below — sRGB max ≈ 0.321,\n * P3 max ≈ 0.363). Used as the binary-search bound and the chroma-ramp default range.\n */\nexport const MAX_CHROMA = 0.37;\n\n/** Wrap a hue into [0, 360). */\nexport function wrapHue(h: number): number {\n return ((h % 360) + 360) % 360;\n}\n\n/** Clamp chroma into [0, MAX_CHROMA]. */\nexport function clampChroma(c: number): number {\n return Math.max(0, Math.min(MAX_CHROMA, c));\n}\n\n/** A bare lightness ≤ 1 is the 0–1 form (×100); otherwise it's already 0–100. */\nfunction normalizeLightness(l: number): number {\n return l <= 1 ? l * 100 : l;\n}\n\n/**\n * Parse an `oklch()` string into components. Handles both lightness conventions used in this\n * repo — `oklch(72% 0.05 255)` and `oklch(0.72 0.05 255)` — plus an optional `/ alpha`.\n * Returns null if the string isn't a parseable oklch().\n */\nexport function parseOklch(input: string): OklchColor | null {\n const match = /^\\s*oklch\\(([^)]+)\\)\\s*$/i.exec(input);\n if (!match) return null;\n\n const [coords, alphaPart] = match[1].split(\"/\");\n const parts = coords.trim().split(/\\s+/);\n if (parts.length < 3) return null;\n\n const lRaw = parts[0];\n const l = lRaw.endsWith(\"%\") ? Number.parseFloat(lRaw) : normalizeLightness(Number.parseFloat(lRaw));\n const c = Number.parseFloat(parts[1]);\n const h = Number.parseFloat(parts[2]);\n if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) return null;\n\n const color: OklchColor = {\n l,\n c,\n h,\n };\n if (alphaPart !== undefined) {\n const a = Number.parseFloat(alphaPart.trim());\n if (Number.isFinite(a)) color.alpha = a;\n }\n return color;\n}\n\n/** Format components back into an `oklch()` string (lightness as %). */\nexport function formatOklch(color: OklchColor, alpha?: number): string {\n const a = alpha ?? color.alpha;\n const base = `${color.l.toFixed(1)}% ${color.c.toFixed(3)} ${color.h.toFixed(1)}`;\n return a === undefined ? `oklch(${base})` : `oklch(${base} / ${a})`;\n}\n\n/** Coerce a base argument (string | OklchColor) into components; throws on an unparseable string. */\nfunction toColor(base: OklchColor | string): OklchColor {\n if (typeof base !== \"string\") return base;\n const parsed = parseOklch(base);\n if (!parsed) throw new Error(`oklch-utils: could not parse \"${base}\"`);\n return parsed;\n}\n\n/** Per-side step count, clamped to a sane [3, 12] (rounded). */\nfunction clampCount(count: number): number {\n return Math.max(3, Math.min(12, Math.round(count)));\n}\n\n/**\n * A full-range value ramp: `count` steps each side of `seed`, reaching `min` on the left and\n * `max` on the right, with the seed held at the center index. Step sizes differ per side (the\n * seed is rarely the midpoint), so the whole [min, max] range is always covered — more steps\n * just sample it finer. Returns `2 * count + 1` values, min → seed → max.\n */\nfunction rangeRamp(seed: number, min: number, max: number, count: number): number[] {\n const leftStep = (seed - min) / count;\n const rightStep = (max - seed) / count;\n const out: number[] = [];\n for (let i = count; i >= 1; i--) out.push(seed - leftStep * i);\n out.push(seed);\n for (let i = 1; i <= count; i++) out.push(seed + rightStep * i);\n return out;\n}\n\n/**\n * Hue ramp covering the FULL wheel: `count` steps each side, the seed centered, hues spread evenly\n * around the wheel (step = 360 / (2·count + 1)) and wrapped into [0, 360). Lightness + chroma are\n * held; `count` clamped to [3, 12]. Unlike chroma/lightness, hue is cyclic — 0° ≡ 360° — so the ramp\n * is distributed cyclically instead of running to both endpoints, which keeps the two edge swatches\n * distinct. e.g. seed 120, count 4 → 320, 0, 40, 80, 120, 160, 200, 240, 280.\n */\nexport function hueRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n const n = clampCount(count);\n const seed = wrapHue(color.h);\n const step = 360 / (2 * n + 1);\n const out: OklchColor[] = [];\n for (let k = -n; k <= n; k++) {\n out.push({\n ...color,\n h: wrapHue(seed + k * step),\n });\n }\n return out;\n}\n\n/** Full-wheel hue ramp as CSS oklch strings (the seed sits at the center index). */\nexport function hueRamp(base: OklchColor | string, count: number): string[] {\n return hueRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Hue harmonies ─────────────────────────────────────────────────────────────\n// Rotate the base hue to derive complementary / harmonious colors (lightness + chroma held). oklch\n// hue is perceptually even, so a 180° \"complement\" is a balanced opposite — not the skewed HSL/RGB one.\n\n/** The perceptual complement: same L + C, hue rotated 180° (alpha preserved), gamut-clamped — the\n * 180° case of {@link harmony}. */\nexport function complement(base: OklchColor | string, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n return harmony(\n base,\n [\n 180,\n ],\n gamut,\n )[1];\n}\n\n/**\n * Hue-harmony set: the base plus a color at each rotation `angle` in degrees (same L + C),\n * gamut-clamped. Returns `[base, …rotations]`. Common sets — analogous `[-30, 30]`, triadic\n * `[120, 240]`, split-complement `[150, 210]`, tetradic `[90, 180, 270]`.\n */\nexport function harmony(base: OklchColor | string, angles: number[], gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n const c = toColor(base);\n return [\n clampToGamut(c, gamut),\n ...angles.map((a) =>\n clampToGamut(\n {\n ...c,\n h: wrapHue(c.h + a),\n },\n gamut,\n ),\n ),\n ];\n}\n\n/** Named color-wheel relationships — the JS mirror of the --hue-* tokens in app/globals.css. */\nexport type HarmonicName =\n | \"base\"\n | \"complement\"\n | \"analogous-1\"\n | \"analogous-2\"\n | \"split-1\"\n | \"split-2\"\n | \"triad-1\"\n | \"triad-2\"\n | \"tetrad-1\"\n | \"tetrad-2\"\n | \"tetrad-3\"\n | \"square-1\"\n | \"square-2\"\n | \"square-3\";\n\n/**\n * Hue offsets (degrees) for each color-wheel relationship. MUST stay in lockstep with the `--hue-*`\n * tokens in app/globals.css (`calc(var(--harmony-h) + N)`) so a JS-solved icon/foreground hue lands on\n * the SAME angle as the CSS harmonic swatches. Pair with `harmonicHue()` + `readableForeground()` to get\n * a contrast-solved (APCA/ARC) color at the harmonic angle.\n */\nexport const HARMONIC_OFFSETS: Record = {\n base: 0,\n complement: 180,\n \"analogous-1\": -30,\n \"analogous-2\": 30,\n \"split-1\": 150,\n \"split-2\": 210,\n \"triad-1\": 120,\n \"triad-2\": 240,\n \"tetrad-1\": 60,\n \"tetrad-2\": 180,\n \"tetrad-3\": 240,\n \"square-1\": 90,\n \"square-2\": 180,\n \"square-3\": 270,\n};\n\n/** All harmonic relationship names, in wheel order (for building pickers). */\nexport const HARMONIC_NAMES = Object.keys(HARMONIC_OFFSETS) as HarmonicName[];\n\n/**\n * Rotate an anchor hue by a named color-wheel relationship, wrapped to [0, 360). The anchor is normally\n * the harmony hue (--harmony-h: the content hue, or 0 for the hue-less selenite/moonstone themes). Feed the\n * result to `readableForeground({ hue })` to keep the contrast solve while landing on the harmonic angle.\n */\nexport function harmonicHue(anchorH: number, name: HarmonicName): number {\n return wrapHue(anchorH + HARMONIC_OFFSETS[name]);\n}\n\n/**\n * Chroma ramp covering [0, cap]: `count` steps each side, the seed centered (left → 0, right →\n * cap). Lightness + hue are held; `count` clamped to [3, 12]. `max` defaults to the largest chroma\n * actually DISPLAYABLE for this L+hue in sRGB (≈0.32, varies by L/hue) — so the ramp reaches the\n * visible edge instead of a flat MAX_CHROMA that just clamps on screen. Pass `maxP3Chroma(l, h)` on\n * P3-capable displays for a punchier top end, or any explicit number to sweep a custom range.\n */\nexport function chromaRampColors(base: OklchColor | string, count: number, max?: number): OklchColor[] {\n const color = toColor(base);\n const cap = max ?? maxSrgbChroma(color.l, color.h);\n const seed = Math.max(0, Math.min(cap, color.c));\n return rangeRamp(seed, 0, cap, clampCount(count)).map((c) => ({\n ...color,\n c,\n }));\n}\n\n/** Chroma ramp as CSS oklch strings (the seed sits at the center index). */\nexport function chromaRamp(base: OklchColor | string, count: number, max?: number): string[] {\n return chromaRampColors(base, count, max).map((color) => formatOklch(color));\n}\n\n/**\n * Lightness ramp covering the FULL range [0, 100]: `count` steps each side, the seed centered\n * (left → 0, right → 100). Chroma + hue are held; `count` clamped to [3, 12].\n */\nexport function lightnessRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n return rangeRamp(color.l, 0, 100, clampCount(count)).map((l) => ({\n ...color,\n l,\n }));\n}\n\n/** Full-range lightness ramp as CSS oklch strings (the seed sits at the center index). */\nexport function lightnessRamp(base: OklchColor | string, count: number): string[] {\n return lightnessRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Gamut-aware tonal scale ───────────────────────────────────────────────────\n// A tonal scale (the Radix/Tailwind 1→N pattern) is NOT a chroma shift: lightness eases\n// light→dark while chroma rises with the scale but is capped by the sRGB gamut — so chroma is\n// near-zero in the light tints, peaks in the mid-dark \"brand\" steps, then tapers in the darkest\n// steps (because dark colors can't physically hold as much chroma). The gamut cap is what\n// produces that peak-then-fall curve; a flat chroma shift cannot.\n\n/** OKLCH (l 0–100) → linear sRGB channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearSrgb(\n l: number,\n c: number,\n hDeg: number,\n): [\n number,\n number,\n number,\n] {\n const L = l / 100;\n const hr = (hDeg * Math.PI) / 180;\n const a = c * Math.cos(hr);\n const b = c * Math.sin(hr);\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.291485548 * b;\n const ll = l_ * l_ * l_;\n const mm = m_ * m_ * m_;\n const ss = s_ * s_ * s_;\n return [\n 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss,\n -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss,\n -0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the sRGB gamut. */\nexport function inSrgbGamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearSrgb(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** OKLCH (l 0–100) → linear Display-P3 channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearP3(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n // linear sRGB → linear Display-P3 (same color, wider basis) — valid even for out-of-sRGB values.\n const [sr, sg, sb] = oklchToLinearSrgb(l, c, h);\n return [\n 0.8224621 * sr + 0.177538 * sg,\n 0.0331942 * sr + 0.9668058 * sg,\n 0.0170608 * sr + 0.072374 * sg + 0.9105652 * sb,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the Display-P3 gamut. */\nexport function inP3Gamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearP3(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** Largest in-gamut chroma for (l, h) under the given gamut test, via binary search. */\nfunction maxChromaFor(l: number, h: number, test: (l: number, c: number, h: number) => boolean): number {\n let lo = 0;\n let hi = MAX_CHROMA;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (test(l, mid, h)) lo = mid;\n else hi = mid;\n }\n // Sit 3% inside the boundary. A color exactly at the gamut edge renders fine in Chrome (CSS Color 4\n // hue-preserving gamut mapping) but Safari per-channel-clamps it toward GREY — so edge-chroma\n // foregrounds/icons went grey in Safari while staying tinted in Chrome. This margin keeps every emitted\n // color clearly in-gamut, so both engines show the same tint.\n return lo * 0.97;\n}\n\n/** Largest chroma keeping (l, h) inside the sRGB gamut. */\nexport function maxSrgbChroma(l: number, h: number): number {\n return maxChromaFor(l, h, inSrgbGamut);\n}\n\n/** Largest chroma keeping (l, h) inside the Display-P3 gamut. */\nexport function maxP3Chroma(l: number, h: number): number {\n return maxChromaFor(l, h, inP3Gamut);\n}\n\n/** Reduce a color's chroma until it fits the given gamut (lightness + hue preserved). */\nexport function clampToGamut(color: OklchColor, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n const max = gamut === \"p3\" ? maxP3Chroma(color.l, color.h) : maxSrgbChroma(color.l, color.h);\n return {\n ...color,\n c: Math.min(color.c, max),\n };\n}\n\nexport interface TonalScaleOptions {\n /** Fixed hue, 0–360. */\n hue: number;\n /** Number of steps. Default 12. */\n steps?: number;\n /** Lightness (0–1) at the lightest step. Default 0.98. */\n lightest?: number;\n /** Lightness (0–1) at the darkest step. Default 0.35. */\n darkest?: number;\n /** Peak chroma intent — the scale builds toward this (gamut permitting). Default 0.2. */\n chroma?: number;\n /** Easing exponent for the lightness descent (>1 stays light longer). Default 2.2. */\n lightEase?: number;\n /** Easing exponent for the chroma rise (>1 stays subtle longer). Default 1.1. */\n chromaRise?: number;\n /** Fraction of the in-gamut chroma ceiling to use, for headroom. Default 0.94. */\n gamutFraction?: number;\n /** Cap chroma to a gamut envelope (gives the natural peak + dark falloff). `\"p3\"` allows a\n * punchier peak on wide-gamut displays; `false` disables clamping. Default `\"srgb\"`. */\n gamut?: \"srgb\" | \"p3\" | false;\n}\n\n/**\n * Generate a tonal color scale — a single hue with lightness eased light→dark and chroma that\n * rises with the scale but is capped by the chosen gamut (sRGB by default; pass `gamut: \"p3\"` for a\n * punchier wide-gamut peak). Returns lightest → darkest as OklchColor[].\n * `tonalScaleColors({ hue: 252 })` gives a Radix/Tailwind-shaped 12-step blue scale.\n */\nexport function tonalScaleColors(options: TonalScaleOptions): OklchColor[] {\n const {\n hue,\n steps = 12,\n lightest = 0.98,\n darkest = 0.35,\n chroma = 0.2,\n lightEase = 2.2,\n chromaRise = 1.1,\n gamutFraction = 0.94,\n gamut = \"srgb\",\n } = options;\n\n const ceiling = gamut === \"p3\" ? maxP3Chroma : maxSrgbChroma;\n const n = Math.max(2, Math.round(steps));\n const out: OklchColor[] = [];\n for (let i = 0; i < n; i++) {\n const p = i / (n - 1);\n const l = (lightest - (lightest - darkest) * p ** lightEase) * 100;\n let c = chroma * p ** chromaRise;\n if (gamut) c = Math.min(c, ceiling(l, hue) * gamutFraction);\n out.push({\n l,\n c: clampChroma(c),\n h: hue,\n });\n }\n return out;\n}\n\n/** Which ramp drives a {@link rampGradient}. */\nexport type RampGradientAxis = \"hue\" | \"lightness\" | \"tonal\" | \"chroma\";\n\n/** Gradient painting shape — same color-stop list, different geometry. */\nexport type GradientShape = \"linear\" | \"radial\" | \"conic\";\n\n/** Geometry knobs for {@link wrapGradient}: `angle` (linear direction / conic `from` rotation), `position`\n * (radial + conic center, e.g. `\"50% 50%\"`, `\"top left\"`), and the radial `shape` / `size`. All optional;\n * the defaults reproduce a centered circle / 90° linear (so omitting them is a no-op). */\nexport interface GradientGeometry {\n angle?: number;\n position?: string;\n radialShape?: \"circle\" | \"ellipse\";\n radialSize?: \"closest-side\" | \"closest-corner\" | \"farthest-side\" | \"farthest-corner\";\n}\n\n/** Wrap a `` in the chosen gradient function (all interpolate `in oklch`). Linear uses the\n * angle as its direction; conic as the `from` rotation about `position`; radial paints a `shape`+`size`\n * centered at `position` (angle unused). */\nexport function wrapGradient(shape: GradientShape, stops: string, geom: GradientGeometry = {}): string {\n const { angle = 90, position = \"50% 50%\", radialShape = \"circle\", radialSize = \"farthest-corner\" } = geom;\n switch (shape) {\n case \"radial\":\n return `radial-gradient(${radialShape} ${radialSize} at ${position} in oklch, ${stops})`;\n case \"conic\":\n return `conic-gradient(from ${angle}deg at ${position} in oklch, ${stops})`;\n default:\n return `linear-gradient(${angle}deg in oklch, ${stops})`;\n }\n}\n\n/**\n * Build a CSS `linear-gradient` from one of the ramps, the seed centered as a slightly wider plateau\n * so the theme color anchors the middle; the rest sit in equal-width bands left → right. `count`\n * steps each side (clamped [3,8]). Interpolated `in oklch` for a perceptual blend.\n * e.g. rampGradient(\"tonal\", { l: 62, c: 0.15, h: 250 }, 5).\n */\n/**\n * THE one axis → colors mapping every backdrop shares (CSS gradient background, canvas background,\n * docs demos), seed-centered with `count` steps per side. Single-sourced so an axis can't mean\n * different math in different engines: `tonal` is the seed's own lightness ramp, gamut-clamped\n * (theme-true — the pure-CSS gradient's original definition), NOT the fixed-chroma tonal scale.\n */\nexport function rampAxisColors(axis: RampGradientAxis, seed: OklchColor, count: number, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n switch (axis) {\n case \"hue\":\n return hueRampColors(seed, count);\n case \"lightness\":\n return lightnessRampColors(seed, count);\n case \"chroma\":\n return chromaRampColors(seed, count, gamut === \"p3\" ? maxP3Chroma(seed.l, seed.h) : maxSrgbChroma(seed.l, seed.h));\n default:\n return lightnessRampColors(seed, count).map((color) => clampToGamut(color, gamut));\n }\n}\n\n/**\n * The lightness range a full-bleed BACKDROP may use while text over it stays readable.\n *\n * The ramps below deliberately cover their whole range — that is what makes them useful as swatches.\n * As a wallpaper it is the opposite of what you want: a lightness ramp runs L 0 → 100, so a single\n * solved foreground is legible at one end of the viewport and invisible at the other. This returns\n * the sub-range on the far side of `fg` where |Lc| stays at or above `targetLc`, measured with the\n * same APCA implementation the rest of the theme uses.\n *\n * Default target is 60 — APCA's non-body level. Body copy sits on veiled glass ABOVE the backdrop and\n * clears 75 comfortably from there; 60 is for the headings that sit directly on it. Asking 75 of the\n * raw backdrop would squeeze light mode into L 87–100 and leave nothing to make a gradient out of.\n *\n * |Lc| is monotonic in backdrop lightness on either side of a foreground, so this binary-searches the\n * boundary rather than sweeping.\n */\nexport function readableLightnessBand(\n fg: OklchColor | string,\n seed: OklchColor,\n targetLc = 60,\n headroom = 7,\n): {\n lMin: number;\n lMax: number;\n} {\n const text = toColor(fg);\n const passes = (l: number) =>\n Math.abs(\n apcaContrast(text, {\n ...seed,\n l,\n }),\n ) >= targetLc;\n /* A dark foreground pushes the backdrop light, and vice versa. */\n const backdropGoesLight = text.l < 50;\n let lo = backdropGoesLight ? 0 : 100;\n let hi = backdropGoesLight ? 100 : 0;\n if (!passes(hi)) {\n /* Nothing on this side clears the target (a mid-lightness foreground). Give back the whole\n range rather than an empty one — banding is a readability aid, not a hard gate. */\n return {\n lMin: 0,\n lMax: 100,\n };\n }\n for (let i = 0; i < 12; i++) {\n const mid = (lo + hi) / 2;\n if (passes(mid)) hi = mid;\n else lo = mid;\n }\n /* Stop `headroom` short of the absolute extreme. That end is the HIGH-contrast one, so backing off\n it costs no readability — but L 0 and L 100 are the two lightnesses that hold no chroma at all, so\n running to them washes the tint out of one edge of the wallpaper. Never let it cross the boundary. */\n const edge = Math.ceil(hi);\n return backdropGoesLight\n ? {\n lMin: edge,\n lMax: Math.max(edge + 1, 100 - headroom),\n }\n : {\n lMin: Math.min(Math.floor(hi) - 1, headroom),\n lMax: Math.floor(hi),\n };\n}\n\n/** Remap a ramp's lightnesses into `band`, preserving the ramp's shape. A ramp whose lightness is\n * constant (the hue and chroma axes) is clamped into the band instead of stretched across it.\n * Exported so the canvas backdrop bands the same ramps the CSS gradient does. */\nexport function fitToBand(\n colors: OklchColor[],\n band: {\n lMin: number;\n lMax: number;\n },\n): OklchColor[] {\n const ls = colors.map((c) => c.l);\n const lo = Math.min(...ls);\n const hi = Math.max(...ls);\n const span = hi - lo;\n if (span < 0.001) {\n const l = Math.min(band.lMax, Math.max(band.lMin, lo));\n return colors.map((c) => ({\n ...c,\n l,\n }));\n }\n return colors.map((c) => ({\n ...c,\n l: band.lMin + ((c.l - lo) / span) * (band.lMax - band.lMin),\n }));\n}\n\n/* Frescoes are authored as fixed multi-hue stops at one lightness, so they skip the ramp entirely —\n which also means they skip its banding and its conic loop-close. Apply both here. */\nexport function bandedFrescoStops(\n colors: string[],\n band:\n | {\n lMin: number;\n lMax: number;\n }\n | undefined,\n shape: GradientShape,\n): string {\n let out = colors;\n if (band) {\n out = out.map((css) => {\n const parsed = parseOklch(css);\n if (!parsed) return css;\n return formatOklch({\n ...parsed,\n l: Math.min(band.lMax, Math.max(band.lMin, parsed.l)),\n });\n });\n }\n if (shape === \"conic\" && out.length > 1) {\n const loop = [\n ...out,\n out[0],\n ];\n return loop.map((css, i) => `${css} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`).join(\", \");\n }\n return out.join(\", \");\n}\n\nexport function rampGradient(\n axis: RampGradientAxis,\n seed: OklchColor,\n count: number,\n options: {\n gamut?: \"srgb\" | \"p3\";\n shape?: GradientShape;\n /** Constrain the ramp's lightness to a readable range — see {@link readableLightnessBand}. */\n band?: {\n lMin: number;\n lMax: number;\n };\n } & GradientGeometry = {},\n): string {\n const { gamut = \"srgb\", shape = \"linear\", band, angle, position, radialShape, radialSize } = options;\n const ramped = rampAxisColors(axis, seed, count, gamut);\n const colors = band ? fitToBand(ramped, band) : ramped;\n\n /* A conic gradient wraps: whatever sits at 360° butts straight into 0°. An open ramp therefore\n meets its own opposite end at the twelve-o'clock line and draws a hard seam right through the\n wallpaper — at full range that is white against black. Close the loop instead, and drop the\n center plateau, which reads as a lopsided wedge once the ramp is bent into a circle.\n hue — already cyclic, so repeating the first color at 360° spaces evenly and joins invisibly.\n other — mirror out and back, giving a symmetric sweep that also ends where it started. */\n if (shape === \"conic\") {\n const loop =\n axis === \"hue\"\n ? [\n ...colors,\n colors[0],\n ]\n : [\n ...colors,\n ...colors.slice(0, -1).reverse(),\n ];\n const stops = loop.map((color, i) => `${formatOklch(color)} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`);\n return wrapGradient(shape, stops.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n }\n\n const mid = Math.floor(colors.length / 2);\n const plateau = 7; // half-width (%) of the centered theme-color band\n const leftEnd = 50 - plateau;\n const rightStart = 50 + plateau;\n const parts = colors.map((color, i) => {\n const css = formatOklch(color);\n if (i === mid) return `${css} ${leftEnd}%, ${css} ${rightStart}%`;\n const pos = i < mid ? (i / mid) * leftEnd : rightStart + ((i - mid) / mid) * (100 - rightStart);\n return `${css} ${pos.toFixed(1)}%`;\n });\n return wrapGradient(shape, parts.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n}\n\n// ── APCA contrast (APCA-W3 / ARC) ─────────────────────────────────────────────\n// Inlined port of the APCA-W3 reference (© Andrew Somers / Myndex Research, licensed to the W3/AGWG).\n// The APCA constants below are the LOCKED beta set (0.1.x) and are used UNMODIFIED. APCA/ARC measures\n// readability contrast — it is NOT a means to claim WCAG 2 conformance. Verified to match apca-w3's\n// APCAcontrast() — see scripts/apca-oracle.mjs. Lc is signed: positive = dark text on a light\n// background, negative = light text on a dark one; |Lc| is the perceptual level (~45 large/UI, ~60\n// other content, ~75 body, ~90 fine). Polarity-aware — which WCAG-2's ratio is not — so it fits dark\n// mode + tinted glass far better.\n\n/** OKLCH (l 0–100) → gamma-encoded sRGB channels in [0, 1] (out-of-gamut values are clipped). */\nexport function oklchToSrgb(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n const [lr, lg, lb] = oklchToLinearSrgb(l, c, h);\n const encode = (v: number) => {\n const x = Math.max(0, Math.min(1, v));\n return x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055;\n };\n return [\n encode(lr),\n encode(lg),\n encode(lb),\n ];\n}\n\n/** Gamma-encoded sRGB channels in [0, 1] → OKLCH (l 0–100). Exact inverse of {@link oklchToSrgb} for\n * in-gamut input, so a composite→decompose round trip is lossless. */\nexport function srgbToOklch([r, g, b]: [\n number,\n number,\n number,\n]): OklchColor {\n const decode = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const [lr, lg, lb] = [\n decode(r),\n decode(g),\n decode(b),\n ];\n const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);\n const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);\n const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);\n const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;\n const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;\n const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;\n return {\n l: L * 100,\n c: Math.hypot(a, bb),\n h: wrapHue((Math.atan2(bb, a) * 180) / Math.PI),\n };\n}\n\n/**\n * Composite a stack of translucent OKLCH layers THE WAY THE BROWSER DOES — CSS source-over on\n * gamma-encoded sRGB channels — and report the result in OKLCH.\n *\n * The obvious shortcut is to lerp the OKLCH coordinates instead (`l1·(1−a) + l2·a`, same for chroma).\n * That is a different operation and it is measurably wrong: OKLCH lightness is a cube-root-ish\n * perceptual scale, sRGB is a ~2.2 gamma, and chroma is a POLAR coordinate, so lerping it mixes two\n * hues along a straight line through the chroma plane rather than around it. Measured against the\n * shipped presets, the shortcut overstated light-mode body contrast by up to 2.8 Lc on the veiled\n * surface — enough to hide four presets sitting under the ARC body floor — while on the crystal\n * surface the error CHANGES SIGN by preset (+0.95 carnelian, −2.66 aventurine). That sign flip is why\n * this is a compositing fix and not a fudge factor: no single correction term can be both.\n *\n * `layers` paint bottom-first; `base` is fully opaque. Each layer's alpha is its own (a layer at\n * alpha 1 simply replaces what is under it). Hue is carried through from the top-most layer that\n * actually contributed chroma, so a neutral stack keeps its nominal hue instead of collapsing to 0.\n */\nexport function compositeSurface(\n base: OklchColor,\n layers: Array<\n OklchColor & {\n a: number;\n }\n >,\n): OklchColor {\n let px = oklchToSrgb(base.l, base.c, base.h);\n let hue = base.h;\n for (const layer of layers) {\n const a = Math.min(Math.max(layer.a, 0), 1);\n if (a <= 0) continue;\n const src = oklchToSrgb(layer.l, layer.c, layer.h);\n px = [\n src[0] * a + px[0] * (1 - a),\n src[1] * a + px[1] * (1 - a),\n src[2] * a + px[2] * (1 - a),\n ];\n if (layer.c > 0) hue = layer.h;\n }\n const out = srgbToOklch(px);\n // Below this the hue angle is numerical noise off a near-neutral pixel — keep the stack's nominal\n // hue so callers that tint from the surface (readableForeground, the ramp base) stay on the theme.\n return out.c < 1e-4\n ? {\n ...out,\n h: hue,\n }\n : out;\n}\n\n/** APCA screen luminance (Ys) from gamma-encoded sRGB. */\nfunction apcaLuminance([r, g, b]: [\n number,\n number,\n number,\n]): number {\n return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;\n}\n\n/**\n * APCA lightness contrast (Lc) between a text color and a background color. Signed: positive = dark\n * text on a light bg, negative = light text on a dark bg; use |Lc| for the level (~60 headings,\n * ~75 body text). A dependency-free port of APCA-W3 0.1.9.\n */\nexport function apcaContrast(text: OklchColor | string, bg: OklchColor | string): number {\n const t = toColor(text);\n const b = toColor(bg);\n let txtY = apcaLuminance(oklchToSrgb(t.l, t.c, t.h));\n let bgY = apcaLuminance(oklchToSrgb(b.l, b.c, b.h));\n const blkThrs = 0.022;\n // biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA blkClmp tuning constant, not √2\n const blkClmp = 1.414;\n txtY = txtY > blkThrs ? txtY : txtY + (blkThrs - txtY) ** blkClmp;\n bgY = bgY > blkThrs ? bgY : bgY + (blkThrs - bgY) ** blkClmp;\n if (Math.abs(bgY - txtY) < 0.0005) return 0;\n let lc: number;\n if (bgY > txtY) {\n const sapc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;\n lc = sapc < 0.1 ? 0 : sapc - 0.027;\n } else {\n const sapc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;\n lc = sapc > -0.1 ? 0 : sapc + 0.027;\n }\n return lc * 100;\n}\n\nconst FG_LIGHT: OklchColor = {\n l: 100,\n c: 0,\n h: 0,\n};\nconst FG_DARK: OklchColor = {\n l: 15,\n c: 0,\n h: 0,\n};\n\n/**\n * Pick whichever foreground has the higher APCA contrast on `bg` — by default near-white vs the\n * near-black `--foreground`. The light, perceptual way to choose a readable text/icon color: cheap\n * enough to run on a theme/tint change (microseconds), no per-frame work, no canvas readback.\n */\nexport function pickForeground(bg: OklchColor | string, light: OklchColor = FG_LIGHT, dark: OklchColor = FG_DARK): OklchColor {\n return Math.abs(apcaContrast(light, bg)) >= Math.abs(apcaContrast(dark, bg)) ? light : dark;\n}\n\n/**\n * From `ramp`, prefer colors whose |APCA Lc| on `surface` falls within [floor, ceiling] and sit closest\n * to `target` — so the floor is honored as a MINIMUM (never undershot) and the ceiling caps the spike.\n * Draws a readable foreground from a real palette (a tonal / lightness ramp) instead of a neutral gray,\n * so text keeps the theme's color while hitting its band. If nothing lands in band (ramp too coarse or\n * the surface can't support it), returns the color nearest the band edge.\n */\nexport function pickInBand(\n ramp: OklchColor[],\n surface: OklchColor | string,\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n): OklchColor {\n const scored = ramp.map((c) => ({\n c,\n lc: Math.abs(apcaContrast(c, surface)),\n }));\n const inBand = scored.filter((s) => s.lc >= band.floor && s.lc <= band.ceiling);\n const pool = inBand.length ? inBand : scored;\n const err = (lc: number) => (inBand.length ? Math.abs(lc - band.target) : Math.min(Math.abs(lc - band.floor), Math.abs(lc - band.ceiling)));\n return pool.reduce((best, s) => (err(s.lc) < err(best.lc) ? s : best), pool[0]).c;\n}\n\n/**\n * The effective glass surface color for the active theme + tint — the theme's light/dark floor\n * blended with the tint wash, mirroring the glass-* utilities (the wash sits at a FIXED lightness,\n * 72 light / 58 dark; only hue, chroma and alpha vary). Pair with pickForeground to choose readable\n * text on a tinted glass surface without reading pixels back.\n */\nexport function glassSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const washL = dark ? 58 : 72;\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: washL,\n c: tint.c * 2.5,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/**\n * The effective glass-SOLID surface color — the legible floor body text actually sits on (never sheer\n * glass). The solid floor (neutral `--glass-solid-l`, 99 light / 18 dark) is composited over the base\n * at `solidA` (the `--glass-solid-a` opacity, ~0.3–0.75), then the tint wash on top. Because that floor\n * is a KNOWN surface, banding text against this — rather than the sheer estimate — gives a real Lc.\n */\nexport function glassSolidSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n solidA: number,\n // Wash knobs — pass the LIVE --glass-wash-l / --glass-wash-c-mult when a theme overrides them\n // (moonstone night: 72 / 2), else the defaults mirror the CSS mode values exactly.\n washL: number = dark ? 58 : 72,\n washCMult = 2.5,\n // The SOLIDIFY floor — `glass` paints --glass-solidify (--glass-opaque-bg at --glass-opacity, default\n // 0.7) as its bottom background-IMAGE layer, i.e. above the background-color modeled by solidA and\n // below the wash. Omitting it models a surface far sheerer than what ships: on most themes the opaque\n // floor sits the same side of mid-grey as the page, so the error is a few Lc, but where the two\n // OPPOSE — moonstone night pairs a cream L84.9 floor with an L20 page — the model lands 27 L on the\n // wrong side and picks text of the wrong polarity entirely. Pass { l, c, a } to include it.\n solidify?: {\n l: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const solidL = dark ? 18 : 99;\n // The real paint order, composited in the real space — see compositeSurface for why lerping the\n // OKLCH coordinates instead is not the same operation.\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: solidL,\n c: 0,\n h: tint.h,\n a: solidA,\n },\n ...(solidify\n ? [\n {\n l: solidify.l,\n c: solidify.c,\n h: tint.h,\n a: solidify.a,\n },\n ]\n : []),\n {\n l: washL,\n c: tint.c * washCMult,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/** Options for {@link themeForeground}. */\nexport interface ThemeForegroundOptions {\n /** Which of the ramp generator's axes the text levels follow. */\n palette: \"tonal\" | \"lightness\" | \"hue\" | \"chroma\";\n /** Text level: 0 = the first swatch; each +1 is one step along the ramp. */\n level: number;\n /** Total steps (the ramp generator's count). */\n count: number;\n /** The chosen color the ramp is built from (the ramp generator's base). */\n base: {\n l: number;\n c: number;\n h: number;\n };\n /** Dark theme? Picks which lightness extreme the tonal/lightness ramps start from. */\n dark: boolean;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * Walk one of the ramp generator's ramps — built from the chosen `base` color — mapping ramp steps\n * to text levels. The ramp is **base-centered with `count` steps EITHER SIDE** (so it matches the\n * ramp generator's \"steps each side\"): level 0 = the readable extreme, level `count` = the base\n * (center), level `2·count` = the opposite extreme — `2·count + 1` levels total. The readable half\n * (levels 0..count) is unchanged, so the live --foreground / --muted-foreground cascade (which only\n * reads that half) is unaffected. `palette` picks the axis:\n * - tonal / lightness: level 0 is the readable lightness extreme (white in dark mode, black in\n * light), easing to the base's lightness at `count`, then on to the opposite extreme;\n * - hue: constant L + C — level 0 is base + count steps, ramping through the base and on the\n * other way (e.g. base `oklch(60% 0.15 255)`, count 8 → level 0 `oklch(60% 0.15 64.4)`);\n * - chroma: constant L + hue, chroma 0 → base → the gamut-displayable max.\n * Gamut-clamped.\n */\nexport function themeForeground(options: ThemeForegroundOptions): OklchColor {\n const { palette, level, count, base, dark, gamut = \"srgb\" } = options;\n // Piecewise around the centered base: readable extreme (level 0) → base (level count) → other.\n const span = (readable: number, mid: number, other: number) => {\n if (count <= 0) return mid;\n if (level <= count) return readable + (mid - readable) * (level / count);\n return mid + (other - mid) * Math.min(1, (level - count) / count);\n };\n const readableL = dark ? 100 : 0;\n const otherL = dark ? 0 : 100;\n const chromaCap = gamut === \"p3\" ? maxP3Chroma(base.l, base.h) : maxSrgbChroma(base.l, base.h);\n let color: OklchColor;\n switch (palette) {\n case \"hue\":\n color = {\n l: base.l,\n c: base.c,\n h: wrapHue(base.h + (count - level) * (360 / (2 * count + 1))),\n };\n break;\n case \"chroma\":\n color = {\n l: base.l,\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n break;\n case \"lightness\":\n color = {\n l: span(readableL, base.l, otherL),\n c: base.c,\n h: base.h,\n };\n break;\n default:\n color = {\n l: span(readableL, base.l, otherL),\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n }\n return clampToGamut(color, gamut);\n}\n\n// ── Readable foreground (soft contrast / ARC Bronze Simple Mode) ──────────────\n// pickForeground() maximizes contrast — it slams to the lightness extreme (pure white / near-black),\n// which reads as a glare/heaviness \"spike\". readableForeground() instead AIMS for a target APCA Lc\n// and stops: legible, but soft. The usage presets follow the ARC \"Bronze Simple Mode\" criterion\n// (readtech.org/ARC) — content-text thresholds, no font-lookup table: body Lc 75 (preferred 90),\n// other content Lc 60, large-fluent (>36px) Lc 45 with a MAX of Lc 90 to \"prevent excessive contrast\"\n// (so the ceiling is straight from the spec). floor = legible minimum, target = aim, ceiling = the cap.\n// `ui` / `non-text` / `disabled` are OUR extension — Bronze scopes to content text, excluding spot text.\n\n/** APCA-derived contrast bands per use case. floor = legible minimum, target = aim, ceiling = cap. */\nexport const READABLE_USAGE = {\n /** Fine / thin / small text — needs the most contrast; little soft room. */\n small: {\n floor: 90,\n target: 90,\n ceiling: 100,\n },\n /** Body text (~16px / 400) — ARC Bronze body: min 75, preferred 90. */\n body: {\n floor: 75,\n target: 80,\n ceiling: 90,\n },\n /** Large or fluent body (~18–24px) / semibold. */\n \"body-large\": {\n floor: 60,\n target: 72,\n ceiling: 88,\n },\n /** Headings / other content text — ARC Bronze \"other content\": min 60. */\n heading: {\n floor: 60,\n target: 66,\n ceiling: 82,\n },\n /** Large fluent text >36px (or ≥24px bold) — ARC Bronze: min 45, max 90. */\n large: {\n floor: 45,\n target: 58,\n ceiling: 76,\n },\n /** UI labels, icons, focus rings — beyond ARC Bronze scope (Bronze is content-text only). Target at the\n * APCA \"other content\" level (60); ceiling 70 stays below body's 75 floor, so icons read quieter than text. */\n ui: {\n floor: 45,\n target: 60,\n ceiling: 70,\n },\n /** Non-text: borders, dividers, graphics — beyond ARC Bronze scope. */\n \"non-text\": {\n floor: 30,\n target: 40,\n ceiling: 58,\n },\n /** Disabled / placeholder — beyond ARC Bronze scope (spot text). */\n disabled: {\n floor: 30,\n target: 33,\n ceiling: 46,\n },\n} as const;\n\nexport type ReadableUsage = keyof typeof READABLE_USAGE;\n\n/** Options for {@link readableForeground}. */\nexport interface ReadableForegroundOptions {\n /** Use-case preset that sets the {floor, target, ceiling} band (default \"body\"). */\n usage?: ReadableUsage;\n /** Aim for this |APCA Lc| — overrides the usage preset's target. */\n target?: number;\n /** Never exceed this |Lc| — the anti-spike cap; overrides the usage preset. */\n ceiling?: number;\n /** Legibility minimum; if even the extreme can't reach it you get max contrast (add a scrim). */\n floor?: number;\n /** Hue to keep for tinted-but-readable text. Defaults to the background's hue. */\n hue?: number;\n /** Chroma to keep, gamut-clamped at the chosen lightness. 0 = neutral gray. */\n chroma?: number;\n /** Opt-in chroma floor (capped at `chroma`). When the band pins the pick to the lightness EXTREME\n * (target/floor beyond the surface's reach), the gamut annihilates the requested chroma there and\n * tinted text reads pure black/white; this backs the aim off to the most contrast available at a\n * lightness that still HOLDS this much chroma — never below an ACHIEVABLE band floor. Reachable\n * bands are unaffected. 0 (default) = off. */\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * A foreground that AIMS for a target APCA contrast on `bg` instead of maximizing it — legible\n * without the harsh pure-black / pure-white spike. Picks the lightness direction with headroom and\n * binary-searches the NEAREST lightness whose |Lc| meets the target (capped at the ceiling); keeps an\n * optional hue/chroma for tinted-but-readable text. The soft-contrast companion to {@link pickForeground}.\n * If even the lightness extreme can't reach the floor (a mid-gray background), it returns the maximum\n * contrast available — the signal that the text needs a scrim / solid backing.\n */\nexport function readableForeground(bg: OklchColor | string, opts: ReadableForegroundOptions = {}): OklchColor {\n const band = READABLE_USAGE[opts.usage ?? \"body\"];\n const target = opts.target ?? band.target;\n const ceiling = opts.ceiling ?? band.ceiling;\n const floor = opts.floor ?? band.floor;\n const { chroma = 0, minChroma = 0, gamut = \"srgb\" } = opts;\n\n const b = toColor(bg);\n const h = opts.hue ?? b.h;\n const cAt = (l: number) => Math.min(chroma, gamut === \"p3\" ? maxP3Chroma(l, h) : maxSrgbChroma(l, h));\n const lcAt = (l: number) =>\n Math.abs(\n apcaContrast(\n {\n l,\n c: cAt(l),\n h,\n },\n b,\n ),\n );\n\n const extreme = lcAt(0) >= lcAt(100) ? 0 : 100; // the lightness direction with headroom\n const reach = lcAt(extreme); // the most contrast this background can give\n // Aim for the target, never past the ceiling; if we can't even clear the floor, take the max.\n let want = reach < floor ? reach : Math.min(target, ceiling, reach);\n\n // Chroma preservation (opt-in, minChroma > 0): want === reach means the band sits beyond what this\n // surface can give, so the search below would pin to the lightness EXTREME — where the gamut\n // annihilates the requested chroma and tinted text reads pure black/white (moonstone-dark cream: reach\n // ≈ 68 < body floor 75 → L≈0 → a chosen accent renders BLACK). Trade the few unreachable Lc points\n // for color: aim instead at the most contrast available at a lightness that still HOLDS the kept\n // chroma — never below the band floor when the floor is achievable. In-reach bands never get here.\n const keep = Math.min(minChroma, chroma);\n if (keep > 0 && want >= reach) {\n // Strict in-gamut test at the kept chroma (÷0.97 so the margined cAt still returns ≥ keep). The\n // shared eps-tolerant gamut test reports PHANTOM chroma at L≈0 — cubed near-zero channels sit\n // inside its 1e-4 tolerance (up to ~0.11 at red/pink hues) — which would converge this search\n // right back to the extreme; eps 1e-6 rejects it.\n const holdsKeep = (l: number) => {\n const [r, g, b3] = gamut === \"p3\" ? oklchToLinearP3(l, keep / 0.97, h) : oklchToLinearSrgb(l, keep / 0.97, h);\n const eps = 1e-6;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b3 >= -eps && b3 <= 1 + eps;\n };\n // Max chroma is ~unimodal in lightness and 0 at both extremes: holdsKeep is false at the extreme\n // and (guarded) true at bg.l, so bisect for the holding lightness NEAREST the extreme.\n if (holdsKeep(b.l)) {\n let kLo = extreme;\n let kHi = b.l;\n for (let i = 0; i < 24; i++) {\n const mid = (kLo + kHi) / 2;\n if (holdsKeep(mid)) kHi = mid;\n else kLo = mid;\n }\n want = Math.min(want, Math.max(lcAt(kHi), reach >= floor ? floor : 0));\n }\n }\n\n // |Lc| rises monotonically from bg.l → the extreme, so binary-search the nearest L meeting `want`.\n let lo = b.l;\n let hi = extreme;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (lcAt(mid) < want) lo = mid;\n else hi = mid;\n }\n return clampToGamut(\n {\n l: hi,\n c: cAt(hi),\n h,\n },\n gamut,\n );\n}\n\n// ── Foreground solve ─────────────────────────────────────────────────────────\n// The model AutoForeground applies to every surface, lifted out of the component so the /colors tester\n// and scripts/check-contrast.mjs run the SAME solve instead of each re-deriving it. Every one of them\n// had drifted from the others, and none of the drifts was visible without measuring the emitted token:\n// the tester modelled no solidify floor (3.3 L off), skipped the tonal clip (so it marked the ramp's\n// pure-WHITE end point as the `fine` pick, a colour the component never emits), skipped the\n// show-through margin, seeded the ramp from --glass-tint-h rather than the composited surface hue, and\n// picked from the whole 2·count+1 ramp where the component only ever considers the readable half; the\n// guard measured readableForeground where the page ships a discrete ramp pick, which hid four presets\n// emitting #000000. Duplication was the root cause in all of them, so it is the thing being removed.\n\n/** A tier's contrast band: floor = legible minimum, target = aim, ceiling = anti-spike cap. */\nexport interface ContrastBand {\n floor: number;\n target: number;\n ceiling: number;\n}\n\n/** Lightness below which an sRGB colour renders as black whatever chroma is asked for — the ramp's dark\n * end is clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to\n * #000. 18 sits just above L15 (#130900 at the warm hue, still reading black at text size). */\nexport const TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. */\nexport const TONAL_MAX_L = 97;\n/** How far under the body floor a tonal pick may sit and still be kept over an extreme that does not\n * clear the floor either. 72 Lc and 74.8 Lc are not a difference a reader can use in body text; ink-blue\n * versus #000002 is one they see. */\nexport const TONAL_HUE_SLACK_LC = 3;\n/** Uncertainty margin at a fully-sheer floor, decaying to 0 as the floor becomes exactly modelable. */\nexport const LC_MARGIN = 12;\n/** Baseline aim for a floor that IS modelled exactly, so certainty is not a reason to aim at the bare\n * minimum. Only the opaque set earns it; every other surface earns its margin from uncertainty. */\nexport const LC_AIM_KNOWN = 4;\n\n/** The backdrop's SURVIVING weight through a stack of alphas — every layer painted above attenuates it.\n * This is what the uncertainty margin scales: the more backdrop shows, the less the model can be\n * trusted. Pass each layer's alpha; order is irrelevant. */\nexport function showThrough(...alphas: number[]): number {\n return Math.min(\n Math.max(\n alphas.reduce((acc, a) => acc * (1 - Math.min(Math.max(a, 0), 1)), 1),\n 0,\n ),\n 1,\n );\n}\n\n/** Lift a band's TARGET by the uncertainty boost + the baseline aim. Floors and ceilings never move —\n * the margin aims higher, it never legalizes a harsher pick than the band allowed. */\nexport function boostBand(band: ContrastBand, lcBoost = 0, lcAim = 0): ContrastBand {\n return {\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n };\n}\n\nexport interface ForegroundRampOptions {\n palette: ThemeForegroundOptions[\"palette\"];\n count: number;\n base: OklchColor;\n dark: boolean;\n /** Levels to build. Defaults to the READABLE half (count + 1) — the only part a foreground is ever\n * drawn from. The full 2·count+1 ramp exists for display; picking from it would offer steps on the\n * far side of the base that the surface can never make legible. */\n levels?: number;\n}\n\n/** The ramp a foreground is picked from: the full build, plus the tonally-clipped subset. */\nexport interface ForegroundRamp {\n raw: OklchColor[];\n tonal: OklchColor[];\n}\n\n/**\n * Build the foreground ramp and its tonal clip.\n *\n * Both achromatic ends are dropped on TINTED themes: sRGB has almost no gamut volume there, so those\n * steps are pure black / pure white wearing a theme colour's name (at L0 any requested chroma renders\n * #000000; at L100, #ffffff). pickInBand reaches them whenever a band's target sits above every step —\n * it then picks for maximum contrast, which IS the extreme. NEUTRAL themes (base chroma 0) keep the\n * full ramp, since there black and white are the genuine ends of a grey scale rather than colours that\n * lost their hue.\n */\nexport function foregroundRamp(o: ForegroundRampOptions): ForegroundRamp {\n const levels = o.levels ?? o.count + 1;\n const raw = Array.from(\n {\n length: levels,\n },\n (_, level) =>\n themeForeground({\n palette: o.palette,\n level,\n count: o.count,\n base: o.base,\n dark: o.dark,\n }),\n );\n if (o.base.c <= 0)\n return {\n raw,\n tonal: raw,\n };\n const tonal = raw.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n return {\n raw,\n tonal: tonal.length ? tonal : raw,\n };\n}\n\n/**\n * Pick from the TONAL ramp, but never at the cost of the band's floor.\n *\n * The clip drops the steps that render black or white; if a band is so demanding that only those could\n * satisfy its FLOOR, legibility outranks hue and the full ramp comes back. Guard the FLOOR, not each\n * band's aspiration: the `small` band asks Lc 90, which no light surface reaches at all, so testing\n * against band.floor there would hand the extreme straight back on every light preset.\n *\n * The trade is bounded from the other side too. When the tonal pick is within {@link TONAL_HUE_SLACK_LC}\n * of the floor and the extreme does NOT clear it either, the extreme is only \"less unreadable\" by under\n * that much, and the hue stays: that was body text solving to #000002 on a dark-wash preset (lapis) over\n * a darker backing, where black bought 1.8 Lc and still missed the floor. A fallback that DOES clear the\n * floor always wins, so nothing that was readable becomes less so. And a tonal pick far under the floor\n * (the mid-tone dead zone, ~43 Lc) still hands back the extreme: there the guard's job is only to take\n * whatever contrast is left.\n */\nexport function pickTonalInBand(ramp: ForegroundRamp, surface: OklchColor | string, band: ContrastBand): OklchColor {\n const best = pickInBand(ramp.tonal, surface, band);\n if (ramp.tonal === ramp.raw) return best;\n const lc = (c: OklchColor) => Math.abs(apcaContrast(c, surface));\n if (lc(best) >= READABLE_USAGE.body.floor) return best;\n const full = pickInBand(ramp.raw, surface, band);\n if (lc(best) >= READABLE_USAGE.body.floor - TONAL_HUE_SLACK_LC && lc(full) < READABLE_USAGE.body.floor) return best;\n return lc(full) > lc(best) ? full : best;\n}\n\n/**\n * The ADAPTIVE twin of {@link pickTonalInBand}, for surfaces the ramp cannot serve (they may need a\n * polarity it does not span — a LIGHT opaque card on a dark page needs DARK text).\n *\n * readableForeground aims at the band target and, when the target sits beyond what the surface can\n * give, returns the most contrast AVAILABLE — which is the lightness extreme, exactly where the gamut\n * annihilates the requested chroma. In light mode that is the default rather than an edge case: an L88\n * opaque floor tops out around 81–84 Lc while the opaque tiers aim at target + LC_AIM_KNOWN, so every\n * jewel's `--foreground-opaque` solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc and\n * returns a genuinely tinted ink.\n *\n * `minChroma` is NOT a substitute despite documenting this case: its search bisects toward the\n * BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the\n * dark-peaking arc, so it bails back to the extreme. It stays on for the accent path it was built for.\n */\nexport function readableTonal(\n surface: OklchColor,\n band: ContrastBand,\n opts: {\n hue: number;\n chroma: number;\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n },\n): OklchColor {\n const solved = readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n hue: opts.hue,\n chroma: opts.chroma,\n minChroma: opts.minChroma ?? 0,\n gamut: opts.gamut,\n });\n // Neutral keeps the full range — black and white are the genuine ends of a grey scale there.\n if (opts.chroma <= 0) return solved;\n const l = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L);\n if (l === solved.l) return solved;\n const clipped = clampToGamut(\n {\n l,\n c: opts.chroma,\n h: opts.hue,\n },\n opts.gamut,\n );\n // Guard legibility, never the aspiration — the same line pickTonalInBand holds.\n return Math.abs(apcaContrast(clipped, surface)) >= READABLE_USAGE.body.floor ? clipped : solved;\n}\n",
+ "content": "/**\n * OKLCH color primitives — parse / format an oklch string and generate symmetric ramps\n * (hue or chroma) around an existing color. Dependency-free; shares the {l, c, h} shape with\n * `GradientColor` in gradient-utils.ts.\n */\n\nexport interface OklchColor {\n /** Lightness, 0–100 (%) */\n l: number;\n /** Chroma, 0–~0.37 */\n c: number;\n /** Hue, 0–360 (degrees) */\n h: number;\n /** Optional alpha, 0–1 */\n alpha?: number;\n}\n\n/**\n * Practical oklch chroma ceiling. Both sRGB and Display-P3 colors stay below ~0.37 (per Evil\n * Martians; confirmed by sweeping every L×hue with the gamut math below — sRGB max ≈ 0.321,\n * P3 max ≈ 0.363). Used as the binary-search bound and the chroma-ramp default range.\n */\nexport const MAX_CHROMA = 0.37;\n\n/** Wrap a hue into [0, 360). */\nexport function wrapHue(h: number): number {\n return ((h % 360) + 360) % 360;\n}\n\n/** Clamp chroma into [0, MAX_CHROMA]. */\nexport function clampChroma(c: number): number {\n return Math.max(0, Math.min(MAX_CHROMA, c));\n}\n\n/** A bare lightness ≤ 1 is the 0–1 form (×100); otherwise it's already 0–100. */\nfunction normalizeLightness(l: number): number {\n return l <= 1 ? l * 100 : l;\n}\n\n/**\n * Parse an `oklch()` string into components. Handles both lightness conventions used in this\n * repo — `oklch(72% 0.05 255)` and `oklch(0.72 0.05 255)` — plus an optional `/ alpha`.\n * Returns null if the string isn't a parseable oklch().\n */\nexport function parseOklch(input: string): OklchColor | null {\n const match = /^\\s*oklch\\(([^)]+)\\)\\s*$/i.exec(input);\n if (!match) return null;\n\n const [coords, alphaPart] = match[1].split(\"/\");\n const parts = coords.trim().split(/\\s+/);\n if (parts.length < 3) return null;\n\n const lRaw = parts[0];\n const l = lRaw.endsWith(\"%\") ? Number.parseFloat(lRaw) : normalizeLightness(Number.parseFloat(lRaw));\n const c = Number.parseFloat(parts[1]);\n const h = Number.parseFloat(parts[2]);\n if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) return null;\n\n const color: OklchColor = {\n l,\n c,\n h,\n };\n if (alphaPart !== undefined) {\n const a = Number.parseFloat(alphaPart.trim());\n if (Number.isFinite(a)) color.alpha = a;\n }\n return color;\n}\n\n/** Format components back into an `oklch()` string (lightness as %). */\nexport function formatOklch(color: OklchColor, alpha?: number): string {\n const a = alpha ?? color.alpha;\n const base = `${color.l.toFixed(1)}% ${color.c.toFixed(3)} ${color.h.toFixed(1)}`;\n return a === undefined ? `oklch(${base})` : `oklch(${base} / ${a})`;\n}\n\n/** Coerce a base argument (string | OklchColor) into components; throws on an unparseable string. */\nfunction toColor(base: OklchColor | string): OklchColor {\n if (typeof base !== \"string\") return base;\n const parsed = parseOklch(base);\n if (!parsed) throw new Error(`oklch-utils: could not parse \"${base}\"`);\n return parsed;\n}\n\n/** Per-side step count, clamped to a sane [3, 12] (rounded). */\nfunction clampCount(count: number): number {\n return Math.max(3, Math.min(12, Math.round(count)));\n}\n\n/**\n * A full-range value ramp: `count` steps each side of `seed`, reaching `min` on the left and\n * `max` on the right, with the seed held at the center index. Step sizes differ per side (the\n * seed is rarely the midpoint), so the whole [min, max] range is always covered — more steps\n * just sample it finer. Returns `2 * count + 1` values, min → seed → max.\n */\nfunction rangeRamp(seed: number, min: number, max: number, count: number): number[] {\n const leftStep = (seed - min) / count;\n const rightStep = (max - seed) / count;\n const out: number[] = [];\n for (let i = count; i >= 1; i--) out.push(seed - leftStep * i);\n out.push(seed);\n for (let i = 1; i <= count; i++) out.push(seed + rightStep * i);\n return out;\n}\n\n/**\n * Hue ramp covering the FULL wheel: `count` steps each side, the seed centered, hues spread evenly\n * around the wheel (step = 360 / (2·count + 1)) and wrapped into [0, 360). Lightness + chroma are\n * held; `count` clamped to [3, 12]. Unlike chroma/lightness, hue is cyclic — 0° ≡ 360° — so the ramp\n * is distributed cyclically instead of running to both endpoints, which keeps the two edge swatches\n * distinct. e.g. seed 120, count 4 → 320, 0, 40, 80, 120, 160, 200, 240, 280.\n */\nexport function hueRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n const n = clampCount(count);\n const seed = wrapHue(color.h);\n const step = 360 / (2 * n + 1);\n const out: OklchColor[] = [];\n for (let k = -n; k <= n; k++) {\n out.push({\n ...color,\n h: wrapHue(seed + k * step),\n });\n }\n return out;\n}\n\n/** Full-wheel hue ramp as CSS oklch strings (the seed sits at the center index). */\nexport function hueRamp(base: OklchColor | string, count: number): string[] {\n return hueRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Hue harmonies ─────────────────────────────────────────────────────────────\n// Rotate the base hue to derive complementary / harmonious colors (lightness + chroma held). oklch\n// hue is perceptually even, so a 180° \"complement\" is a balanced opposite — not the skewed HSL/RGB one.\n\n/** The perceptual complement: same L + C, hue rotated 180° (alpha preserved), gamut-clamped — the\n * 180° case of {@link harmony}. */\nexport function complement(base: OklchColor | string, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n return harmony(\n base,\n [\n 180,\n ],\n gamut,\n )[1];\n}\n\n/**\n * Hue-harmony set: the base plus a color at each rotation `angle` in degrees (same L + C),\n * gamut-clamped. Returns `[base, …rotations]`. Common sets — analogous `[-30, 30]`, triadic\n * `[120, 240]`, split-complement `[150, 210]`, tetradic `[90, 180, 270]`.\n */\nexport function harmony(base: OklchColor | string, angles: number[], gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n const c = toColor(base);\n return [\n clampToGamut(c, gamut),\n ...angles.map((a) =>\n clampToGamut(\n {\n ...c,\n h: wrapHue(c.h + a),\n },\n gamut,\n ),\n ),\n ];\n}\n\n/** Named color-wheel relationships — the JS mirror of the --hue-* tokens in app/globals.css. */\nexport type HarmonicName =\n | \"base\"\n | \"complement\"\n | \"analogous-1\"\n | \"analogous-2\"\n | \"split-1\"\n | \"split-2\"\n | \"triad-1\"\n | \"triad-2\"\n | \"tetrad-1\"\n | \"tetrad-2\"\n | \"tetrad-3\"\n | \"square-1\"\n | \"square-2\"\n | \"square-3\";\n\n/**\n * Hue offsets (degrees) for each color-wheel relationship. MUST stay in lockstep with the `--hue-*`\n * tokens in app/globals.css (`calc(var(--harmony-h) + N)`) so a JS-solved icon/foreground hue lands on\n * the SAME angle as the CSS harmonic swatches. Pair with `harmonicHue()` + `readableForeground()` to get\n * a contrast-solved (APCA/ARC) color at the harmonic angle.\n */\nexport const HARMONIC_OFFSETS: Record = {\n base: 0,\n complement: 180,\n \"analogous-1\": -30,\n \"analogous-2\": 30,\n \"split-1\": 150,\n \"split-2\": 210,\n \"triad-1\": 120,\n \"triad-2\": 240,\n \"tetrad-1\": 60,\n \"tetrad-2\": 180,\n \"tetrad-3\": 240,\n \"square-1\": 90,\n \"square-2\": 180,\n \"square-3\": 270,\n};\n\n/** All harmonic relationship names, in wheel order (for building pickers). */\nexport const HARMONIC_NAMES = Object.keys(HARMONIC_OFFSETS) as HarmonicName[];\n\n/**\n * Rotate an anchor hue by a named color-wheel relationship, wrapped to [0, 360). The anchor is normally\n * the harmony hue (--harmony-h: the content hue, or 0 for the hue-less selenite/moonstone themes). Feed the\n * result to `readableForeground({ hue })` to keep the contrast solve while landing on the harmonic angle.\n */\nexport function harmonicHue(anchorH: number, name: HarmonicName): number {\n return wrapHue(anchorH + HARMONIC_OFFSETS[name]);\n}\n\n/**\n * Chroma ramp covering [0, cap]: `count` steps each side, the seed centered (left → 0, right →\n * cap). Lightness + hue are held; `count` clamped to [3, 12]. `max` defaults to the largest chroma\n * actually DISPLAYABLE for this L+hue in sRGB (≈0.32, varies by L/hue) — so the ramp reaches the\n * visible edge instead of a flat MAX_CHROMA that just clamps on screen. Pass `maxP3Chroma(l, h)` on\n * P3-capable displays for a punchier top end, or any explicit number to sweep a custom range.\n */\nexport function chromaRampColors(base: OklchColor | string, count: number, max?: number): OklchColor[] {\n const color = toColor(base);\n const cap = max ?? maxSrgbChroma(color.l, color.h);\n const seed = Math.max(0, Math.min(cap, color.c));\n return rangeRamp(seed, 0, cap, clampCount(count)).map((c) => ({\n ...color,\n c,\n }));\n}\n\n/** Chroma ramp as CSS oklch strings (the seed sits at the center index). */\nexport function chromaRamp(base: OklchColor | string, count: number, max?: number): string[] {\n return chromaRampColors(base, count, max).map((color) => formatOklch(color));\n}\n\n/**\n * Lightness ramp covering the FULL range [0, 100]: `count` steps each side, the seed centered\n * (left → 0, right → 100). Chroma + hue are held; `count` clamped to [3, 12].\n */\nexport function lightnessRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n return rangeRamp(color.l, 0, 100, clampCount(count)).map((l) => ({\n ...color,\n l,\n }));\n}\n\n/** Full-range lightness ramp as CSS oklch strings (the seed sits at the center index). */\nexport function lightnessRamp(base: OklchColor | string, count: number): string[] {\n return lightnessRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Gamut-aware tonal scale ───────────────────────────────────────────────────\n// A tonal scale (the Radix/Tailwind 1→N pattern) is NOT a chroma shift: lightness eases\n// light→dark while chroma rises with the scale but is capped by the sRGB gamut — so chroma is\n// near-zero in the light tints, peaks in the mid-dark \"brand\" steps, then tapers in the darkest\n// steps (because dark colors can't physically hold as much chroma). The gamut cap is what\n// produces that peak-then-fall curve; a flat chroma shift cannot.\n\n/** OKLCH (l 0–100) → linear sRGB channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearSrgb(\n l: number,\n c: number,\n hDeg: number,\n): [\n number,\n number,\n number,\n] {\n const L = l / 100;\n const hr = (hDeg * Math.PI) / 180;\n const a = c * Math.cos(hr);\n const b = c * Math.sin(hr);\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.291485548 * b;\n const ll = l_ * l_ * l_;\n const mm = m_ * m_ * m_;\n const ss = s_ * s_ * s_;\n return [\n 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss,\n -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss,\n -0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the sRGB gamut. */\nexport function inSrgbGamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearSrgb(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** OKLCH (l 0–100) → linear Display-P3 channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearP3(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n // linear sRGB → linear Display-P3 (same color, wider basis) — valid even for out-of-sRGB values.\n const [sr, sg, sb] = oklchToLinearSrgb(l, c, h);\n return [\n 0.8224621 * sr + 0.177538 * sg,\n 0.0331942 * sr + 0.9668058 * sg,\n 0.0170608 * sr + 0.072374 * sg + 0.9105652 * sb,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the Display-P3 gamut. */\nexport function inP3Gamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearP3(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** Largest in-gamut chroma for (l, h) under the given gamut test, via binary search. */\nfunction maxChromaFor(l: number, h: number, test: (l: number, c: number, h: number) => boolean): number {\n let lo = 0;\n let hi = MAX_CHROMA;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (test(l, mid, h)) lo = mid;\n else hi = mid;\n }\n // Sit 3% inside the boundary. A color exactly at the gamut edge renders fine in Chrome (CSS Color 4\n // hue-preserving gamut mapping) but Safari per-channel-clamps it toward GREY — so edge-chroma\n // foregrounds/icons went grey in Safari while staying tinted in Chrome. This margin keeps every emitted\n // color clearly in-gamut, so both engines show the same tint.\n return lo * 0.97;\n}\n\n/** Largest chroma keeping (l, h) inside the sRGB gamut. */\nexport function maxSrgbChroma(l: number, h: number): number {\n return maxChromaFor(l, h, inSrgbGamut);\n}\n\n/** Largest chroma keeping (l, h) inside the Display-P3 gamut. */\nexport function maxP3Chroma(l: number, h: number): number {\n return maxChromaFor(l, h, inP3Gamut);\n}\n\n/** Reduce a color's chroma until it fits the given gamut (lightness + hue preserved). */\nexport function clampToGamut(color: OklchColor, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n const max = gamut === \"p3\" ? maxP3Chroma(color.l, color.h) : maxSrgbChroma(color.l, color.h);\n return {\n ...color,\n c: Math.min(color.c, max),\n };\n}\n\nexport interface TonalScaleOptions {\n /** Fixed hue, 0–360. */\n hue: number;\n /** Number of steps. Default 12. */\n steps?: number;\n /** Lightness (0–1) at the lightest step. Default 0.98. */\n lightest?: number;\n /** Lightness (0–1) at the darkest step. Default 0.35. */\n darkest?: number;\n /** Peak chroma intent — the scale builds toward this (gamut permitting). Default 0.2. */\n chroma?: number;\n /** Easing exponent for the lightness descent (>1 stays light longer). Default 2.2. */\n lightEase?: number;\n /** Easing exponent for the chroma rise (>1 stays subtle longer). Default 1.1. */\n chromaRise?: number;\n /** Fraction of the in-gamut chroma ceiling to use, for headroom. Default 0.94. */\n gamutFraction?: number;\n /** Cap chroma to a gamut envelope (gives the natural peak + dark falloff). `\"p3\"` allows a\n * punchier peak on wide-gamut displays; `false` disables clamping. Default `\"srgb\"`. */\n gamut?: \"srgb\" | \"p3\" | false;\n}\n\n/**\n * Generate a tonal color scale — a single hue with lightness eased light→dark and chroma that\n * rises with the scale but is capped by the chosen gamut (sRGB by default; pass `gamut: \"p3\"` for a\n * punchier wide-gamut peak). Returns lightest → darkest as OklchColor[].\n * `tonalScaleColors({ hue: 252 })` gives a Radix/Tailwind-shaped 12-step blue scale.\n */\nexport function tonalScaleColors(options: TonalScaleOptions): OklchColor[] {\n const {\n hue,\n steps = 12,\n lightest = 0.98,\n darkest = 0.35,\n chroma = 0.2,\n lightEase = 2.2,\n chromaRise = 1.1,\n gamutFraction = 0.94,\n gamut = \"srgb\",\n } = options;\n\n const ceiling = gamut === \"p3\" ? maxP3Chroma : maxSrgbChroma;\n const n = Math.max(2, Math.round(steps));\n const out: OklchColor[] = [];\n for (let i = 0; i < n; i++) {\n const p = i / (n - 1);\n const l = (lightest - (lightest - darkest) * p ** lightEase) * 100;\n let c = chroma * p ** chromaRise;\n if (gamut) c = Math.min(c, ceiling(l, hue) * gamutFraction);\n out.push({\n l,\n c: clampChroma(c),\n h: hue,\n });\n }\n return out;\n}\n\n/** Which ramp drives a {@link rampGradient}. */\nexport type RampGradientAxis = \"hue\" | \"lightness\" | \"tonal\" | \"chroma\";\n\n/** Gradient painting shape — same color-stop list, different geometry. */\nexport type GradientShape = \"linear\" | \"radial\" | \"conic\";\n\n/** Geometry knobs for {@link wrapGradient}: `angle` (linear direction / conic `from` rotation), `position`\n * (radial + conic center, e.g. `\"50% 50%\"`, `\"top left\"`), and the radial `shape` / `size`. All optional;\n * the defaults reproduce a centered circle / 90° linear (so omitting them is a no-op). */\nexport interface GradientGeometry {\n angle?: number;\n position?: string;\n radialShape?: \"circle\" | \"ellipse\";\n radialSize?: \"closest-side\" | \"closest-corner\" | \"farthest-side\" | \"farthest-corner\";\n}\n\n/** Wrap a `` in the chosen gradient function (all interpolate `in oklch`). Linear uses the\n * angle as its direction; conic as the `from` rotation about `position`; radial paints a `shape`+`size`\n * centered at `position` (angle unused). */\nexport function wrapGradient(shape: GradientShape, stops: string, geom: GradientGeometry = {}): string {\n const { angle = 90, position = \"50% 50%\", radialShape = \"circle\", radialSize = \"farthest-corner\" } = geom;\n switch (shape) {\n case \"radial\":\n return `radial-gradient(${radialShape} ${radialSize} at ${position} in oklch, ${stops})`;\n case \"conic\":\n return `conic-gradient(from ${angle}deg at ${position} in oklch, ${stops})`;\n default:\n return `linear-gradient(${angle}deg in oklch, ${stops})`;\n }\n}\n\n/**\n * Build a CSS `linear-gradient` from one of the ramps, the seed centered as a slightly wider plateau\n * so the theme color anchors the middle; the rest sit in equal-width bands left → right. `count`\n * steps each side (clamped [3,8]). Interpolated `in oklch` for a perceptual blend.\n * e.g. rampGradient(\"tonal\", { l: 62, c: 0.15, h: 250 }, 5).\n */\n/**\n * THE one axis → colors mapping every backdrop shares (CSS gradient background, canvas background,\n * docs demos), seed-centered with `count` steps per side. Single-sourced so an axis can't mean\n * different math in different engines: `tonal` is the seed's own lightness ramp, gamut-clamped\n * (theme-true — the pure-CSS gradient's original definition), NOT the fixed-chroma tonal scale.\n */\nexport function rampAxisColors(axis: RampGradientAxis, seed: OklchColor, count: number, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n switch (axis) {\n case \"hue\":\n return hueRampColors(seed, count);\n case \"lightness\":\n return lightnessRampColors(seed, count);\n case \"chroma\":\n return chromaRampColors(seed, count, gamut === \"p3\" ? maxP3Chroma(seed.l, seed.h) : maxSrgbChroma(seed.l, seed.h));\n default:\n return lightnessRampColors(seed, count).map((color) => clampToGamut(color, gamut));\n }\n}\n\n/**\n * The lightness range a full-bleed BACKDROP may use while text over it stays readable.\n *\n * The ramps below deliberately cover their whole range — that is what makes them useful as swatches.\n * As a wallpaper it is the opposite of what you want: a lightness ramp runs L 0 → 100, so a single\n * solved foreground is legible at one end of the viewport and invisible at the other. This returns\n * the sub-range on the far side of `fg` where |Lc| stays at or above `targetLc`, measured with the\n * same APCA implementation the rest of the theme uses.\n *\n * Default target is 60 — APCA's non-body level. Body copy sits on veiled glass ABOVE the backdrop and\n * clears 75 comfortably from there; 60 is for the headings that sit directly on it. Asking 75 of the\n * raw backdrop would squeeze light mode into L 87–100 and leave nothing to make a gradient out of.\n *\n * |Lc| is monotonic in backdrop lightness on either side of a foreground, so this binary-searches the\n * boundary rather than sweeping.\n */\nexport function readableLightnessBand(\n fg: OklchColor | string,\n seed: OklchColor,\n targetLc = 60,\n headroom = 7,\n): {\n lMin: number;\n lMax: number;\n} {\n const text = toColor(fg);\n const passes = (l: number) =>\n Math.abs(\n apcaContrast(text, {\n ...seed,\n l,\n }),\n ) >= targetLc;\n /* A dark foreground pushes the backdrop light, and vice versa. */\n const backdropGoesLight = text.l < 50;\n let lo = backdropGoesLight ? 0 : 100;\n let hi = backdropGoesLight ? 100 : 0;\n if (!passes(hi)) {\n /* Nothing on this side clears the target (a mid-lightness foreground). Give back the whole\n range rather than an empty one — banding is a readability aid, not a hard gate. */\n return {\n lMin: 0,\n lMax: 100,\n };\n }\n for (let i = 0; i < 12; i++) {\n const mid = (lo + hi) / 2;\n if (passes(mid)) hi = mid;\n else lo = mid;\n }\n /* Stop `headroom` short of the absolute extreme. That end is the HIGH-contrast one, so backing off\n it costs no readability — but L 0 and L 100 are the two lightnesses that hold no chroma at all, so\n running to them washes the tint out of one edge of the wallpaper. Never let it cross the boundary. */\n const edge = Math.ceil(hi);\n return backdropGoesLight\n ? {\n lMin: edge,\n lMax: Math.max(edge + 1, 100 - headroom),\n }\n : {\n lMin: Math.min(Math.floor(hi) - 1, headroom),\n lMax: Math.floor(hi),\n };\n}\n\n/** Remap a ramp's lightnesses into `band`, preserving the ramp's shape. A ramp whose lightness is\n * constant (the hue and chroma axes) is clamped into the band instead of stretched across it.\n * Exported so the canvas backdrop bands the same ramps the CSS gradient does. */\nexport function fitToBand(\n colors: OklchColor[],\n band: {\n lMin: number;\n lMax: number;\n },\n): OklchColor[] {\n const ls = colors.map((c) => c.l);\n const lo = Math.min(...ls);\n const hi = Math.max(...ls);\n const span = hi - lo;\n if (span < 0.001) {\n const l = Math.min(band.lMax, Math.max(band.lMin, lo));\n return colors.map((c) => ({\n ...c,\n l,\n }));\n }\n return colors.map((c) => ({\n ...c,\n l: band.lMin + ((c.l - lo) / span) * (band.lMax - band.lMin),\n }));\n}\n\n/* Frescoes are authored as fixed multi-hue stops at one lightness, so they skip the ramp entirely —\n which also means they skip its banding and its conic loop-close. Apply both here. */\nexport function bandedFrescoStops(\n colors: string[],\n band:\n | {\n lMin: number;\n lMax: number;\n }\n | undefined,\n shape: GradientShape,\n): string {\n let out = colors;\n if (band) {\n out = out.map((css) => {\n const parsed = parseOklch(css);\n if (!parsed) return css;\n return formatOklch({\n ...parsed,\n l: Math.min(band.lMax, Math.max(band.lMin, parsed.l)),\n });\n });\n }\n if (shape === \"conic\" && out.length > 1) {\n const loop = [\n ...out,\n out[0],\n ];\n return loop.map((css, i) => `${css} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`).join(\", \");\n }\n return out.join(\", \");\n}\n\nexport function rampGradient(\n axis: RampGradientAxis,\n seed: OklchColor,\n count: number,\n options: {\n gamut?: \"srgb\" | \"p3\";\n shape?: GradientShape;\n /** Constrain the ramp's lightness to a readable range — see {@link readableLightnessBand}. */\n band?: {\n lMin: number;\n lMax: number;\n };\n } & GradientGeometry = {},\n): string {\n const { gamut = \"srgb\", shape = \"linear\", band, angle, position, radialShape, radialSize } = options;\n const ramped = rampAxisColors(axis, seed, count, gamut);\n const colors = band ? fitToBand(ramped, band) : ramped;\n\n /* A conic gradient wraps: whatever sits at 360° butts straight into 0°. An open ramp therefore\n meets its own opposite end at the twelve-o'clock line and draws a hard seam right through the\n wallpaper — at full range that is white against black. Close the loop instead, and drop the\n center plateau, which reads as a lopsided wedge once the ramp is bent into a circle.\n hue — already cyclic, so repeating the first color at 360° spaces evenly and joins invisibly.\n other — mirror out and back, giving a symmetric sweep that also ends where it started. */\n if (shape === \"conic\") {\n const loop =\n axis === \"hue\"\n ? [\n ...colors,\n colors[0],\n ]\n : [\n ...colors,\n ...colors.slice(0, -1).reverse(),\n ];\n const stops = loop.map((color, i) => `${formatOklch(color)} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`);\n return wrapGradient(shape, stops.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n }\n\n const mid = Math.floor(colors.length / 2);\n const plateau = 7; // half-width (%) of the centered theme-color band\n const leftEnd = 50 - plateau;\n const rightStart = 50 + plateau;\n const parts = colors.map((color, i) => {\n const css = formatOklch(color);\n if (i === mid) return `${css} ${leftEnd}%, ${css} ${rightStart}%`;\n const pos = i < mid ? (i / mid) * leftEnd : rightStart + ((i - mid) / mid) * (100 - rightStart);\n return `${css} ${pos.toFixed(1)}%`;\n });\n return wrapGradient(shape, parts.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n}\n\n// ── APCA contrast (APCA-W3 / ARC) ─────────────────────────────────────────────\n// Inlined port of the APCA-W3 reference (© Andrew Somers / Myndex Research, licensed to the W3/AGWG).\n// The APCA constants below are the LOCKED beta set (0.1.x) and are used UNMODIFIED. APCA/ARC measures\n// readability contrast — it is NOT a means to claim WCAG 2 conformance. Verified to match apca-w3's\n// APCAcontrast() — see scripts/apca-oracle.mjs. Lc is signed: positive = dark text on a light\n// background, negative = light text on a dark one; |Lc| is the perceptual level (~45 large/UI, ~60\n// other content, ~75 body, ~90 fine). Polarity-aware — which WCAG-2's ratio is not — so it fits dark\n// mode + tinted glass far better.\n\n/** OKLCH (l 0–100) → gamma-encoded sRGB channels in [0, 1] (out-of-gamut values are clipped). */\nexport function oklchToSrgb(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n const [lr, lg, lb] = oklchToLinearSrgb(l, c, h);\n const encode = (v: number) => {\n const x = Math.max(0, Math.min(1, v));\n return x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055;\n };\n return [\n encode(lr),\n encode(lg),\n encode(lb),\n ];\n}\n\n/** Gamma-encoded sRGB channels in [0, 1] → OKLCH (l 0–100). Exact inverse of {@link oklchToSrgb} for\n * in-gamut input, so a composite→decompose round trip is lossless. */\nexport function srgbToOklch([r, g, b]: [\n number,\n number,\n number,\n]): OklchColor {\n const decode = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const [lr, lg, lb] = [\n decode(r),\n decode(g),\n decode(b),\n ];\n const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);\n const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);\n const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);\n const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;\n const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;\n const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;\n return {\n l: L * 100,\n c: Math.hypot(a, bb),\n h: wrapHue((Math.atan2(bb, a) * 180) / Math.PI),\n };\n}\n\n/**\n * Composite a stack of translucent OKLCH layers THE WAY THE BROWSER DOES — CSS source-over on\n * gamma-encoded sRGB channels — and report the result in OKLCH.\n *\n * The obvious shortcut is to lerp the OKLCH coordinates instead (`l1·(1−a) + l2·a`, same for chroma).\n * That is a different operation and it is measurably wrong: OKLCH lightness is a cube-root-ish\n * perceptual scale, sRGB is a ~2.2 gamma, and chroma is a POLAR coordinate, so lerping it mixes two\n * hues along a straight line through the chroma plane rather than around it. Measured against the\n * shipped presets, the shortcut overstated light-mode body contrast by up to 2.8 Lc on the veiled\n * surface — enough to hide four presets sitting under the ARC body floor — while on the crystal\n * surface the error CHANGES SIGN by preset (+0.95 carnelian, −2.66 aventurine). That sign flip is why\n * this is a compositing fix and not a fudge factor: no single correction term can be both.\n *\n * `layers` paint bottom-first; `base` is fully opaque. Each layer's alpha is its own (a layer at\n * alpha 1 simply replaces what is under it). Hue is carried through from the top-most layer that\n * actually contributed chroma, so a neutral stack keeps its nominal hue instead of collapsing to 0.\n */\nexport function compositeSurface(\n base: OklchColor,\n layers: Array<\n OklchColor & {\n a: number;\n }\n >,\n): OklchColor {\n let px = oklchToSrgb(base.l, base.c, base.h);\n let hue = base.h;\n for (const layer of layers) {\n const a = Math.min(Math.max(layer.a, 0), 1);\n if (a <= 0) continue;\n const src = oklchToSrgb(layer.l, layer.c, layer.h);\n px = [\n src[0] * a + px[0] * (1 - a),\n src[1] * a + px[1] * (1 - a),\n src[2] * a + px[2] * (1 - a),\n ];\n if (layer.c > 0) hue = layer.h;\n }\n const out = srgbToOklch(px);\n // Below this the hue angle is numerical noise off a near-neutral pixel — keep the stack's nominal\n // hue so callers that tint from the surface (readableForeground, the ramp base) stay on the theme.\n return out.c < 1e-4\n ? {\n ...out,\n h: hue,\n }\n : out;\n}\n\n/** APCA screen luminance (Ys) from gamma-encoded sRGB. */\nfunction apcaLuminance([r, g, b]: [\n number,\n number,\n number,\n]): number {\n return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;\n}\n\n/**\n * The colour stops of a fresco gradient (`linear-gradient(135deg in oklch, oklch(85% 0.1 222 / 0.32) 0%, …)`)\n * as compositable layers. Reads the computed value of `--glass-crystal-fresco`, which the four fresco presets\n * point at their multi-hue sheet and every other preset leaves unset (empty string → no stops).\n */\nexport function parseGradientStops(gradient: string): Array<\n OklchColor & {\n a: number;\n }\n> {\n const out: Array<\n OklchColor & {\n a: number;\n }\n > = [];\n for (const m of gradient.matchAll(/oklch\\(\\s*([\\d.]+)(%?)\\s+([\\d.]+)\\s+([\\d.]+)\\s*(?:\\/\\s*([\\d.]+)(%?))?\\s*\\)/g)) {\n out.push({\n l: m[2] ? Number(m[1]) : Number(m[1]) * 100,\n c: Number(m[3]),\n h: Number(m[4]),\n a: m[5] === undefined ? 1 : m[6] ? Number(m[5]) / 100 : Number(m[5]),\n });\n }\n return out;\n}\n\n/**\n * The hardest place on a surface to read text, once a fresco's multi-hue gradient is painted over it.\n *\n * A fresco sheet is bold (stops at L78–89, chroma up to 0.2, alpha 0.2–0.46), and nothing modelled it:\n * every material banded its text against the plain surface, so text sized for the average dipped to\n * 66–74 Lc where a gradient stop pulled the surface toward it. A linear gradient's extremes are its\n * stops, so the worst point is the surface with one stop composited over it. Which stop is worst depends\n * on the text's polarity, so that is decided first, by which of black or white reaches further on the\n * plain surface. Returns the plain surface when there are no stops.\n */\nexport function limitingSurface(\n surface: OklchColor,\n stops: Array<\n OklchColor & {\n a: number;\n }\n >,\n): OklchColor {\n if (stops.length === 0) return surface;\n const black = {\n l: 0,\n c: 0,\n h: 0,\n };\n const white = {\n l: 100,\n c: 0,\n h: 0,\n };\n const ink = Math.abs(apcaContrast(black, surface)) >= Math.abs(apcaContrast(white, surface)) ? black : white;\n let worst = surface;\n let worstLc = Math.abs(apcaContrast(ink, surface));\n for (const stop of stops) {\n const lit = compositeSurface(surface, [\n stop,\n ]);\n const lc = Math.abs(apcaContrast(ink, lit));\n if (lc < worstLc) {\n worst = lit;\n worstLc = lc;\n }\n }\n return worst;\n}\n\n/**\n * APCA lightness contrast (Lc) between a text color and a background color. Signed: positive = dark\n * text on a light bg, negative = light text on a dark bg; use |Lc| for the level (~60 headings,\n * ~75 body text). A dependency-free port of APCA-W3 0.1.9.\n */\nexport function apcaContrast(text: OklchColor | string, bg: OklchColor | string): number {\n const t = toColor(text);\n const b = toColor(bg);\n let txtY = apcaLuminance(oklchToSrgb(t.l, t.c, t.h));\n let bgY = apcaLuminance(oklchToSrgb(b.l, b.c, b.h));\n const blkThrs = 0.022;\n // biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA blkClmp tuning constant, not √2\n const blkClmp = 1.414;\n txtY = txtY > blkThrs ? txtY : txtY + (blkThrs - txtY) ** blkClmp;\n bgY = bgY > blkThrs ? bgY : bgY + (blkThrs - bgY) ** blkClmp;\n if (Math.abs(bgY - txtY) < 0.0005) return 0;\n let lc: number;\n if (bgY > txtY) {\n const sapc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;\n lc = sapc < 0.1 ? 0 : sapc - 0.027;\n } else {\n const sapc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;\n lc = sapc > -0.1 ? 0 : sapc + 0.027;\n }\n return lc * 100;\n}\n\nconst FG_LIGHT: OklchColor = {\n l: 100,\n c: 0,\n h: 0,\n};\nconst FG_DARK: OklchColor = {\n l: 15,\n c: 0,\n h: 0,\n};\n\n/**\n * Pick whichever foreground has the higher APCA contrast on `bg` — by default near-white vs the\n * near-black `--foreground`. The light, perceptual way to choose a readable text/icon color: cheap\n * enough to run on a theme/tint change (microseconds), no per-frame work, no canvas readback.\n */\nexport function pickForeground(bg: OklchColor | string, light: OklchColor = FG_LIGHT, dark: OklchColor = FG_DARK): OklchColor {\n return Math.abs(apcaContrast(light, bg)) >= Math.abs(apcaContrast(dark, bg)) ? light : dark;\n}\n\n/**\n * From `ramp`, prefer colors whose |APCA Lc| on `surface` falls within [floor, ceiling] and sit closest\n * to `target` — so the floor is honored as a MINIMUM (never undershot) and the ceiling caps the spike.\n * Draws a readable foreground from a real palette (a tonal / lightness ramp) instead of a neutral gray,\n * so text keeps the theme's color while hitting its band. If nothing lands in band (ramp too coarse or\n * the surface can't support it), returns the color nearest the band edge.\n */\nexport function pickInBand(\n ramp: OklchColor[],\n surface: OklchColor | string,\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n): OklchColor {\n const scored = ramp.map((c) => ({\n c,\n lc: Math.abs(apcaContrast(c, surface)),\n }));\n const inBand = scored.filter((s) => s.lc >= band.floor && s.lc <= band.ceiling);\n const pool = inBand.length ? inBand : scored;\n const err = (lc: number) => (inBand.length ? Math.abs(lc - band.target) : Math.min(Math.abs(lc - band.floor), Math.abs(lc - band.ceiling)));\n return pool.reduce((best, s) => (err(s.lc) < err(best.lc) ? s : best), pool[0]).c;\n}\n\n/**\n * The effective glass surface color for the active theme + tint — the theme's light/dark floor\n * blended with the tint wash, mirroring the glass-* utilities (the wash sits at a FIXED lightness,\n * 72 light / 58 dark; only hue, chroma and alpha vary). Pair with pickForeground to choose readable\n * text on a tinted glass surface without reading pixels back.\n */\nexport function glassSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const washL = dark ? 58 : 72;\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: washL,\n c: tint.c * 2.5,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/**\n * The effective glass-SOLID surface color — the legible floor body text actually sits on (never sheer\n * glass). The solid floor (neutral `--glass-solid-l`, 99 light / 18 dark) is composited over the base\n * at `solidA` (the `--glass-solid-a` opacity, ~0.3–0.75), then the tint wash on top. Because that floor\n * is a KNOWN surface, banding text against this — rather than the sheer estimate — gives a real Lc.\n */\nexport function glassSolidSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n solidA: number,\n // Wash knobs — pass the LIVE --glass-wash-l / --glass-wash-c-mult when a theme overrides them\n // (moonstone night: 72 / 2), else the defaults mirror the CSS mode values exactly.\n washL: number = dark ? 58 : 72,\n washCMult = 2.5,\n // The SOLIDIFY floor — `glass` paints --glass-solidify (--glass-opaque-bg at --glass-opacity, default\n // 0.7) as its bottom background-IMAGE layer, i.e. above the background-color modeled by solidA and\n // below the wash. Omitting it models a surface far sheerer than what ships: on most themes the opaque\n // floor sits the same side of mid-grey as the page, so the error is a few Lc, but where the two\n // OPPOSE — moonstone night pairs a cream L84.9 floor with an L20 page — the model lands 27 L on the\n // wrong side and picks text of the wrong polarity entirely. Pass { l, c, a } to include it.\n solidify?: {\n l: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const solidL = dark ? 18 : 99;\n // The real paint order, composited in the real space — see compositeSurface for why lerping the\n // OKLCH coordinates instead is not the same operation.\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: solidL,\n c: 0,\n h: tint.h,\n a: solidA,\n },\n ...(solidify\n ? [\n {\n l: solidify.l,\n c: solidify.c,\n h: tint.h,\n a: solidify.a,\n },\n ]\n : []),\n {\n l: washL,\n c: tint.c * washCMult,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/** Options for {@link themeForeground}. */\nexport interface ThemeForegroundOptions {\n /** Which of the ramp generator's axes the text levels follow. */\n palette: \"tonal\" | \"lightness\" | \"hue\" | \"chroma\";\n /** Text level: 0 = the first swatch; each +1 is one step along the ramp. */\n level: number;\n /** Total steps (the ramp generator's count). */\n count: number;\n /** The chosen color the ramp is built from (the ramp generator's base). */\n base: {\n l: number;\n c: number;\n h: number;\n };\n /** Dark theme? Picks which lightness extreme the tonal/lightness ramps start from. */\n dark: boolean;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * Walk one of the ramp generator's ramps — built from the chosen `base` color — mapping ramp steps\n * to text levels. The ramp is **base-centered with `count` steps EITHER SIDE** (so it matches the\n * ramp generator's \"steps each side\"): level 0 = the readable extreme, level `count` = the base\n * (center), level `2·count` = the opposite extreme — `2·count + 1` levels total. The readable half\n * (levels 0..count) is unchanged, so the live --foreground / --muted-foreground cascade (which only\n * reads that half) is unaffected. `palette` picks the axis:\n * - tonal / lightness: level 0 is the readable lightness extreme (white in dark mode, black in\n * light), easing to the base's lightness at `count`, then on to the opposite extreme;\n * - hue: constant L + C — level 0 is base + count steps, ramping through the base and on the\n * other way (e.g. base `oklch(60% 0.15 255)`, count 8 → level 0 `oklch(60% 0.15 64.4)`);\n * - chroma: constant L + hue, chroma 0 → base → the gamut-displayable max.\n * Gamut-clamped.\n */\nexport function themeForeground(options: ThemeForegroundOptions): OklchColor {\n const { palette, level, count, base, dark, gamut = \"srgb\" } = options;\n // Piecewise around the centered base: readable extreme (level 0) → base (level count) → other.\n const span = (readable: number, mid: number, other: number) => {\n if (count <= 0) return mid;\n if (level <= count) return readable + (mid - readable) * (level / count);\n return mid + (other - mid) * Math.min(1, (level - count) / count);\n };\n const readableL = dark ? 100 : 0;\n const otherL = dark ? 0 : 100;\n const chromaCap = gamut === \"p3\" ? maxP3Chroma(base.l, base.h) : maxSrgbChroma(base.l, base.h);\n let color: OklchColor;\n switch (palette) {\n case \"hue\":\n color = {\n l: base.l,\n c: base.c,\n h: wrapHue(base.h + (count - level) * (360 / (2 * count + 1))),\n };\n break;\n case \"chroma\":\n color = {\n l: base.l,\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n break;\n case \"lightness\":\n color = {\n l: span(readableL, base.l, otherL),\n c: base.c,\n h: base.h,\n };\n break;\n default:\n color = {\n l: span(readableL, base.l, otherL),\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n }\n return clampToGamut(color, gamut);\n}\n\n// ── Readable foreground (soft contrast / ARC Bronze Simple Mode) ──────────────\n// pickForeground() maximizes contrast — it slams to the lightness extreme (pure white / near-black),\n// which reads as a glare/heaviness \"spike\". readableForeground() instead AIMS for a target APCA Lc\n// and stops: legible, but soft. The usage presets follow the ARC \"Bronze Simple Mode\" criterion\n// (readtech.org/ARC) — content-text thresholds, no font-lookup table: body Lc 75 (preferred 90),\n// other content Lc 60, large-fluent (>36px) Lc 45 with a MAX of Lc 90 to \"prevent excessive contrast\"\n// (so the ceiling is straight from the spec). floor = legible minimum, target = aim, ceiling = the cap.\n// `ui` / `non-text` / `disabled` are OUR extension — Bronze scopes to content text, excluding spot text.\n\n/** APCA-derived contrast bands per use case. floor = legible minimum, target = aim, ceiling = cap. */\nexport const READABLE_USAGE = {\n /** Fine / thin / small text — needs the most contrast; little soft room. */\n small: {\n floor: 90,\n target: 90,\n ceiling: 100,\n },\n /** Body text (~16px / 400) — ARC Bronze body: min 75, preferred 90. */\n body: {\n floor: 75,\n target: 80,\n ceiling: 90,\n },\n /** Large or fluent body (~18–24px) / semibold. */\n \"body-large\": {\n floor: 60,\n target: 72,\n ceiling: 88,\n },\n /** Headings / other content text — ARC Bronze \"other content\": min 60. */\n heading: {\n floor: 60,\n target: 66,\n ceiling: 82,\n },\n /** Large fluent text >36px (or ≥24px bold) — ARC Bronze: min 45, max 90. */\n large: {\n floor: 45,\n target: 58,\n ceiling: 76,\n },\n /** UI labels, icons, focus rings — beyond ARC Bronze scope (Bronze is content-text only). Target at the\n * APCA \"other content\" level (60); ceiling 70 stays below body's 75 floor, so icons read quieter than text. */\n ui: {\n floor: 45,\n target: 60,\n ceiling: 70,\n },\n /** Non-text: borders, dividers, graphics — beyond ARC Bronze scope. */\n \"non-text\": {\n floor: 30,\n target: 40,\n ceiling: 58,\n },\n /** Disabled / placeholder — beyond ARC Bronze scope (spot text). */\n disabled: {\n floor: 30,\n target: 33,\n ceiling: 46,\n },\n} as const;\n\nexport type ReadableUsage = keyof typeof READABLE_USAGE;\n\n/** Options for {@link readableForeground}. */\nexport interface ReadableForegroundOptions {\n /** Use-case preset that sets the {floor, target, ceiling} band (default \"body\"). */\n usage?: ReadableUsage;\n /** Aim for this |APCA Lc| — overrides the usage preset's target. */\n target?: number;\n /** Never exceed this |Lc| — the anti-spike cap; overrides the usage preset. */\n ceiling?: number;\n /** Legibility minimum; if even the extreme can't reach it you get max contrast (add a scrim). */\n floor?: number;\n /** Hue to keep for tinted-but-readable text. Defaults to the background's hue. */\n hue?: number;\n /** Chroma to keep, gamut-clamped at the chosen lightness. 0 = neutral gray. */\n chroma?: number;\n /** Opt-in chroma floor (capped at `chroma`). When the band pins the pick to the lightness EXTREME\n * (target/floor beyond the surface's reach), the gamut annihilates the requested chroma there and\n * tinted text reads pure black/white; this backs the aim off to the most contrast available at a\n * lightness that still HOLDS this much chroma — never below an ACHIEVABLE band floor. Reachable\n * bands are unaffected. 0 (default) = off. */\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * A foreground that AIMS for a target APCA contrast on `bg` instead of maximizing it — legible\n * without the harsh pure-black / pure-white spike. Picks the lightness direction with headroom and\n * binary-searches the NEAREST lightness whose |Lc| meets the target (capped at the ceiling); keeps an\n * optional hue/chroma for tinted-but-readable text. The soft-contrast companion to {@link pickForeground}.\n * If even the lightness extreme can't reach the floor (a mid-gray background), it returns the maximum\n * contrast available — the signal that the text needs a scrim / solid backing.\n */\nexport function readableForeground(bg: OklchColor | string, opts: ReadableForegroundOptions = {}): OklchColor {\n const band = READABLE_USAGE[opts.usage ?? \"body\"];\n const target = opts.target ?? band.target;\n const ceiling = opts.ceiling ?? band.ceiling;\n const floor = opts.floor ?? band.floor;\n const { chroma = 0, minChroma = 0, gamut = \"srgb\" } = opts;\n\n const b = toColor(bg);\n const h = opts.hue ?? b.h;\n const cAt = (l: number) => Math.min(chroma, gamut === \"p3\" ? maxP3Chroma(l, h) : maxSrgbChroma(l, h));\n const lcAt = (l: number) =>\n Math.abs(\n apcaContrast(\n {\n l,\n c: cAt(l),\n h,\n },\n b,\n ),\n );\n\n const extreme = lcAt(0) >= lcAt(100) ? 0 : 100; // the lightness direction with headroom\n const reach = lcAt(extreme); // the most contrast this background can give\n // Aim for the target, never past the ceiling; if we can't even clear the floor, take the max.\n let want = reach < floor ? reach : Math.min(target, ceiling, reach);\n\n // Chroma preservation (opt-in, minChroma > 0): want === reach means the band sits beyond what this\n // surface can give, so the search below would pin to the lightness EXTREME — where the gamut\n // annihilates the requested chroma and tinted text reads pure black/white (moonstone-dark cream: reach\n // ≈ 68 < body floor 75 → L≈0 → a chosen accent renders BLACK). Trade the few unreachable Lc points\n // for color: aim instead at the most contrast available at a lightness that still HOLDS the kept\n // chroma — never below the band floor when the floor is achievable. In-reach bands never get here.\n const keep = Math.min(minChroma, chroma);\n if (keep > 0 && want >= reach) {\n // Strict in-gamut test at the kept chroma (÷0.97 so the margined cAt still returns ≥ keep). The\n // shared eps-tolerant gamut test reports PHANTOM chroma at L≈0 — cubed near-zero channels sit\n // inside its 1e-4 tolerance (up to ~0.11 at red/pink hues) — which would converge this search\n // right back to the extreme; eps 1e-6 rejects it.\n const holdsKeep = (l: number) => {\n const [r, g, b3] = gamut === \"p3\" ? oklchToLinearP3(l, keep / 0.97, h) : oklchToLinearSrgb(l, keep / 0.97, h);\n const eps = 1e-6;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b3 >= -eps && b3 <= 1 + eps;\n };\n // Max chroma is ~unimodal in lightness and 0 at both extremes: holdsKeep is false at the extreme\n // and (guarded) true at bg.l, so bisect for the holding lightness NEAREST the extreme.\n if (holdsKeep(b.l)) {\n let kLo = extreme;\n let kHi = b.l;\n for (let i = 0; i < 24; i++) {\n const mid = (kLo + kHi) / 2;\n if (holdsKeep(mid)) kHi = mid;\n else kLo = mid;\n }\n want = Math.min(want, Math.max(lcAt(kHi), reach >= floor ? floor : 0));\n }\n }\n\n // |Lc| rises monotonically from bg.l → the extreme, so binary-search the nearest L meeting `want`.\n let lo = b.l;\n let hi = extreme;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (lcAt(mid) < want) lo = mid;\n else hi = mid;\n }\n return clampToGamut(\n {\n l: hi,\n c: cAt(hi),\n h,\n },\n gamut,\n );\n}\n\n// ── Foreground solve ─────────────────────────────────────────────────────────\n// The model AutoForeground applies to every surface, lifted out of the component so the /colors tester\n// and scripts/check-contrast.mjs run the SAME solve instead of each re-deriving it. Every one of them\n// had drifted from the others, and none of the drifts was visible without measuring the emitted token:\n// the tester modelled no solidify floor (3.3 L off), skipped the tonal clip (so it marked the ramp's\n// pure-WHITE end point as the `fine` pick, a colour the component never emits), skipped the\n// show-through margin, seeded the ramp from --glass-tint-h rather than the composited surface hue, and\n// picked from the whole 2·count+1 ramp where the component only ever considers the readable half; the\n// guard measured readableForeground where the page ships a discrete ramp pick, which hid four presets\n// emitting #000000. Duplication was the root cause in all of them, so it is the thing being removed.\n\n/** A tier's contrast band: floor = legible minimum, target = aim, ceiling = anti-spike cap. */\nexport interface ContrastBand {\n floor: number;\n target: number;\n ceiling: number;\n}\n\n/** Lightness below which an sRGB colour renders as black whatever chroma is asked for — the ramp's dark\n * end is clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to\n * #000. 18 sits just above L15 (#130900 at the warm hue, still reading black at text size). */\nexport const TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. */\nexport const TONAL_MAX_L = 97;\n/** How far under the body floor a tonal pick may sit and still be kept over an extreme that does not\n * clear the floor either. 72 Lc and 74.8 Lc are not a difference a reader can use in body text; ink-blue\n * versus #000002 is one they see. */\nexport const TONAL_HUE_SLACK_LC = 3;\n/** Uncertainty margin at a fully-sheer floor, decaying to 0 as the floor becomes exactly modelable. */\nexport const LC_MARGIN = 12;\n/** Baseline aim for a floor that IS modelled exactly, so certainty is not a reason to aim at the bare\n * minimum. Only the opaque set earns it; every other surface earns its margin from uncertainty. */\nexport const LC_AIM_KNOWN = 4;\n\n/** The backdrop's SURVIVING weight through a stack of alphas — every layer painted above attenuates it.\n * This is what the uncertainty margin scales: the more backdrop shows, the less the model can be\n * trusted. Pass each layer's alpha; order is irrelevant. */\nexport function showThrough(...alphas: number[]): number {\n return Math.min(\n Math.max(\n alphas.reduce((acc, a) => acc * (1 - Math.min(Math.max(a, 0), 1)), 1),\n 0,\n ),\n 1,\n );\n}\n\n/** Lift a band's TARGET by the uncertainty boost + the baseline aim. Floors and ceilings never move —\n * the margin aims higher, it never legalizes a harsher pick than the band allowed. */\nexport function boostBand(band: ContrastBand, lcBoost = 0, lcAim = 0): ContrastBand {\n return {\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n };\n}\n\nexport interface ForegroundRampOptions {\n palette: ThemeForegroundOptions[\"palette\"];\n count: number;\n base: OklchColor;\n dark: boolean;\n /** Levels to build. Defaults to the READABLE half (count + 1) — the only part a foreground is ever\n * drawn from. The full 2·count+1 ramp exists for display; picking from it would offer steps on the\n * far side of the base that the surface can never make legible. */\n levels?: number;\n}\n\n/** The ramp a foreground is picked from: the full build, plus the tonally-clipped subset. */\nexport interface ForegroundRamp {\n raw: OklchColor[];\n tonal: OklchColor[];\n}\n\n/**\n * Build the foreground ramp and its tonal clip.\n *\n * Both achromatic ends are dropped on TINTED themes: sRGB has almost no gamut volume there, so those\n * steps are pure black / pure white wearing a theme colour's name (at L0 any requested chroma renders\n * #000000; at L100, #ffffff). pickInBand reaches them whenever a band's target sits above every step —\n * it then picks for maximum contrast, which IS the extreme. NEUTRAL themes (base chroma 0) keep the\n * full ramp, since there black and white are the genuine ends of a grey scale rather than colours that\n * lost their hue.\n */\nexport function foregroundRamp(o: ForegroundRampOptions): ForegroundRamp {\n const levels = o.levels ?? o.count + 1;\n const raw = Array.from(\n {\n length: levels,\n },\n (_, level) =>\n themeForeground({\n palette: o.palette,\n level,\n count: o.count,\n base: o.base,\n dark: o.dark,\n }),\n );\n if (o.base.c <= 0)\n return {\n raw,\n tonal: raw,\n };\n const tonal = raw.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n return {\n raw,\n tonal: tonal.length ? tonal : raw,\n };\n}\n\n/**\n * Pick from the TONAL ramp, but never at the cost of the band's floor.\n *\n * The clip drops the steps that render black or white; if a band is so demanding that only those could\n * satisfy its FLOOR, legibility outranks hue and the full ramp comes back. Guard the FLOOR, not each\n * band's aspiration: the `small` band asks Lc 90, which no light surface reaches at all, so testing\n * against band.floor there would hand the extreme straight back on every light preset.\n *\n * The trade is bounded from the other side too. When the tonal pick is within {@link TONAL_HUE_SLACK_LC}\n * of the floor and the extreme does NOT clear it either, the extreme is only \"less unreadable\" by under\n * that much, and the hue stays: that was body text solving to #000002 on a dark-wash preset (lapis) over\n * a darker backing, where black bought 1.8 Lc and still missed the floor. A fallback that DOES clear the\n * floor always wins, so nothing that was readable becomes less so. And a tonal pick far under the floor\n * (the mid-tone dead zone, ~43 Lc) still hands back the extreme: there the guard's job is only to take\n * whatever contrast is left.\n */\nexport function pickTonalInBand(ramp: ForegroundRamp, surface: OklchColor | string, band: ContrastBand): OklchColor {\n const best = pickInBand(ramp.tonal, surface, band);\n if (ramp.tonal === ramp.raw) return best;\n const lc = (c: OklchColor) => Math.abs(apcaContrast(c, surface));\n if (lc(best) >= READABLE_USAGE.body.floor) return best;\n const full = pickInBand(ramp.raw, surface, band);\n if (lc(best) >= READABLE_USAGE.body.floor - TONAL_HUE_SLACK_LC && lc(full) < READABLE_USAGE.body.floor) return best;\n return lc(full) > lc(best) ? full : best;\n}\n\n/**\n * The ADAPTIVE twin of {@link pickTonalInBand}, for surfaces the ramp cannot serve (they may need a\n * polarity it does not span — a LIGHT opaque card on a dark page needs DARK text).\n *\n * readableForeground aims at the band target and, when the target sits beyond what the surface can\n * give, returns the most contrast AVAILABLE — which is the lightness extreme, exactly where the gamut\n * annihilates the requested chroma. In light mode that is the default rather than an edge case: an L88\n * opaque floor tops out around 81–84 Lc while the opaque tiers aim at target + LC_AIM_KNOWN, so every\n * jewel's `--foreground-opaque` solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc and\n * returns a genuinely tinted ink.\n *\n * `minChroma` is NOT a substitute despite documenting this case: its search bisects toward the\n * BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the\n * dark-peaking arc, so it bails back to the extreme. It stays on for the accent path it was built for.\n */\nexport function readableTonal(\n surface: OklchColor,\n band: ContrastBand,\n opts: {\n hue: number;\n chroma: number;\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n },\n): OklchColor {\n const solved = readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n hue: opts.hue,\n chroma: opts.chroma,\n minChroma: opts.minChroma ?? 0,\n gamut: opts.gamut,\n });\n // Neutral keeps the full range — black and white are the genuine ends of a grey scale there.\n if (opts.chroma <= 0) return solved;\n const l = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L);\n if (l === solved.l) return solved;\n const clipped = clampToGamut(\n {\n l,\n c: opts.chroma,\n h: opts.hue,\n },\n opts.gamut,\n );\n // Guard legibility, never the aspiration — the same line pickTonalInBand holds.\n return Math.abs(apcaContrast(clipped, surface)) >= READABLE_USAGE.body.floor ? clipped : solved;\n}\n",
"type": "registry:lib",
"target": "lib/oklch-utils.ts"
}
diff --git a/public/r/registry.json b/public/r/registry.json
index 07b66869..33660b8d 100644
--- a/public/r/registry.json
+++ b/public/r/registry.json
@@ -2536,7 +2536,7 @@
"name": "background-controller",
"type": "registry:block",
"title": "Background Controller",
- "description": "Site-wide animated background system — a provider + segmented switcher driving CSS gradient, JS canvas, and pure-CSS pattern backdrops (dots, grid, mesh, starfield, synthwave/moonrise/clouds/dune horizons with a placeable sun or moon, dancing aurora borealis, Pac-Man chase, and more) that recolor with the live glass tint and accent. Exposes density, speed, accent, and a themed \"none\" base-color.",
+ "description": "Site-wide animated background system — a provider + segmented switcher driving CSS gradient, JS canvas, and pure-CSS pattern backdrops (dots, grid, mesh, starfield, synthwave/moonrise/clouds/dune horizons with a placeable sun or moon, a fogbound coastal redwood forest, dancing aurora borealis, Pac-Man chase, and more) that recolor with the live glass tint and accent. Exposes density, speed, accent, and a themed \"none\" base-color.",
"registryDependencies": [
"@sistine/canvas-background-utils",
"@sistine/oklch-utils",
diff --git a/public/r/theme.json b/public/r/theme.json
index 1a0de590..f8439c6d 100644
--- a/public/r/theme.json
+++ b/public/r/theme.json
@@ -10,7 +10,7 @@
"files": [
{
"path": "registry/theme/globals.css",
- "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `bun run registry:check` after editing any partial and commit\n registry/theme + public/r. Site-only CSS (the Geist font mapping) lives in app/site.css, which imports\n this file, so nothing the documentation site defines leaks into the shipped theme.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each material's and page style's shadow\n (they set --srf-shadow; the glow is appended after it) instead of being replaced by it. inherits:false\n keeps a glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* The veil floor, NON-inheriting for the same reason as the glow: `glass-veil` sets it on the element\n and `glass` paints it as that element's background-color. Inherited, a plain glass surface nested in\n a veiled menu, popover, tooltip, select or card painted its PARENT's veil as its own floor (under the\n Opaque page style: flat white in light, black in dark). A child without a veil of its own now falls\n back to its material's --srf-bg-color, as it would anywhere else. */\n@property --veil-floor {\n syntax: \"*\";\n inherits: false;\n}\n/* Its twin for the solidify layer (see @utility glass-veil): a veiled parent must not restore the layer\n on a child that dropped it. */\n@property --veil-solidify {\n syntax: \"*\";\n inherits: false;\n}\n/* The ink border colour (glass-border-ink): an inked panel must not ink the borders of the glass inside it. */\n@property --brd-ink {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"