From 6ea2ff3e55b5fbdf027df4f83eae82b71058b046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sat, 24 Oct 2026 23:04:39 -0300 Subject: [PATCH 01/13] chore: start #560 From c6843d1a1358054ce048dac252a8b28a33018997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sat, 24 Oct 2026 23:15:50 -0300 Subject: [PATCH 02/13] feat: ColorField, SliderField and color utilities; studio state core (#560) --- .../components/ColorField/ColorField.svelte | 223 +++++++++ .../ColorField/ColorField.svelte.test.ts | 51 ++ .../ColorField/ColorFieldHarness.test.svelte | 10 + .../src/lib/components/Slider/Slider.svelte | 5 +- .../components/SliderField/SliderField.svelte | 134 ++++++ .../SliderField/SliderField.svelte.test.ts | 34 ++ .../SliderFieldHarness.test.svelte | 10 + packages/ui/src/lib/i18n/all.ts | 2 + packages/ui/src/lib/i18n/groups/colorField.ts | 16 + packages/ui/src/lib/i18n/groups/slider.ts | 4 + packages/ui/src/lib/index.ts | 17 +- packages/ui/src/lib/theme/contrast.ts | 5 +- packages/ui/src/lib/utils/color.ts | 206 ++++++++ packages/ui/tests/color.test.ts | 106 ++++ packages/ui/tests/theme-studio.test.ts | 196 ++++++++ site/src/components/site/StudioBoot.astro | 21 + site/src/components/theme/apply-site.ts | 94 ++++ site/src/components/theme/engine.ts | 24 + site/src/components/theme/fonts.ts | 215 +++++++++ site/src/components/theme/looks.ts | 198 ++++++++ site/src/components/theme/studio-output.ts | 187 ++++++++ site/src/components/theme/studio-state.ts | 454 ++++++++++++++++++ site/src/layouts/Base.astro | 2 + 23 files changed, 2210 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/lib/components/ColorField/ColorField.svelte create mode 100644 packages/ui/src/lib/components/ColorField/ColorField.svelte.test.ts create mode 100644 packages/ui/src/lib/components/ColorField/ColorFieldHarness.test.svelte create mode 100644 packages/ui/src/lib/components/SliderField/SliderField.svelte create mode 100644 packages/ui/src/lib/components/SliderField/SliderField.svelte.test.ts create mode 100644 packages/ui/src/lib/components/SliderField/SliderFieldHarness.test.svelte create mode 100644 packages/ui/src/lib/i18n/groups/colorField.ts create mode 100644 packages/ui/src/lib/utils/color.ts create mode 100644 packages/ui/tests/color.test.ts create mode 100644 packages/ui/tests/theme-studio.test.ts create mode 100644 site/src/components/site/StudioBoot.astro create mode 100644 site/src/components/theme/apply-site.ts create mode 100644 site/src/components/theme/engine.ts create mode 100644 site/src/components/theme/fonts.ts create mode 100644 site/src/components/theme/looks.ts create mode 100644 site/src/components/theme/studio-output.ts create mode 100644 site/src/components/theme/studio-state.ts diff --git a/packages/ui/src/lib/components/ColorField/ColorField.svelte b/packages/ui/src/lib/components/ColorField/ColorField.svelte new file mode 100644 index 00000000..14a82c52 --- /dev/null +++ b/packages/ui/src/lib/components/ColorField/ColorField.svelte @@ -0,0 +1,223 @@ + + +
+ { + editing = true; + error = ''; + }} + onchange={commit} + onblur={() => { + if (editing && parseColorInput(text)) commit(text); + else if (editing && text.trim() === '') { + editing = false; + text = value; + } + }} + > + {#snippet leading()} + + {/snippet} + {#snippet trailing()} + + {/snippet} + + {#if ratio !== null && level} +

+ {ratio.toFixed(1)}:1 + {passes ? t().colorField.pass(level) : t().colorField.fail(minRatio)} +

+ {/if} +
+ + diff --git a/packages/ui/src/lib/components/ColorField/ColorField.svelte.test.ts b/packages/ui/src/lib/components/ColorField/ColorField.svelte.test.ts new file mode 100644 index 00000000..ee9181cf --- /dev/null +++ b/packages/ui/src/lib/components/ColorField/ColorField.svelte.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import ColorFieldHarness from "./ColorFieldHarness.test.svelte"; + +describe("ColorField", () => { + it("commits a typed oklch() as typed and passes axe", async () => { + const screen = await render(ColorFieldHarness, { against: "#ffffff" }); + const input = screen.getByRole("textbox", { name: "Brand" }); + await input.fill("oklch(0.5 0.2 30)"); + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("oklch(0.5 0.2 30)"); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("rejects an invalid color with an error and never writes it to value", async () => { + const onchange = vi.fn(); + const screen = await render(ColorFieldHarness, { onchange }); + await screen.getByRole("textbox", { name: "Brand" }).fill("nope"); + await userEvent.keyboard("{Enter}"); + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Use #rrggbb, rgb() or oklch()"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("#2b5fd9"); + expect(onchange).not.toHaveBeenCalled(); + }); + + it("shows the contrast ratio, and says so in words when it fails", async () => { + const pass = await render(ColorFieldHarness, { initial: "#595959", against: "#ffffff" }); + await expect.element(pass.getByText("7.0:1", { exact: true })).toBeInTheDocument(); + await expect.element(pass.getByText("Passes AAA")).toBeInTheDocument(); + pass.unmount(); + // 4.48:1 prints as "4.5:1" but is under the threshold: the verdict must follow the real ratio. + const edge = await render(ColorFieldHarness, { initial: "#777777", against: "#ffffff" }); + await expect.element(edge.getByText("Below 4.5:1")).toBeInTheDocument(); + edge.unmount(); + const fail = await render(ColorFieldHarness, { initial: "#cccccc", against: "#ffffff" }); + await expect.element(fail.getByText("Below 4.5:1")).toBeInTheDocument(); + }); + + it("the native picker writes hex and the field follows", async () => { + const screen = await render(ColorFieldHarness); + const native = screen.container.querySelector('input[type="color"]'); + expect(native).not.toBeNull(); + native!.value = "#ff0000"; + native!.dispatchEvent(new Event("input", { bubbles: true })); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("#ff0000"); + await expect.element(screen.getByRole("textbox", { name: "Brand" })).toHaveValue("#ff0000"); + }); +}); diff --git a/packages/ui/src/lib/components/ColorField/ColorFieldHarness.test.svelte b/packages/ui/src/lib/components/ColorField/ColorFieldHarness.test.svelte new file mode 100644 index 00000000..259eab3c --- /dev/null +++ b/packages/ui/src/lib/components/ColorField/ColorFieldHarness.test.svelte @@ -0,0 +1,10 @@ + + + +{value} diff --git a/packages/ui/src/lib/components/Slider/Slider.svelte b/packages/ui/src/lib/components/Slider/Slider.svelte index ec243bb4..e933c2e3 100644 --- a/packages/ui/src/lib/components/Slider/Slider.svelte +++ b/packages/ui/src/lib/components/Slider/Slider.svelte @@ -27,6 +27,8 @@ step?: number; disabled?: boolean; class?: string; + /** Accessible name of the thumb(s). Without it a thumb is announced as "Value 40". A range appends "minimum"/"maximum". */ + label?: string; /** Called on change. Always receives array regardless of single/range mode. */ onchange?: (value: number[]) => void; /** Plays `tick` for each step the user crosses (a cue name replaces it); off under reduced motion. Needs `@codeatlasdev/ui/sound`. */ @@ -41,6 +43,7 @@ step = 1, disabled = false, class: className = '', + label, onchange, sound, }: Props = $props(); @@ -202,7 +205,7 @@ role="slider" tabindex={disabled ? -1 : 0} class="ca-slider__thumb" - aria-label={t().slider.value(v)} + aria-label={label ? (internal.length > 1 ? `${label} ${i === 0 ? t().slider.minimum : t().slider.maximum}` : label) : t().slider.value(v)} aria-valuemin={min} aria-valuemax={max} aria-valuenow={v} diff --git a/packages/ui/src/lib/components/SliderField/SliderField.svelte b/packages/ui/src/lib/components/SliderField/SliderField.svelte new file mode 100644 index 00000000..8c9a7b51 --- /dev/null +++ b/packages/ui/src/lib/components/SliderField/SliderField.svelte @@ -0,0 +1,134 @@ + + +
+
+ {label} + + (editing = true)} + onblur={commit} + onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && commit()} + /> + +
+ set(v[0] ?? value)} /> + {#if description}

{description}

{/if} +
+ + diff --git a/packages/ui/src/lib/components/SliderField/SliderField.svelte.test.ts b/packages/ui/src/lib/components/SliderField/SliderField.svelte.test.ts new file mode 100644 index 00000000..01825d6d --- /dev/null +++ b/packages/ui/src/lib/components/SliderField/SliderField.svelte.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import SliderFieldHarness from "./SliderFieldHarness.test.svelte"; + +describe("SliderField", () => { + it("names the slider and the number field after the label and passes axe", async () => { + const screen = await render(SliderFieldHarness); + await expect.element(screen.getByRole("slider", { name: "Radius" })).toBeInTheDocument(); + await expect.element(screen.getByRole("textbox", { name: "Radius" })).toHaveValue("0.80"); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("a typed value is clamped and snapped to step on Enter", async () => { + const screen = await render(SliderFieldHarness); + const input = screen.getByRole("textbox", { name: "Radius" }); + await input.fill("1.234"); + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("1.25"); + await input.fill("9"); + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("2"); + await expect.element(input).toHaveValue("2.00"); + }); + + it("arrow keys on the slider move by step and update the number", async () => { + const screen = await render(SliderFieldHarness, { initial: 1 }); + screen.getByRole("slider", { name: "Radius" }).element().focus(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("1.05"); + await expect.element(screen.getByRole("textbox", { name: "Radius" })).toHaveValue("1.05"); + }); +}); diff --git a/packages/ui/src/lib/components/SliderField/SliderFieldHarness.test.svelte b/packages/ui/src/lib/components/SliderField/SliderFieldHarness.test.svelte new file mode 100644 index 00000000..cbe1971e --- /dev/null +++ b/packages/ui/src/lib/components/SliderField/SliderFieldHarness.test.svelte @@ -0,0 +1,10 @@ + + + +{value} diff --git a/packages/ui/src/lib/i18n/all.ts b/packages/ui/src/lib/i18n/all.ts index f2708a79..6bde4055 100644 --- a/packages/ui/src/lib/i18n/all.ts +++ b/packages/ui/src/lib/i18n/all.ts @@ -21,6 +21,7 @@ import { charts } from "./groups/charts.js"; import { chat } from "./groups/chat.js"; import { chip } from "./groups/chip.js"; import { codeSnippet } from "./groups/codeSnippet.js"; +import { colorField } from "./groups/colorField.js"; import { combobox } from "./groups/combobox.js"; import { commandPalette } from "./groups/commandPalette.js"; import { common } from "./groups/common.js"; @@ -85,6 +86,7 @@ export const groups = { calendar, chip, codeSnippet, + colorField, combobox, commandPalette, copyButton, diff --git a/packages/ui/src/lib/i18n/groups/colorField.ts b/packages/ui/src/lib/i18n/groups/colorField.ts new file mode 100644 index 00000000..17dfd1d8 --- /dev/null +++ b/packages/ui/src/lib/i18n/groups/colorField.ts @@ -0,0 +1,16 @@ +import { defineGroup } from "../catalog.js"; + +export const colorField = defineGroup("colorField", { + en: { + invalid: "Use #rrggbb, rgb() or oklch()", + pick: (label: string) => `Pick a color for ${label}`, + pass: (level: string) => (level === "fail" ? "" : `Passes ${level}`), + fail: (min: number) => `Below ${min}:1`, + }, + "pt-BR": { + invalid: "Use #rrggbb, rgb() ou oklch()", + pick: (label) => `Escolher cor para ${label}`, + pass: (level) => (level === "fail" ? "" : `Passa ${level}`), + fail: (min) => `Abaixo de ${min}:1`, + }, +}); diff --git a/packages/ui/src/lib/i18n/groups/slider.ts b/packages/ui/src/lib/i18n/groups/slider.ts index d527b86d..96e10900 100644 --- a/packages/ui/src/lib/i18n/groups/slider.ts +++ b/packages/ui/src/lib/i18n/groups/slider.ts @@ -3,8 +3,12 @@ import { defineGroup } from "../catalog.js"; export const slider = defineGroup("slider", { en: { value: (value: number | string) => `Value ${value}`, + minimum: "minimum", + maximum: "maximum", }, "pt-BR": { value: (value) => `Valor ${value}`, + minimum: "mínimo", + maximum: "máximo", }, }); diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts index 29203857..05776286 100644 --- a/packages/ui/src/lib/index.ts +++ b/packages/ui/src/lib/index.ts @@ -75,6 +75,7 @@ export { default as Chip } from "./components/Chip/Chip.svelte"; export type { ChipGroupItem } from "./components/ChipGroup/ChipGroup.svelte"; export { default as ChipGroup } from "./components/ChipGroup/ChipGroup.svelte"; export { default as Collapsible } from "./components/Collapsible/Collapsible.svelte"; +export { default as ColorField } from "./components/ColorField/ColorField.svelte"; export type { ComboboxOption } from "./components/Combobox/Combobox.svelte"; export { default as Combobox } from "./components/Combobox/Combobox.svelte"; export type { CommandResult } from "./components/CommandPalette/CommandPalette.svelte"; @@ -225,6 +226,7 @@ export { default as SiteHeader } from "./components/SiteHeader/SiteHeader.svelte export { default as Skeleton } from "./components/Skeleton/Skeleton.svelte"; export { default as SkipLink } from "./components/SkipLink/SkipLink.svelte"; export { default as Slider } from "./components/Slider/Slider.svelte"; +export { default as SliderField } from "./components/SliderField/SliderField.svelte"; export { default as Spinner } from "./components/Spinner/Spinner.svelte"; export { default as SplashScreen } from "./components/SplashScreen/SplashScreen.svelte"; export { default as Stack } from "./components/Stack/Stack.svelte"; @@ -303,6 +305,8 @@ export { stageInsets } from "./stores/stage-insets.svelte.js"; export type { ToastItem, ToastOptions, ToastVariant } from "./stores/toast.svelte.js"; export { toast } from "./stores/toast.svelte.js"; export { viewport } from "./stores/viewport.svelte.js"; +export type { PresetContrastIssue } from "./theme/contrast.js"; +export { presetContrastIssues } from "./theme/contrast.js"; export type { AvatarTokens, ButtonSizeTokens, @@ -332,7 +336,7 @@ export type { TypeScale, TypographyOptions, } from "./theme/define.js"; -export { defineTheme, designTokens, themeCSS } from "./theme/define.js"; +export { defineTheme, designTokens, presetTokens, themeCSS } from "./theme/define.js"; export type { CaTokenName, CaTokenRegistry, @@ -351,6 +355,17 @@ export { THEME_PRESETS } from "./tokens/presets.js"; export type { HeadingLevel, IconComponent, IconProps } from "./types.js"; export type { Politeness } from "./utils/announce.js"; export { announce } from "./utils/announce.js"; +export type { Oklch, PaletteColor, WcagLevel } from "./utils/color.js"; +export { + contrast, + extractPalette, + formatOklch, + parseColorInput, + pickBrand, + toHex, + toOklch, + wcagLevel, +} from "./utils/color.js"; export type { ClassValue, ExportColumn, diff --git a/packages/ui/src/lib/theme/contrast.ts b/packages/ui/src/lib/theme/contrast.ts index 6d20eb36..a0608805 100644 --- a/packages/ui/src/lib/theme/contrast.ts +++ b/packages/ui/src/lib/theme/contrast.ts @@ -1,6 +1,7 @@ // ─── Build-time contrast findings for hand-set preset roles ────────────────── -// Used by caThemePlugin and caUI only (never by the runtime entries), so the color evaluation it -// needs stays out of the bundles that import `defineTheme` for its defaults (seo/pwa.ts). +// Used by caThemePlugin and caUI at build time, and by theme editors in the browser through the +// package barrel. The runtime entries (seo/pwa.ts) never import it, so the color evaluation stays +// out of the bundles that only need `defineTheme` for its defaults. import type { ParsedColor } from "../utils/css-color.js"; import { compositeOver, evalColor } from "../utils/css-color-mix.js"; diff --git a/packages/ui/src/lib/utils/color.ts b/packages/ui/src/lib/utils/color.ts new file mode 100644 index 00000000..b23409ca --- /dev/null +++ b/packages/ui/src/lib/utils/color.ts @@ -0,0 +1,206 @@ +// ─── Color helpers for pickers and theme tooling ───────────────────────────── +// DOM-free, so they run in Vite, Astro, workers and the browser alike. Everything is built on the +// parser the theme build already trusts (`evalColor`) and the OKLab matrices of `palette-generator`. + +import { oklabToRgb, type ParsedColor } from "./css-color.js"; +import { compositeOver, evalColor } from "./css-color-mix.js"; +import { hexToOklch, oklchToHex } from "./palette-generator.js"; + +export interface Oklch { + /** Lightness, 0 to 1. */ + l: number; + /** Chroma, 0 to ~0.37 inside sRGB. */ + c: number; + /** Hue angle in degrees, 0 to 360. */ + h: number; +} + +const round = (n: number, p: number) => Number(n.toFixed(p)); + +const hex2 = (v: number) => + Math.round(Math.min(1, Math.max(0, v)) * 255) + .toString(16) + .padStart(2, "0"); + +/** Opaque `#rrggbb` of any color the theme parser understands (alpha is dropped). Null if it cannot be evaluated. */ +export function toHex(color: string): string | null { + const p = evalColor(color); + return p ? `#${p.rgb.map(hex2).join("")}` : null; +} + +/** OKLCH of any evaluable CSS color (alpha dropped). Null if it cannot be evaluated. */ +export function toOklch(color: string): Oklch | null { + const hex = toHex(color); + return hex ? hexToOklch(hex) : null; +} + +/** `oklch(L C H)` string with 3-decimal lightness/chroma and 1-decimal hue. */ +export function formatOklch({ l, c, h }: Oklch): string { + return `oklch(${round(l, 3)} ${round(c, 3)} ${round(h, 1)})`; +} + +/** Opaque `#rrggbb` for an OKLCH triple (gamut-clipped). */ +export function oklchHex({ l, c, h }: Oklch): string { + return oklchToHex(`oklch(${l.toFixed(5)} ${c.toFixed(5)} ${h.toFixed(3)})`); +} + +/** + * Parse what a user types into a color field: `#rgb`, `#rrggbb`, `rgb()`, `oklch()`, `color-mix()`, + * `white`/`black`, or a bare `rrggbb`. Returns the canonical `#rrggbb` and OKLCH, or null. + */ +export function parseColorInput( + input: string +): { hex: string; oklch: Oklch; alpha: number; css: string } | null { + let t = input.trim(); + if (/^[0-9a-f]{3}$|^[0-9a-f]{6}$/i.test(t)) t = `#${t}`; + const p = evalColor(t); + if (!p) return null; + const hex = `#${p.rgb.map(hex2).join("")}`; + return { hex, oklch: hexToOklch(hex), alpha: p.alpha, css: t }; +} + +const lin = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); +const lum = (c: ParsedColor["rgb"]) => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); + +/** + * WCAG contrast ratio of `fg` on `bg`. A translucent `fg` is composited over `bg`, and a + * translucent `bg` over white, like a browser paints them. Null when either cannot be evaluated + * (`var()`, other keywords). + */ +export function contrast(fg: string, bg: string): number | null { + const f = evalColor(fg); + const b = evalColor(bg); + if (!f || !b) return null; + const base = compositeOver(b, [1, 1, 1]); + const top = compositeOver(f, base); + const [hi, lo] = [lum(top), lum(base)].sort((x, y) => y - x); + return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05); +} + +export type WcagLevel = "AAA" | "AA" | "AA-large" | "fail"; + +/** WCAG level a ratio reaches for normal text: AAA 7, AA 4.5, AA-large 3 (large text and UI parts), else fail. */ +export function wcagLevel(ratio: number): WcagLevel { + return ratio >= 7 ? "AAA" : ratio >= 4.5 ? "AA" : ratio >= 3 ? "AA-large" : "fail"; +} + +// ─── Palette extraction (logo / image → brand colors) ──────────────────────── + +export interface PaletteColor { + hex: string; + oklch: Oklch; + /** Share of the sampled pixels in this cluster, 0 to 1. */ + share: number; +} + +type Lab = [number, number, number]; + +const toLab = (rgb: [number, number, number]): Lab => { + const [r, g, b] = rgb.map(lin) as [number, number, number]; + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +}; + +const labDist = (a: Lab, b: Lab) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; + +/** + * Dominant colors of an RGBA pixel buffer (`ImageData.data`), by k-means in OKLab so clusters are + * perceptual rather than channel-wise. Transparent pixels are ignored; the buffer is subsampled to + * at most `maxSamples` pixels. Deterministic (farthest-point seeding, no randomness). Result is + * sorted by `share`, largest first, and merged so no two entries sit within `minDistance` in OKLab. + */ +export function extractPalette( + rgba: ArrayLike, + { k = 6, maxSamples = 4000, minDistance = 0.06 } = {} +): PaletteColor[] { + const px: Lab[] = []; + const total = Math.floor(rgba.length / 4); + const stride = Math.max(1, Math.floor(total / maxSamples)); + for (let i = 0; i < total; i += stride) { + if ((rgba[i * 4 + 3] ?? 255) < 128) continue; + px.push( + toLab([(rgba[i * 4] ?? 0) / 255, (rgba[i * 4 + 1] ?? 0) / 255, (rgba[i * 4 + 2] ?? 0) / 255]) + ); + } + if (px.length === 0) return []; + + // Farthest-point seeding: the first seed is the pixel nearest the mean, each next seed is the + // pixel farthest from every seed so far. Deterministic, and it finds small accent clusters. + const mean: Lab = [0, 0, 0]; + for (const p of px) + for (let i = 0; i < 3; i++) mean[i] = (mean[i] ?? 0) + (p[i] ?? 0) / px.length; + let seeds: Lab[] = [px.reduce((best, p) => (labDist(p, mean) < labDist(best, mean) ? p : best))]; + while (seeds.length < Math.min(k, px.length)) { + let far = px[0] as Lab; + let farD = -1; + for (const p of px) { + const d = Math.min(...seeds.map((s) => labDist(p, s))); + if (d > farD) { + farD = d; + far = p; + } + } + if (farD < 1e-6) break; + seeds.push(far); + } + + let groups: Lab[][] = []; + for (let iter = 0; iter < 12; iter++) { + groups = seeds.map(() => []); + for (const p of px) { + let best = 0; + let bestD = Number.POSITIVE_INFINITY; + seeds.forEach((s, i) => { + const d = labDist(p, s); + if (d < bestD) { + bestD = d; + best = i; + } + }); + groups[best]?.push(p); + } + seeds = seeds.map((s, i) => { + const g = groups[i] ?? []; + if (g.length === 0) return s; + return [0, 1, 2].map((c) => g.reduce((a, p) => a + (p[c] ?? 0), 0) / g.length) as Lab; + }); + } + + const clusters = seeds + .map((lab, i) => ({ lab, n: groups[i]?.length ?? 0 })) + .filter((c) => c.n > 0) + .sort((a, b) => b.n - a.n); + const merged: { lab: Lab; n: number }[] = []; + for (const c of clusters) { + const near = merged.find((m) => Math.sqrt(labDist(m.lab, c.lab)) < minDistance); + if (near) near.n += c.n; + else merged.push({ ...c }); + } + return merged.map(({ lab, n }) => { + const c = Math.hypot(lab[1], lab[2]); + const h = c < 1e-4 ? 0 : ((Math.atan2(lab[2], lab[1]) * 180) / Math.PI + 360) % 360; + // The hex comes from the unrounded center so a single-color cluster returns its exact color. + const hex = `#${oklabToRgb(lab[0], lab[1], lab[2]).map(hex2).join("")}`; + return { hex, oklch: hexToOklch(hex), share: n / px.length }; + }); +} + +/** + * Pick the brand color of a palette: the most vivid cluster that is neither near-white nor near-black, + * weighted by how much of the image it covers. Falls back to the largest cluster for a grayscale image. + */ +export function pickBrand(palette: PaletteColor[]): PaletteColor | null { + const usable = palette.filter((p) => p.oklch.l > 0.25 && p.oklch.l < 0.92); + const scored = usable + .map((p) => ({ p, score: p.oklch.c * (0.4 + Math.sqrt(p.share)) })) + .sort((a, b) => b.score - a.score); + const best = scored[0]; + if (best && best.p.oklch.c > 0.04) return best.p; + return palette[0] ?? null; +} diff --git a/packages/ui/tests/color.test.ts b/packages/ui/tests/color.test.ts new file mode 100644 index 00000000..4e888e17 --- /dev/null +++ b/packages/ui/tests/color.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "bun:test"; +import { + contrast, + extractPalette, + formatOklch, + parseColorInput, + pickBrand, + toHex, + wcagLevel, +} from "../src/lib/utils/color.js"; + +/** RGBA buffer of `n` pixels per color, in order. */ +const pixels = (...parts: [string, number][]) => + Uint8ClampedArray.from( + parts.flatMap(([hex, n]) => { + const rgb = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)); + return Array.from({ length: n }, () => [...rgb, 255]).flat(); + }) + ); + +describe("parseColorInput", () => { + it("reads hex, bare hex, rgb() and oklch() into the same canonical hex", () => { + expect(parseColorInput("#fff")?.hex).toBe("#ffffff"); + expect(parseColorInput("2b5fd9")?.hex).toBe("#2b5fd9"); + expect(parseColorInput("rgb(43 95 217)")?.hex).toBe("#2b5fd9"); + expect(parseColorInput("oklch(1 0 0)")?.hex).toBe("#ffffff"); + }); + + it("rejects anything that is not one color, including declaration injection", () => { + expect(parseColorInput("not a color")).toBeNull(); + expect(parseColorInput("#fff; color: red")).toBeNull(); + expect(parseColorInput("var(--x)")).toBeNull(); + }); + + it("keeps alpha apart from the opaque hex", () => { + expect(parseColorInput("oklch(1 0 0 / 0.5)")?.alpha).toBeCloseTo(0.5, 2); + }); +}); + +describe("contrast", () => { + it("is 21:1 for black on white and 1:1 for a color on itself", () => { + expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 0); + expect(contrast("#336699", "#336699")).toBeCloseTo(1, 5); + }); + + it("composites a translucent foreground over the background before measuring", () => { + const solid = contrast("#000000", "#ffffff") as number; + const half = contrast("oklch(0 0 0 / 0.5)", "#ffffff") as number; + expect(half).toBeLessThan(solid); + expect(half).toBeGreaterThan(3); + }); + + it("is null for colors it cannot evaluate", () => { + expect(contrast("var(--brand)", "#fff")).toBeNull(); + }); +}); + +describe("wcagLevel", () => { + it("maps ratios to the WCAG thresholds", () => { + expect([2.9, 3, 4.49, 4.5, 6.99, 7].map(wcagLevel)).toEqual([ + "fail", + "AA-large", + "AA-large", + "AA", + "AA", + "AAA", + ]); + }); +}); + +describe("toHex / formatOklch", () => { + it("round-trips a mid blue within one channel step", () => { + const hex = toHex("oklch(0.55 0.2 260)") as string; + expect(hex).toMatch(/^#[0-9a-f]{6}$/); + expect(formatOklch({ l: 0.55, c: 0.2, h: 260 })).toBe("oklch(0.55 0.2 260)"); + }); +}); + +describe("extractPalette / pickBrand", () => { + it("finds the dominant colors and ignores transparent pixels", () => { + const data = pixels(["#2b5fd9", 600], ["#ffffff", 300], ["#e8590c", 100]); + const rgba = Uint8ClampedArray.from([...data, 0, 0, 0, 0, 0, 0, 0, 0]); + const palette = extractPalette(rgba, { k: 4 }); + expect(palette.length).toBeGreaterThanOrEqual(3); + expect(palette[0]?.hex).toBe("#2b5fd9"); + expect(palette[0]?.share).toBeCloseTo(0.6, 1); + expect(palette.reduce((a, p) => a + p.share, 0)).toBeCloseTo(1, 5); + }); + + it("is deterministic", () => { + const data = pixels(["#16a34a", 400], ["#111111", 400], ["#fafafa", 200]); + expect(extractPalette(data)).toEqual(extractPalette(data)); + }); + + it("picks the vivid cluster over a bigger white or black one", () => { + const data = pixels(["#ffffff", 700], ["#111111", 200], ["#e8590c", 100]); + expect(pickBrand(extractPalette(data))?.hex).toBe("#e8590c"); + }); + + it("falls back to the largest cluster for a grayscale image and to null for an empty one", () => { + const gray = pixels(["#808080", 500], ["#cccccc", 100]); + expect(pickBrand(extractPalette(gray))?.hex).toBe("#808080"); + expect(extractPalette(new Uint8ClampedArray(0))).toEqual([]); + expect(pickBrand([])).toBeNull(); + }); +}); diff --git a/packages/ui/tests/theme-studio.test.ts b/packages/ui/tests/theme-studio.test.ts new file mode 100644 index 00000000..8235896e --- /dev/null +++ b/packages/ui/tests/theme-studio.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "bun:test"; +import { FONTS, familyOf, stackOf } from "../../../site/src/components/theme/fonts.js"; +import { LOOKS } from "../../../site/src/components/theme/looks.js"; +import { + BUILTIN_PRESETS_IDS, + COALESCE_MS, + commit, + createHistory, + DEFAULT_STATE, + decodeState, + encodeState, + redo, + STUDIO_PRESET, + type StudioState, + sanitizeState, + toThemeOptions, + undo, +} from "../../../site/src/components/theme/studio-state.js"; +import { BUILTIN_PRESETS } from "../src/lib/theme/builtins.js"; +import { defineTheme, themeCSS } from "../src/lib/theme/define.js"; + +const edit = (p: Partial): StudioState => ({ + ...structuredClone(DEFAULT_STATE), + ...p, +}); + +describe("studio state → ThemeOptions", () => { + it("keeps the built-in preset ids in lockstep with the package", () => { + expect(BUILTIN_PRESETS_IDS).toEqual(BUILTIN_PRESETS.map((p) => p.id)); + }); + + it("maps each control onto its ThemeOptions key and compiles through the real engine", () => { + const s = edit({ + brand: "#e8590c", + radius: 1.2, + density: "compact", + elevation: "hard", + fontScale: 1.1, + motionScale: 0.5, + fonts: { body: '"Inter", sans-serif' }, + }); + const o = toThemeOptions(s); + expect(o.presets?.[0]).toMatchObject({ id: STUDIO_PRESET, color: "#e8590c" }); + expect(o.radius).toBe(1.2); + expect(o.density).toBe("compact"); + expect(o.typography?.scale).toBe(1.1); + expect(o.motion?.scale).toBe(0.5); + const css = themeCSS(defineTheme(o)); + expect(css).toContain('[data-preset="studio"]'); + expect(css).toContain("--ca-radius-scale: 1.2"); + expect(css).toContain("--ca-density: 0.75"); + expect(css).toContain("--ca-motion-scale: 0.5"); + expect(css).toContain('--ca-font-sans: "Inter", sans-serif'); + }); + + it("emits nothing for a default state beyond the preset choice", () => { + const o = toThemeOptions(edit({ preset: "ocean" })); + expect(o).toEqual({ defaultPreset: "ocean", defaultScheme: "dark", defaultStyle: "solid" }); + }); + + it("hand-picked roles land in the preset's per-scheme colors", () => { + const o = toThemeOptions( + edit({ colors: { light: { text: "#111111" }, dark: { background: "#101018" } } }) + ); + expect(o.presets?.[0]?.colors?.light).toMatchObject({ text: "#111111" }); + expect(o.presets?.[0]?.colors?.dark).toMatchObject({ background: "#101018" }); + expect(() => defineTheme(o)).not.toThrow(); + }); +}); + +describe("share code", () => { + it("round-trips a rich state exactly", () => { + const s = edit({ + base: "brutalism", + brand: "#16a34a", + brandDark: "#4ade80", + scheme: "light", + surface: "flat", + radius: 0.2, + density: 1.1, + paper: "grid", + fonts: { body: "Inter, sans-serif", mono: "monospace" }, + type: { label: { size: "12px", tracking: "0.04em", weight: 600 } }, + colors: { light: { text: "#101010" }, dark: {} }, + headingCase: "uppercase", + }); + expect(decodeState(encodeState(s))).toEqual(s); + }); + + it("is tiny for a default theme and rejects garbage and other versions", () => { + expect(encodeState(DEFAULT_STATE).length).toBeLessThan(12); + expect(decodeState(encodeState(DEFAULT_STATE))).toEqual(DEFAULT_STATE); + expect(decodeState("nope")).toBeNull(); + expect(decodeState("v9.e30")).toBeNull(); + expect(decodeState("v1.!!!")).toBeNull(); + }); +}); + +describe("sanitizeState", () => { + it("drops out-of-range and wrong-typed fields one by one without losing the valid ones", () => { + const s = sanitizeState({ + brand: "#abcdef", + radius: 99, + density: "huge", + scheme: "light", + surface: 7, + }); + expect(s.brand).toBe("#abcdef"); + expect(s.scheme).toBe("light"); + expect(s.radius).toBe(DEFAULT_STATE.radius); + expect(s.density).toBe(DEFAULT_STATE.density); + expect(s.surface).toBe(DEFAULT_STATE.surface); + }); + + it("never lets a declaration breakout reach CSS or generated code", () => { + const s = sanitizeState({ + fonts: { body: "x; } body { display:none", mono: "a`b" }, + colors: { light: { text: "red; background: url(x)", border: "#fff" } }, + headingTracking: "1px}", + }); + expect(s.fonts).toEqual({}); + expect(s.colors.light).toEqual({ border: "#fff" }); + expect(s.headingTracking).toBe(""); + }); + + it("treats non-objects as the default", () => { + expect(sanitizeState(null)).toEqual(DEFAULT_STATE); + expect(sanitizeState("x")).toEqual(DEFAULT_STATE); + }); +}); + +describe("history", () => { + const a = edit({ radius: 0.1 }); + const b = edit({ radius: 0.2 }); + const c = edit({ brand: "#000000" }); + + it("undoes and redoes, and a new edit clears the redo stack", () => { + let h = createHistory(DEFAULT_STATE); + h = commit(h, a, "radius", 0); + h = commit(h, c, "brand", 10_000); + h = undo(h); + expect(h.present).toEqual(a); + h = redo(h); + expect(h.present).toEqual(c); + h = undo(undo(h)); + expect(h.present).toEqual(DEFAULT_STATE); + h = commit(h, b, "radius", 20_000); + expect(h.future).toEqual([]); + }); + + it("coalesces a drag of one control into a single undo step", () => { + let h = createHistory(DEFAULT_STATE); + for (let i = 1; i <= 20; i++) h = commit(h, edit({ radius: i / 10 }), "radius", i * 10); + expect(h.past).toHaveLength(1); + expect(undo(h).present).toEqual(DEFAULT_STATE); + // a pause longer than the window starts a new step + h = commit(h, edit({ radius: 0.05 }), "radius", 20 * 10 + COALESCE_MS + 1); + expect(h.past).toHaveLength(2); + }); + + it("ignores a commit that changes nothing", () => { + const h = createHistory(DEFAULT_STATE); + expect(commit(h, structuredClone(DEFAULT_STATE), "x", 0)).toBe(h); + }); +}); + +describe("looks", () => { + it("has distinct ids and compiles every look through the real engine", () => { + expect(new Set(LOOKS.map((l) => l.id)).size).toBe(LOOKS.length); + for (const l of LOOKS) { + const css = themeCSS(defineTheme(toThemeOptions(l.state as StudioState))); + expect(css, l.id).toContain('[data-preset="studio"]'); + } + }); + + it("does not lose authored role colors to sanitizing", () => { + const tokyo = LOOKS.find((l) => l.id === "tokyo-night") as (typeof LOOKS)[number]; + const dark = (tokyo.state as StudioState).colors.dark; + expect(dark.background).toBe("#1a1b26"); + expect(dark.sunken).toBe("#11121c"); + expect(dark.text).toBe("#c0caf5"); + }); + + it("survives a share-code round trip", () => { + for (const l of LOOKS) + expect(decodeState(encodeState(l.state as StudioState))).toEqual(l.state); + }); +}); + +describe("fonts", () => { + it("recognises the stack it printed and nothing else", () => { + for (const f of FONTS) expect(familyOf(stackOf(f))?.id).toBe(f.id); + expect(familyOf("Comic Sans MS, cursive")).toBeNull(); + expect(familyOf(undefined)).toBeNull(); + }); +}); diff --git a/site/src/components/site/StudioBoot.astro b/site/src/components/site/StudioBoot.astro new file mode 100644 index 00000000..3b505f63 --- /dev/null +++ b/site/src/components/site/StudioBoot.astro @@ -0,0 +1,21 @@ +--- +// Replays a theme the visitor applied from /theme/ ("Apply to site"). It only reads this +// browser's localStorage key; with no key it does nothing, so every build and every first visit +// renders sketchbook exactly as published. The CSS string was compiled by the studio when the +// visitor applied it, so no library code runs here. +--- + diff --git a/site/src/components/theme/apply-site.ts b/site/src/components/theme/apply-site.ts new file mode 100644 index 00000000..b9d29698 --- /dev/null +++ b/site/src/components/theme/apply-site.ts @@ -0,0 +1,94 @@ +// ─── "Apply to site": the visitor's own theme, for this browser only ───────── +// The edited theme is compiled to CSS once, stored under the site's own key and replayed by a tiny +// inline script (`StudioBoot.astro`) before first paint. Nothing here touches the published theme, +// the build output or the package defaults: clear the key and the site is sketchbook again. + +import { colorScheme } from "@codeatlasdev/ui"; +import { siteCSS } from "./studio-output"; +import { encodeState, type StudioState } from "./studio-state"; + +/** The site's own storage key. The boot script in `StudioBoot.astro` reads the same one. */ +export const SITE_KEY = "ca-site-studio"; +export const STYLE_ID = "ca-site-studio"; + +/** What is persisted. `css` is the compiled theme, so the boot script needs no library code. */ +export interface SiteTheme { + v: 1; + css: string; + /** `data-preset` to show: a built-in id, or the site's own default preset id for a custom brand. */ + preset: string; + /** `data-style` (surface material). */ + style: string; + /** The studio state, as a share code, so the editor reopens on exactly what is applied. */ + code: string; + /** What the visitor had before applying, restored by `reset`. */ + prev: { preset: string; style: string }; +} + +export function readApplied(): SiteTheme | null { + try { + const v = JSON.parse(localStorage.getItem(SITE_KEY) ?? "null") as SiteTheme | null; + return v && v.v === 1 && typeof v.css === "string" ? v : null; + } catch { + return null; + } +} + +function writeStyle(css: string) { + let el = document.getElementById(STYLE_ID); + if (!css) return el?.remove(); + if (!el) { + el = document.createElement("style"); + el.id = STYLE_ID; + document.head.appendChild(el); + } + el.textContent = css; +} + +/** + * Make the whole site wear `state`. A custom brand is an extra preset the build never emitted, so + * its id is the site's own default preset: the compiled CSS redefines `[data-preset=""]` and the + * existing theme store keeps working (scheme toggle, preset reads) with no special cases. + */ +export function applyToSite(state: StudioState, sitePreset: string): SiteTheme { + const previous = readApplied(); + const custom = state.preset === "studio"; + // Rename the custom preset to the site's so `data-preset` is a value the page already knows. + const css = siteCSS(state).replaceAll('[data-preset="studio"]', `[data-preset="${sitePreset}"]`); + const preset = custom ? sitePreset : state.preset; + const applied: SiteTheme = { + v: 1, + css, + preset, + style: state.surface, + code: encodeState(state), + prev: previous?.prev ?? { preset: colorScheme.preset, style: colorScheme.style }, + }; + try { + localStorage.setItem(SITE_KEY, JSON.stringify(applied)); + } catch { + /* private mode / quota: the theme still shows for this page */ + } + writeStyle(css); + colorScheme.setStyle(state.surface); + colorScheme.setPreference(state.scheme); + // A built-in preset must be one the page's init script knows; a custom one rides on `sitePreset`. + if (colorScheme.presets.includes(preset)) colorScheme.setPreset(preset); + return applied; +} + +/** Back to what the site ships (sketchbook): drops the stored theme and restores the visitor's choices. */ +export function resetSite(): void { + const applied = readApplied(); + try { + localStorage.removeItem(SITE_KEY); + } catch { + /* nothing stored */ + } + writeStyle(""); + if (applied) { + colorScheme.setStyle(applied.prev.style as typeof colorScheme.style); + if (colorScheme.presets.includes(applied.prev.preset)) + colorScheme.setPreset(applied.prev.preset); + } +} diff --git a/site/src/components/theme/engine.ts b/site/src/components/theme/engine.ts new file mode 100644 index 00000000..6a43aaf7 --- /dev/null +++ b/site/src/components/theme/engine.ts @@ -0,0 +1,24 @@ +// The one place the studio reaches into the package. Everything here is public API of +// `@codeatlasdev/ui`: the studio never imports a private path, so what it shows is what an app gets. + +export type { + ColorScheme, + Density, + DesignStyleId, + ElevationModel, + Paper, + PresetContrastIssue, + SurfaceStyle, + ThemeOptions, +} from "@codeatlasdev/ui"; +export { + contrast, + DESIGN_STYLES, + defineTheme, + designStyle, + presetContrastIssues, + presetTokens, + THEME_PRESETS, + themeCSS, + wcagLevel, +} from "@codeatlasdev/ui"; diff --git a/site/src/components/theme/fonts.ts b/site/src/components/theme/fonts.ts new file mode 100644 index 00000000..20abdce3 --- /dev/null +++ b/site/src/components/theme/fonts.ts @@ -0,0 +1,215 @@ +// ─── Curated fonts ─────────────────────────────────────────────────────────── +// A short, deliberate list (not a 1,500-family dropdown): each family has a role, a safe fallback +// chain and a Google Fonts spec so the studio can load it live. The exported `theme.ts` carries the +// plain stack; an app self-hosts the file (docs: theming > fonts) or loads it its own way. + +export type FontRole = "body" | "display" | "mono"; + +export interface FontFamily { + /** Stable id, also the share-code value never; the stack is what is stored. */ + id: string; + /** Family name as CSS knows it. */ + family: string; + roles: FontRole[]; + /** Fallback chain appended after the family. */ + generic: string; + /** Google Fonts `family=` query value, absent for system stacks. */ + google?: string; + /** One-word character shown next to the name. */ + feel: string; +} + +const SANS = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; +const SERIF = "ui-serif, Georgia, Cambria, 'Times New Roman', serif"; +const MONO = "ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace"; + +export const FONTS: FontFamily[] = [ + { id: "system", family: "System UI", roles: ["body", "display"], generic: SANS, feel: "native" }, + { + id: "inter", + family: "Inter", + roles: ["body", "display"], + generic: SANS, + google: "Inter:wght@400;500;600;700", + feel: "neutral", + }, + { + id: "geist", + family: "Geist", + roles: ["body", "display"], + generic: SANS, + google: "Geist:wght@400;500;600;700", + feel: "crisp", + }, + { + id: "dm-sans", + family: "DM Sans", + roles: ["body", "display"], + generic: SANS, + google: "DM+Sans:wght@400;500;600;700", + feel: "friendly", + }, + { + id: "plus-jakarta", + family: "Plus Jakarta Sans", + roles: ["body", "display"], + generic: SANS, + google: "Plus+Jakarta+Sans:wght@400;500;600;700", + feel: "modern", + }, + { + id: "manrope", + family: "Manrope", + roles: ["body", "display"], + generic: SANS, + google: "Manrope:wght@400;500;600;700", + feel: "geometric", + }, + { + id: "nunito", + family: "Nunito", + roles: ["body", "display"], + generic: "ui-rounded, " + SANS, + google: "Nunito:wght@400;600;700", + feel: "rounded", + }, + { + id: "space-grotesk", + family: "Space Grotesk", + roles: ["display"], + generic: SANS, + google: "Space+Grotesk:wght@500;600;700", + feel: "techy", + }, + { + id: "sora", + family: "Sora", + roles: ["display"], + generic: SANS, + google: "Sora:wght@500;600;700", + feel: "wide", + }, + { + id: "fraunces", + family: "Fraunces", + roles: ["display"], + generic: SERIF, + google: "Fraunces:wght@500;600;700", + feel: "soft serif", + }, + { + id: "playfair", + family: "Playfair Display", + roles: ["display"], + generic: SERIF, + google: "Playfair+Display:wght@500;600;700", + feel: "editorial", + }, + { + id: "lora", + family: "Lora", + roles: ["body", "display"], + generic: SERIF, + google: "Lora:wght@400;500;600;700", + feel: "bookish", + }, + { + id: "source-serif", + family: "Source Serif 4", + roles: ["body", "display"], + generic: SERIF, + google: "Source+Serif+4:wght@400;600;700", + feel: "readable", + }, + { + id: "jetbrains-mono", + family: "JetBrains Mono", + roles: ["mono", "display"], + generic: MONO, + google: "JetBrains+Mono:wght@400;500;700", + feel: "code", + }, + { + id: "fira-code", + family: "Fira Code", + roles: ["mono"], + generic: MONO, + google: "Fira+Code:wght@400;500;700", + feel: "ligatures", + }, + { + id: "ibm-plex-mono", + family: "IBM Plex Mono", + roles: ["mono", "display"], + generic: MONO, + google: "IBM+Plex+Mono:wght@400;500;600", + feel: "engineered", + }, + { + id: "space-mono", + family: "Space Mono", + roles: ["mono", "display"], + generic: MONO, + google: "Space+Mono:wght@400;700", + feel: "retro", + }, +]; + +/** CSS `font-family` value for a family: the quoted name plus its fallback chain. */ +export const stackOf = (f: FontFamily): string => + f.id === "system" ? f.generic : `"${f.family}", ${f.generic}`; + +export const byRole = (role: FontRole): FontFamily[] => FONTS.filter((f) => f.roles.includes(role)); + +/** The family a stored stack was made from, or null for a stack the user typed. */ +export function familyOf(stack: string | undefined): FontFamily | null { + if (!stack) return null; + return FONTS.find((f) => stackOf(f) === stack) ?? null; +} + +/** Curated body/display/mono pairings: one click sets all three. */ +export interface FontPair { + id: string; + label: string; + body: string; + display?: string; + mono: string; +} + +export const PAIRS: FontPair[] = [ + { id: "clean", label: "Clean", body: "inter", mono: "jetbrains-mono" }, + { + id: "editorial", + label: "Editorial", + body: "source-serif", + display: "playfair", + mono: "ibm-plex-mono", + }, + { id: "soft", label: "Soft", body: "nunito", display: "fraunces", mono: "space-mono" }, + { id: "tech", label: "Tech", body: "geist", display: "space-grotesk", mono: "fira-code" }, + { id: "warm", label: "Warm", body: "dm-sans", display: "fraunces", mono: "ibm-plex-mono" }, + { id: "sharp", label: "Sharp", body: "manrope", display: "sora", mono: "jetbrains-mono" }, +]; + +const loaded = new Set(); + +/** + * Load a family from Google Fonts, once, on demand. Resolves when the browser has the face or after + * a short timeout, so a slow network never blocks the editor; the fallback stack shows meanwhile. + * Browser only. + */ +export async function loadFont(f: FontFamily | null, timeoutMs = 2500): Promise { + if (!f?.google || typeof document === "undefined" || loaded.has(f.id)) return; + loaded.add(f.id); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `https://fonts.googleapis.com/css2?family=${f.google}&display=swap`; + document.head.appendChild(link); + const timeout = Promise.withResolvers(); + const timer = setTimeout(timeout.resolve, timeoutMs); + await Promise.race([ + document.fonts.load(`1em "${f.family}"`).catch(() => undefined), + timeout.promise, + ]); + clearTimeout(timer); +} diff --git a/site/src/components/theme/looks.ts b/site/src/components/theme/looks.ts new file mode 100644 index 00000000..1e577092 --- /dev/null +++ b/site/src/components/theme/looks.ts @@ -0,0 +1,198 @@ +// ─── Looks: the presets gallery ────────────────────────────────────────────── +// A look is a complete starting point: a design-style recipe, a brand, fonts and shape. Picking one +// replaces the editor state (undoable). Each is a plain `StudioState` patch, so a look is also a +// worked example of what the knobs can do, and `theme-studio.test.ts` compiles every one through +// the real engine. + +import { FONTS, stackOf } from "./fonts"; +import { DEFAULT_STATE, type StudioState, sanitizeState } from "./studio-state"; + +export interface Look { + id: string; + label: string; + /** One line: when it fits. */ + blurb: string; + /** Four swatches for the picker: brand, page, surface, text (dark scheme). */ + swatch: [string, string, string, string]; + state: Partial; +} + +const font = (id: string) => { + const f = FONTS.find((x) => x.id === id); + if (!f) throw new Error(`unknown font ${id}`); + return stackOf(f); +}; + +const RAW: Look[] = [ + { + id: "atlas", + label: "Atlas", + blurb: "The package default: solid surfaces, calm blue.", + swatch: ["#2b5fd9", "#14151a", "#1c1e25", "#e6e8ee"], + state: { brand: "#2b5fd9" }, + }, + { + id: "ember", + label: "Ember", + blurb: "Warm amber on soft glass, round and friendly.", + swatch: ["#e8590c", "#17120e", "#211a14", "#f1e6dc"], + state: { base: "liquid", brand: "#e8590c", radius: 1.2, fonts: { body: font("dm-sans") } }, + }, + { + id: "tokyo-night", + label: "Tokyo Night", + blurb: "Editor-theme blues on dark ink, mono accents.", + swatch: ["#7aa2f7", "#1a1b26", "#232430", "#c0caf5"], + state: { + brand: "#7aa2f7", + colors: { + light: {}, + dark: { + background: "#1a1b26", + sunken: "#11121c", + surface: "#232430", + overlay: "#2d2e3a", + text: "#c0caf5", + textSecondary: "#a9b1d6", + border: "#565f89", + }, + }, + radius: 0.6, + fonts: { mono: font("jetbrains-mono") }, + }, + }, + { + id: "paper", + label: "Paper", + blurb: "Editorial serif on warm paper, hairline borders.", + swatch: ["#b4451f", "#f6f1e7", "#fffdf8", "#2a2118"], + state: { + base: "editorial", + brand: "#b4451f", + scheme: "light", + radius: 0.25, + fonts: { body: font("source-serif"), display: font("playfair") }, + paper: "plain", + }, + }, + { + id: "brutal", + label: "Brutal", + blurb: "Hard offset shadows, thick borders, loud yellow.", + swatch: ["#facc15", "#fff8dc", "#ffffff", "#111111"], + state: { + base: "brutalism", + brand: "#facc15", + scheme: "light", + radius: 0, + borderWidth: 2, + borderStrong: 3, + elevation: "hard", + headingCase: "uppercase", + fonts: { display: font("space-grotesk") }, + }, + }, + { + id: "mint-glass", + label: "Mint Glass", + blurb: "Frosted panels over a green mesh.", + swatch: ["#10b981", "#07140f", "#0e211a", "#d9f3ea"], + state: { + base: "glassmorphism", + brand: "#10b981", + surface: "frosted", + radius: 1.4, + paper: "mesh", + }, + }, + { + id: "terminal", + label: "Terminal", + blurb: "Green phosphor, monospace everything, no motion.", + swatch: ["#22c55e", "#0a0f0a", "#101810", "#bbf7d0"], + state: { + base: "terminal", + brand: "#22c55e", + radius: 0, + fonts: { + body: font("ibm-plex-mono"), + display: font("ibm-plex-mono"), + mono: font("ibm-plex-mono"), + }, + motionEnter: "none", + paper: "lines", + }, + }, + { + id: "clay", + label: "Clay", + blurb: "Soft, puffy, pastel. Big radii and deep shadows.", + swatch: ["#a78bfa", "#ece8f7", "#f6f3fc", "#2c2740"], + state: { + base: "claymorphism", + brand: "#8b5cf6", + scheme: "light", + surface: "clay", + radius: 1.8, + controlRadius: "xl", + fonts: { body: font("nunito") }, + }, + }, + { + id: "swiss", + label: "Swiss", + blurb: "Grid, Helvetica-like type, one red accent.", + swatch: ["#dc2626", "#ffffff", "#f4f4f5", "#18181b"], + state: { + base: "swiss", + brand: "#dc2626", + scheme: "light", + radius: 0, + borderWidth: 1, + headingTracking: "-0.02em", + fonts: { body: font("inter") }, + }, + }, + { + id: "ocean-bento", + label: "Ocean Bento", + blurb: "Rounded tiles, deep sea blue, dense and tidy.", + swatch: ["#0ea5e9", "#08131c", "#0f1f2c", "#d8ecf7"], + state: { base: "bento", brand: "#0ea5e9", radius: 1.5, density: "compact" }, + }, + { + id: "neon", + label: "Neon", + blurb: "Violet glow on near-black, y2k edges.", + swatch: ["#c026d3", "#0c0614", "#160b22", "#f1d9fa"], + state: { + base: "y2k", + brand: "#c026d3", + elevation: "glow", + radius: 0.9, + fonts: { display: font("sora") }, + }, + }, + { + id: "calm", + label: "Calm", + blurb: "Airy density, slate, slow motion. Reading first.", + swatch: ["#64748b", "#f8fafc", "#ffffff", "#0f172a"], + state: { + brand: "#475569", + scheme: "light", + density: "airy", + motionScale: 1.4, + lineHeight: 1.7, + fonts: { body: font("lora") }, + }, + }, +]; + +/** Every look, validated onto a full state so a typo here can never reach the editor. */ +export const LOOKS: Look[] = RAW.map((l) => ({ + ...l, + state: { ...sanitizeState({ ...DEFAULT_STATE, ...l.state }) }, +})); + +export const lookById = (id: string): Look | undefined => LOOKS.find((l) => l.id === id); diff --git a/site/src/components/theme/studio-output.ts b/site/src/components/theme/studio-output.ts new file mode 100644 index 00000000..16443cf7 --- /dev/null +++ b/site/src/components/theme/studio-output.ts @@ -0,0 +1,187 @@ +// ─── Studio output: everything derived from a StudioState ──────────────────── +// Live CSS for the preview island, CSS for the whole site, the code a user pastes, the AI prompt +// and the contrast findings. All pure functions of the state. + +import { + contrast, + type DesignStyleId, + defineTheme, + designStyle, + type PresetContrastIssue, + presetContrastIssues, + presetTokens, + THEME_PRESETS, + type ThemeOptions, + themeCSS, +} from "./engine"; +import { + encodeState, + prune, + STUDIO_PRESET, + type StudioState, + toThemeOptions, +} from "./studio-state"; + +/** Class of the preview islands. Everything `themeCSS` emits is rewritten to it. */ +export const ISLAND = "te-island"; + +/** The recipe flattened under the user's options: what the engine actually receives. */ +export function resolveOptions(s: StudioState): ThemeOptions { + const mine = toThemeOptions(s); + return s.base === "default" ? mine : designStyle(s.base, mine); +} + +/** + * CSS for a preview island. `themeCSS` targets `:root` (knobs, raw overrides) and brand presets keyed + * on `[data-preset]`; the `:root` selectors are rewritten to the island so the page never changes. + */ +export function islandCSS(s: StudioState): string { + return themeCSS(defineTheme(resolveOptions(s))) + .replace(':root,\n[data-theme-scope="light"] {', `.${ISLAND} {`) + .replace( + ':root.dark,\n:root [data-theme-scope="dark"] {', + `.${ISLAND}[data-theme-scope="dark"] {` + ); +} + +/** CSS that restyles the whole page (the "apply to site" payload). Same text a real app would ship. */ +export function siteCSS(s: StudioState): string { + return themeCSS(defineTheme(resolveOptions(s))); +} + +// ─── Code ──────────────────────────────────────────────────────────────────── + +const KEY = /^[A-Za-z_$][\w$]*$/; +const str = (v: string) => JSON.stringify(v); + +function literal(v: unknown, depth: number): string { + const pad = "\t".repeat(depth + 1); + const end = "\t".repeat(depth); + if (typeof v === "string") return str(v); + if (typeof v !== "object" || v === null) return String(v); + if (Array.isArray(v)) + return `[\n${v.map((x) => `${pad}${literal(x, depth + 1)},`).join("\n")}\n${end}]`; + const rows = Object.entries(v).map( + ([k, x]) => `${pad}${KEY.test(k) ? k : str(k)}: ${literal(x, depth + 1)},` + ); + return `{\n${rows.join("\n")}\n${end}}`; +} + +/** + * The exact `src/lib/theme.ts`. A chosen recipe is referenced through `designStyle(id, …)` so the + * app keeps following the package; only the user's own options are written out. + */ +export function themeSource(s: StudioState): string { + const mine = prune(toThemeOptions(s)); + const body = mine ? literal(mine, 0) : "{}"; + if (s.base === "default") + return `import type { ThemeOptions } from "@codeatlasdev/ui";\n\nexport const theme: ThemeOptions = ${body};\n`; + return `import { designStyle, type ThemeOptions } from "@codeatlasdev/ui";\n\nexport const theme: ThemeOptions = designStyle(${str(s.base)}, ${body});\n`; +} + +/** Where the theme goes in a SvelteKit app and an Astro app (the two integrations the package ships). */ +export function wiringSource(kind: "kit" | "astro"): string { + if (kind === "astro") + return `// astro.config.mjs\nimport { caUI } from "@codeatlasdev/ui/astro";\nimport { theme } from "./src/theme.ts";\n\nexport default defineConfig({\n\tintegrations: [caUI({ theme })],\n});\n`; + return `// vite.config.ts\nimport { caErrorPlugin, caThemePlugin } from "@codeatlasdev/ui/vite";\nimport { theme } from "./src/lib/theme.ts";\n\nexport default defineConfig({\n\tplugins: [sveltekit(), caThemePlugin(theme), caErrorPlugin({ scaffold: true, theme })],\n});\n\n// src/hooks.server.ts\nimport { sequence } from "@sveltejs/kit/hooks";\nimport { caThemeHandle, createErrorHandler } from "@codeatlasdev/ui/kit";\nimport { theme } from "$lib/theme";\n\nexport const handle = sequence(caThemeHandle(theme));\nexport const handleError = createErrorHandler();\n`; +} + +/** The CSS the theme compiles to, for apps that paste CSS instead of using the plugin. */ +export function cssSource(s: StudioState): string { + const css = siteCSS(s); + return css || "/* Every value is a package default: no CSS to ship. */"; +} + +/** Share link for the current state. `base` is the page URL without a hash. */ +export function shareURL(s: StudioState, base: string): string { + return `${base.replace(/#.*$/, "")}#t=${encodeState(s)}`; +} + +/** + * A prompt a coding agent can follow without reading the docs: the theme object, where it goes, + * and the rules that keep it correct. + */ +export function agentPrompt(s: StudioState): string { + return `Apply this @codeatlasdev/ui theme to the project. + +1. Create \`src/lib/theme.ts\` (SvelteKit) or \`src/theme.ts\` (Astro) with exactly: + +\`\`\`ts +${themeSource(s).trimEnd()} +\`\`\` + +2. Wire it once. + +SvelteKit: +\`\`\`ts +${wiringSource("kit").trimEnd()} +\`\`\` + +Astro: +\`\`\`ts +${wiringSource("astro").trimEnd()} +\`\`\` + +Rules: +- The file is a plain \`ThemeOptions\` object: use \`import type\` only, because vite.config and astro.config load it in Node. +- Import the CSS once: \`import "@codeatlasdev/ui/styles/global.css";\` +- To change a token later, edit \`theme.ts\`. Do not write \`!important\` or override \`.ca-*\` classes. +- Local overrides go on \`:root\` as \`--ca-*\` custom properties. +- Docs: https://ui.codeatlas.com.br/docs/getting-started/theming/ +`; +} + +// ─── Contrast findings ─────────────────────────────────────────────────────── + +export interface ContrastFinding { + scheme: "light" | "dark"; + /** What is measured, e.g. "Brand text on surface". */ + label: string; + /** Token of the foreground role, so a panel can show the finding next to its field. */ + role: string; + ratio: number; + /** Ratio that counts as passing: 4.5 for text, 3 for UI parts. */ + min: number; + pass: boolean; +} + +/** [label, role, foreground token, background token, minimum ratio] */ +const PAIRS: [string, string, string, string, number][] = [ + ["Body text on page", "text", "--ca-text-primary", "--ca-surface-base", 4.5], + ["Body text on cards", "text", "--ca-text-primary", "--ca-surface-raised", 4.5], + ["Secondary text on cards", "textSecondary", "--ca-text-secondary", "--ca-surface-raised", 4.5], + ["Brand text on surface", "brand", "--ca-brand-text", "--ca-surface-raised", 4.5], + ["Button label on brand fill", "brandContrast", "--ca-brand-contrast", "--ca-brand-fill", 4.5], + ["Field outline on page", "border", "--ca-outline-control", "--ca-surface-base", 3], +]; + +/** + * Always-on contrast readout of the resolved palette, from the same tokens the stylesheet declares. + * A pair the build cannot evaluate (a `var()` role) is skipped, never guessed. + */ +export function contrastReport(s: StudioState): ContrastFinding[] { + const src = + s.preset === STUDIO_PRESET + ? (resolveOptions(s).presets ?? []).find((p) => p.id === STUDIO_PRESET) + : THEME_PRESETS.find((p) => p.id === s.preset); + if (!src) return []; + const tokens = presetTokens("hue" in src ? src : (src as Parameters[0])); + const out: ContrastFinding[] = []; + for (const scheme of ["light", "dark"] as const) { + const vars = tokens[scheme]; + for (const [label, role, fg, bg, min] of PAIRS) { + const [a, b] = [vars[fg as keyof typeof vars], vars[bg as keyof typeof vars]]; + const ratio = a && b ? contrast(a, b) : null; + if (ratio !== null) out.push({ scheme, label, role, ratio, min, pass: ratio >= min }); + } + } + return out; +} + +/** The engine's own findings for hand-set roles (the same ones `caThemePlugin` prints at build). */ +export function engineIssues(s: StudioState): PresetContrastIssue[] { + const t = defineTheme(resolveOptions(s)); + return presetContrastIssues(t); +} + +export type { DesignStyleId }; diff --git a/site/src/components/theme/studio-state.ts b/site/src/components/theme/studio-state.ts new file mode 100644 index 00000000..0954dbad --- /dev/null +++ b/site/src/components/theme/studio-state.ts @@ -0,0 +1,454 @@ +// ─── Theme Studio state ────────────────────────────────────────────────────── +// One plain, serializable object is the whole editor. `toThemeOptions` maps it 1:1 onto the lib's +// `ThemeOptions`; nothing in the studio invents a knob the engine does not have. Pure and DOM-free, +// so the codec, the history and the mapping are unit-testable. + +import type { + ColorScheme, + Density, + DesignStyleId, + ElevationModel, + Paper, + SurfaceStyle, + ThemeOptions, +} from "./engine"; + +/** + * Ids of the built-in presets. Kept literal so this module has no runtime import of the package + * barrel (it pulls Svelte components) and stays testable under plain `bun test`; `studio.test.ts` + * asserts it equals the package's own `THEME_PRESETS` ids, so a new built-in cannot drift. + */ +export const BUILTIN_PRESETS_IDS = [ + "amber", + "ocean", + "crimson", + "cyan", + "violet", + "emerald", + "rose", + "slate", +]; + +/** Preset id the studio gives the user's own brand. Never clashes with a built-in. */ +export const STUDIO_PRESET = "studio"; + +/** Roles a user can hand-pick per scheme (a subset of the engine's `SchemeColors`). */ +export const COLOR_ROLES = [ + "brandFill", + "brandFillHover", + "brandContrast", + "background", + "sunken", + "surface", + "overlay", + "text", + "textSecondary", + "textHint", + "textDisabled", + "borderSubtle", + "border", + "borderStrong", + "success", + "warning", + "info", + "error", +] as const; +export type ColorRole = (typeof COLOR_ROLES)[number]; + +/** Hand-picked colors for one scheme. A missing role keeps the generated value. */ +export type RoleColors = Partial>; + +/** Font pair ids from `fonts.ts`; `custom` keeps the user's three stacks. */ +export interface FontChoice { + body?: string; + display?: string; + mono?: string; +} + +export interface TypeRoleEdit { + size?: string; + leading?: number; + tracking?: string; + weight?: number; +} + +export interface StudioState { + /** Design-style recipe the edits sit on top of. */ + base: DesignStyleId; + /** `studio` = the user's own brand, else a built-in preset id. */ + preset: string; + /** Brand color as `#rrggbb`; generates the full `studio` preset. */ + brand: string; + /** Optional separate brand for dark mode. */ + brandDark?: string; + colors: { light: RoleColors; dark: RoleColors }; + scheme: ColorScheme; + /** Surface material. */ + surface: SurfaceStyle; + radius: number; + controlRadius: "xs" | "sm" | "md" | "lg" | "xl" | "pill"; + density: Density; + borderWidth: number; + borderStrong: number; + elevation: ElevationModel; + paper: Paper; + noise: number; + fonts: FontChoice; + /** Multiplier on the whole UI type scale. */ + fontScale: number; + headingWeight: number; + headingTracking: string; + headingCase: "none" | "uppercase"; + labelCase: "none" | "uppercase"; + lineHeight: number; + type: Partial>; + motionScale: number; + motionEasing: "standard" | "emphasized" | "spring" | "linear" | "steps"; + motionEnter: "default" | "none"; + iconStroke: number; + /** Button/field component tokens (a small, real subset of `ThemeOptions.components`). */ + buttonWeight?: number; + buttonTracking?: string; + buttonGap?: string; +} + +/** What a brand-new studio shows: the package default with a neutral blue brand. */ +export const DEFAULT_STATE: StudioState = { + base: "default", + preset: STUDIO_PRESET, + brand: "#2b5fd9", + colors: { light: {}, dark: {} }, + scheme: "dark", + surface: "solid", + radius: 0.8, + controlRadius: "md", + density: "comfortable", + borderWidth: 1, + borderStrong: 2, + elevation: "soft", + paper: "plain", + noise: 0, + fonts: {}, + fontScale: 1, + headingWeight: 600, + headingTracking: "", + headingCase: "none", + labelCase: "none", + lineHeight: 1.55, + type: {}, + motionScale: 1, + motionEasing: "standard", + motionEnter: "default", + iconStroke: 1.75, +}; + +export const clone = (v: T): T => structuredClone(v); + +const HEX = /^#[0-9a-f]{6}$/i; +export const isHex = (v: unknown): v is `#${string}` => typeof v === "string" && HEX.test(v); + +const isPlain = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** Drop `undefined` and empty groups so printed code and share URLs carry only meaningful keys. */ +export function prune(v: T): T | undefined { + if (Array.isArray(v)) return (v.length ? v.map(prune) : undefined) as T | undefined; + if (!isPlain(v)) return v === undefined || v === "" ? undefined : v; + const out: Record = {}; + for (const [k, x] of Object.entries(v)) { + const p = prune(x); + if (p !== undefined) out[k] = p; + } + return (Object.keys(out).length ? out : undefined) as T | undefined; +} + +/** + * The state as `ThemeOptions`, exactly what the user pastes into `theme.ts`. Values equal to the + * engine default are left out (the engine emits no CSS for them either). `base` is not applied + * here: the printed code references the recipe through `designStyle(base, …)` so the app keeps + * following it, while `resolveThemeOptions` flattens it for the live preview. + */ +export function toThemeOptions(s: StudioState): ThemeOptions { + const d = DEFAULT_STATE; + const custom = s.preset === STUDIO_PRESET; + const roles = (c: RoleColors) => (Object.keys(c).length ? c : undefined); + const light = { + ...(s.brand ? { brand: s.brand as `#${string}` } : {}), + ...roles(s.colors.light), + }; + const dark = { + ...(s.brandDark ? { brand: s.brandDark as `#${string}` } : {}), + ...roles(s.colors.dark), + }; + const t = (k: keyof StudioState) => (s[k] !== d[k] ? s[k] : undefined); + const options: ThemeOptions = { + ...(custom + ? { + presets: [ + { + id: STUDIO_PRESET, + label: "Studio", + color: s.brand as `#${string}`, + colors: { + ...(Object.keys(light).length > 1 ? { light } : {}), + ...(Object.keys(dark).length ? { dark } : {}), + }, + }, + ], + } + : {}), + defaultPreset: s.preset, + defaultScheme: s.scheme, + defaultStyle: s.surface, + radius: t("radius") as number | undefined, + controlRadius: t("controlRadius") as StudioState["controlRadius"] | undefined, + density: t("density") as Density | undefined, + border: + s.borderWidth !== d.borderWidth || s.borderStrong !== d.borderStrong + ? { width: s.borderWidth, strong: s.borderStrong } + : undefined, + elevation: t("elevation") as ElevationModel | undefined, + paper: t("paper") as Paper | undefined, + noise: t("noise") as number | undefined, + iconStroke: t("iconStroke") as number | undefined, + fonts: { + body: s.fonts.body, + display: s.fonts.display, + mono: s.fonts.mono, + }, + typography: { + scale: s.fontScale !== 1 ? s.fontScale : undefined, + weights: s.headingWeight !== d.headingWeight ? { heading: s.headingWeight } : undefined, + headingTracking: s.headingTracking || undefined, + headingCase: s.headingCase !== "none" ? s.headingCase : undefined, + labelCase: s.labelCase !== "none" ? s.labelCase : undefined, + lineHeight: s.lineHeight !== d.lineHeight ? s.lineHeight : undefined, + }, + type: s.type, + motion: { + scale: s.motionScale !== 1 ? s.motionScale : undefined, + easing: s.motionEasing !== "standard" ? s.motionEasing : undefined, + enter: s.motionEnter !== "default" ? s.motionEnter : undefined, + }, + components: { + button: { + weight: s.buttonWeight, + tracking: s.buttonTracking, + gap: s.buttonGap, + }, + }, + }; + // A built-in preset is only named, never redefined. + if (!custom && !BUILTIN_PRESETS_IDS.includes(s.preset)) options.defaultPreset = "amber"; + return prune(options) ?? {}; +} + +// ─── Share URL ─────────────────────────────────────────────────────────────── + +const VERSION = "1"; + +/** Keys of `StudioState` whose value differs from the default (the share payload). */ +export function diffFromDefault(s: StudioState): Partial { + const out: Record = {}; + // Union of keys: optional fields (`brandDark`, `buttonWeight`) are absent from the default. + for (const k of new Set([...Object.keys(DEFAULT_STATE), ...Object.keys(s)]) as Set< + keyof StudioState + >) { + if (JSON.stringify(s[k]) !== JSON.stringify(DEFAULT_STATE[k])) out[k] = s[k]; + } + return out as Partial; +} + +const b64 = { + enc: (s: string) => + btoa(String.fromCharCode(...new TextEncoder().encode(s))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""), + dec: (s: string) => { + const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/")); + return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))); + }, +}; + +/** `v1.`: short for a default theme, stable, and safe in a URL hash. */ +export function encodeState(s: StudioState): string { + return `v${VERSION}.${b64.enc(JSON.stringify(diffFromDefault(s)))}`; +} + +const SCHEMES = ["light", "dark", "system"]; +const SURFACES = [ + "glass", + "frosted", + "solid", + "flat", + "outlined", + "neumorphic", + "clay", + "tonal", + "chrome", +]; +const RADII = ["xs", "sm", "md", "lg", "xl", "pill"]; +const ELEVATIONS = ["soft", "none", "hard", "glow"]; +const PAPERS = ["mesh", "plain", "grid", "dots", "lines"]; +const EASINGS = ["standard", "emphasized", "spring", "linear", "steps"]; +const DENSITIES = ["compact", "comfortable", "airy"]; + +const num = (v: unknown, lo: number, hi: number) => + typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi; +const oneOf = (v: unknown, list: readonly string[]) => typeof v === "string" && list.includes(v); +/** A value safe to print inside a CSS declaration or a string literal. */ +const safeText = (v: unknown, max = 200): v is string => + typeof v === "string" && v.length <= max && !/[;{}<>\\`]/.test(v); + +function roleColors(v: unknown): RoleColors { + if (!isPlain(v)) return {}; + const out: RoleColors = {}; + for (const role of COLOR_ROLES) { + const c = v[role]; + if (typeof c === "string" && safeText(c, 80) && c.trim()) out[role] = c.trim(); + } + return out; +} + +/** + * Validate an untrusted partial state (from a URL, storage or a generator) onto the default. Every + * field is checked individually, so one bad key never discards the rest. Strings that reach CSS or + * code are screened for declaration breakouts. + */ +export function sanitizeState(raw: unknown): StudioState { + const s = clone(DEFAULT_STATE); + if (!isPlain(raw)) return s; + const r = raw as Record; + if ( + oneOf(r.base, [ + "default", + "liquid", + "swiss", + "brutalism", + "neumorphism", + "glassmorphism", + "material", + "fluent", + "apple", + "bento", + "claymorphism", + "y2k", + "editorial", + "terminal", + "sketchbook", + ]) + ) + s.base = r.base as DesignStyleId; + if ( + r.preset === STUDIO_PRESET || + (typeof r.preset === "string" && BUILTIN_PRESETS_IDS.includes(r.preset)) + ) + s.preset = r.preset as string; + if (isHex(r.brand)) s.brand = r.brand.toLowerCase(); + if (isHex(r.brandDark)) s.brandDark = r.brandDark.toLowerCase(); + if (isPlain(r.colors)) + s.colors = { light: roleColors(r.colors.light), dark: roleColors(r.colors.dark) }; + if (oneOf(r.scheme, SCHEMES)) s.scheme = r.scheme as ColorScheme; + if (oneOf(r.surface, SURFACES)) s.surface = r.surface as SurfaceStyle; + if (num(r.radius, 0, 3)) s.radius = r.radius as number; + if (oneOf(r.controlRadius, RADII)) + s.controlRadius = r.controlRadius as StudioState["controlRadius"]; + if (oneOf(r.density, DENSITIES) || num(r.density, 0.5, 2)) s.density = r.density as Density; + if (num(r.borderWidth, 0, 6)) s.borderWidth = r.borderWidth as number; + if (num(r.borderStrong, 0, 8)) s.borderStrong = r.borderStrong as number; + if (oneOf(r.elevation, ELEVATIONS)) s.elevation = r.elevation as ElevationModel; + if (oneOf(r.paper, PAPERS)) s.paper = r.paper as Paper; + if (num(r.noise, 0, 0.12)) s.noise = r.noise as number; + if (isPlain(r.fonts)) { + for (const k of ["body", "display", "mono"] as const) + if (safeText(r.fonts[k], 160) && (r.fonts[k] as string).trim()) + s.fonts[k] = r.fonts[k] as string; + } + if (num(r.fontScale, 0.7, 1.6)) s.fontScale = r.fontScale as number; + if (num(r.headingWeight, 100, 900)) s.headingWeight = r.headingWeight as number; + if (safeText(r.headingTracking, 12)) s.headingTracking = r.headingTracking as string; + if (oneOf(r.headingCase, ["none", "uppercase"])) + s.headingCase = r.headingCase as "none" | "uppercase"; + if (oneOf(r.labelCase, ["none", "uppercase"])) s.labelCase = r.labelCase as "none" | "uppercase"; + if (num(r.lineHeight, 1, 2.4)) s.lineHeight = r.lineHeight as number; + if (isPlain(r.type)) { + for (const role of ["title", "heading", "body", "label", "caption", "num"] as const) { + const spec = r.type[role]; + if (!isPlain(spec)) continue; + const e: TypeRoleEdit = {}; + if (safeText(spec.size, 16)) e.size = spec.size as string; + if (num(spec.leading, 0.8, 3)) e.leading = spec.leading as number; + if (safeText(spec.tracking, 16)) e.tracking = spec.tracking as string; + if (num(spec.weight, 1, 1000)) e.weight = spec.weight as number; + if (Object.keys(e).length) s.type[role] = e; + } + } + if (num(r.motionScale, 0, 3)) s.motionScale = r.motionScale as number; + if (oneOf(r.motionEasing, EASINGS)) + s.motionEasing = r.motionEasing as StudioState["motionEasing"]; + if (oneOf(r.motionEnter, ["default", "none"])) + s.motionEnter = r.motionEnter as "default" | "none"; + if (num(r.iconStroke, 0.5, 3)) s.iconStroke = r.iconStroke as number; + if (num(r.buttonWeight, 100, 900)) s.buttonWeight = r.buttonWeight as number; + if (safeText(r.buttonTracking, 12)) s.buttonTracking = r.buttonTracking as string; + if (safeText(r.buttonGap, 12)) s.buttonGap = r.buttonGap as string; + return s; +} + +/** Inverse of `encodeState`. Null for anything that is not a v1 payload. */ +export function decodeState(code: string): StudioState | null { + const m = /^v(\d+)\.([\w-]+)$/.exec(code.trim()); + if (!m || m[1] !== VERSION) return null; + try { + return sanitizeState(JSON.parse(b64.dec(m[2] as string))); + } catch { + return null; + } +} + +// ─── History ───────────────────────────────────────────────────────────────── + +export interface History { + past: StudioState[]; + present: StudioState; + future: StudioState[]; + /** Key of the last coalesced edit (`radius`, `brand`): same key within `COALESCE_MS` extends one step. */ + key?: string; + at: number; +} + +export const HISTORY_LIMIT = 100; +export const COALESCE_MS = 600; + +export const createHistory = (present: StudioState): History => ({ + past: [], + present, + future: [], + at: 0, +}); + +/** Commit `next`. Consecutive edits of the same `key` inside `COALESCE_MS` become one undo step. */ +export function commit(h: History, next: StudioState, key?: string, now = Date.now()): History { + if (JSON.stringify(next) === JSON.stringify(h.present)) return h; + const merge = key !== undefined && key === h.key && now - h.at < COALESCE_MS && h.past.length > 0; + return { + past: merge ? h.past : [...h.past, h.present].slice(-HISTORY_LIMIT), + present: next, + future: [], + key, + at: now, + }; +} + +export function undo(h: History): History { + const prev = h.past[h.past.length - 1]; + if (!prev) return h; + return { past: h.past.slice(0, -1), present: prev, future: [h.present, ...h.future], at: 0 }; +} + +export function redo(h: History): History { + const [next, ...rest] = h.future; + if (!next) return h; + return { past: [...h.past, h.present], present: next, future: rest, at: 0 }; +} diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 87c859cd..fb5fa1a3 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -4,6 +4,7 @@ import "@fontsource-variable/jetbrains-mono"; import { iconLinks, serializeJsonLd } from "@codeatlasdev/ui/seo"; import "@codeatlasdev/ui/styles/global.css"; import "@codeatlasdev/ui/styles/marks.css"; +import StudioBoot from "~/components/site/StudioBoot.astro"; import { brand } from "~/generated/brand"; import "~/styles/site.css"; import "~/styles/sketch.css"; @@ -53,6 +54,7 @@ const ld = jsonLd && serializeJsonLd(jsonLd); {ld && -
-
- - -
-
- {#each CUES as cue (cue)} - - {/each} -
+
+ +
diff --git a/site/public/api/v1/en/p/988.txt b/site/public/api/v1/en/p/988.txt index a46e1cd4..c797387f 100644 --- a/site/public/api/v1/en/p/988.txt +++ b/site/public/api/v1/en/p/988.txt @@ -1,13 +1,12 @@ -Use @codeatlasdev/ui demo #988 "Hear it" (feedback/sound). -What it shows: A sound toggle with volume, a profile picker (system, glass, brutalism, terminal) and a board of buttons that each play one cue. When to use: Let people audition a sound profile before turning interface sound on in an app. +Use @codeatlasdev/ui demo #988 "SliderField" (inputs/slider). +What it shows: Two labelled sliders with an editable number and a unit: radius and type scale. When to use: Tuning a numeric setting where both scrubbing and typing an exact value matter. Spec: -- `` next to a `SegmentedControl` bound to the profile -- `$effect(() => sound.use(PROFILES[profile]))` pins the picked profile and `onDestroy` releases it -- A `role="group"` board of outlined small `Button` items, each with `sound={cue}` for one cue name -- The cues are `press`, `toggle-on`, `toggle-off`, `success`, `error`, `warning`, `open`, `close` and `tab` +- Two ` diff --git a/site/src/components/theme/Controls.svelte b/site/src/components/theme/Controls.svelte deleted file mode 100644 index 438ca9f5..00000000 --- a/site/src/components/theme/Controls.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - -{#snippet label(text: string, value?: string | number)} -
{text}{#if value !== undefined}{value}{/if}
-{/snippet} - -
-
-

Design style

- ({ value: s.id, label: s.label, description: s.description }))} - onchange={(v) => selectStyle(v as DesignStyleId)} - /> -
- -
-

Color

- {@render label('Scheme preview')} - setScheme(v as 'light' | 'dark')} - items={[ - { value: 'light', label: 'Light', icon: Sun }, - { value: 'dark', label: 'Dark', icon: Moon }, - ]} - /> - - {@render label('Built-in palettes', activePreset)} -
- {#each THEME_PRESETS as p (p.id)} - patch({ presets: [], defaultPreset: p.id })} - /> - {/each} -
-
- -
-

Shape and density

- {@render label('Surface style')} - patch({ motion: { ...motion, easing: v as never } })} /> - {@render label('Icon stroke', def.iconStroke ?? 1.75)} - patch({ iconStroke: v })} /> - {@render label('Paper')} - patch({ paper: v as never })} items={opts(PAPERS)} /> - {@render label('Noise', def.noise ?? 0)} - patch({ noise: v })} /> -
- - -
- - diff --git a/site/src/components/theme/Preview.svelte b/site/src/components/theme/Preview.svelte deleted file mode 100644 index 08840924..00000000 --- a/site/src/components/theme/Preview.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - - - {@html ``} - - - -
- - {#snippet headerActions()} - - {/snippet} -
-

Invite your team

-

- Every control in this card reads its radius, density, border, type and motion from the - theme you are editing. -

- - - -
- - - - -
-
- - - -
-
-
- - - - Changes apply to this preview only. - - - - - MemberRoleStatus - - - {#each rows as r} - - {r.who} - {r.role} - - - {/each} - - - -
- - diff --git a/site/src/components/theme/ThemeEditor.svelte b/site/src/components/theme/ThemeEditor.svelte deleted file mode 100644 index 30d44d75..00000000 --- a/site/src/components/theme/ThemeEditor.svelte +++ /dev/null @@ -1,111 +0,0 @@ - - -{#snippet head(title: string, sub?: string)} -
-
- {title} - {#if sub}{sub}{/if} -
-
-{/snippet} - -{#snippet controls()} - -{/snippet} -{#snippet preview()} - -{/snippet} -{#snippet code()} -
-{/snippet} - -{#if wide} -
-
- {@render head('Controls', DESIGN_STYLES[state.style].label)} -
{@render controls()}
-
-
- {@render head('Preview', 'Scoped to this panel')} -
{@render preview()}
-
-
- {@render head('Code', 'Paste into src/lib/theme.ts')} -
{@render code()}
-
-
-{:else} - -
- - Controls - Preview - Code - -
- {@render controls()} - {@render preview()} - {@render code()} -
-{/if} - - diff --git a/site/src/components/theme/ThemeStudio.svelte b/site/src/components/theme/ThemeStudio.svelte new file mode 100644 index 00000000..17c0a116 --- /dev/null +++ b/site/src/components/theme/ThemeStudio.svelte @@ -0,0 +1,309 @@ + + + + +{#snippet toolbar()} + +{/snippet} + +{#snippet stageBar()} +
+ {#if wide} + ({ value: s.id, label: s.label }))} bind:value={screen} /> + {:else} +
+