From 64c2d28816bafdbcc1903a60417ed18c9d803ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 20:55:17 -0300 Subject: [PATCH 1/3] chore: start #654 From 48de3c3b05d12fe3154a33fa7734e03d7eddac0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 21:09:15 -0300 Subject: [PATCH 2/3] refactor: the built-in preset ids are declared once, in builtins.ts BuiltinPresetId, the studio's BUILTIN_PRESETS_IDS and the landing theme lab derive from BUILTIN_PRESETS; HeroStage's curated six is typed satisfies BuiltinPresetId. Fail before: the new preset-ids-source guard lists 4 modules that hand-list the ids; pass after (83 related tests, svelte-check 0 errors). Closes #654 --- packages/ui/src/lib/theme/builtins.ts | 10 +++-- packages/ui/src/lib/tokens/presets.ts | 12 +----- packages/ui/tests/preset-ids-source.test.ts | 45 ++++++++++++++++++++ packages/ui/tests/theme-studio.test.ts | 6 --- site/src/components/landing/HeroStage.svelte | 4 +- site/src/components/landing/ThemeLab.svelte | 4 +- site/src/components/theme/studio-state.ts | 17 ++------ 7 files changed, 63 insertions(+), 35 deletions(-) create mode 100644 packages/ui/tests/preset-ids-source.test.ts diff --git a/packages/ui/src/lib/theme/builtins.ts b/packages/ui/src/lib/theme/builtins.ts index 2dd47a19..dfc3bf9c 100644 --- a/packages/ui/src/lib/theme/builtins.ts +++ b/packages/ui/src/lib/theme/builtins.ts @@ -3,7 +3,8 @@ import type { TokenMap } from "../utils/palette-generator.js"; export interface BuiltinPresetSource { - id: "amber" | "ocean" | "crimson" | "cyan" | "violet" | "emerald" | "rose" | "slate"; + /** The one declaration of the built-in ids: `BuiltinPresetId` (tokens/presets.ts) is derived from the data below. */ + id: string; label: string; /** sRGB swatch for pickers and canvas. */ color: `#${string}`; @@ -13,7 +14,10 @@ export interface BuiltinPresetSource { overrides: { light: TokenMap; dark: TokenMap }; } -export const BUILTIN_PRESETS: BuiltinPresetSource[] = [ +/** Keeps each `id` a literal type (`BuiltinPresetId` is derived from it) while checking the shape. */ +const presets = (list: T): T => list; + +export const BUILTIN_PRESETS = presets([ { id: "amber", label: "Amber", @@ -146,4 +150,4 @@ export const BUILTIN_PRESETS: BuiltinPresetSource[] = [ dark: {}, }, }, -]; +]); diff --git a/packages/ui/src/lib/tokens/presets.ts b/packages/ui/src/lib/tokens/presets.ts index 9507c2da..196e4126 100644 --- a/packages/ui/src/lib/tokens/presets.ts +++ b/packages/ui/src/lib/tokens/presets.ts @@ -7,16 +7,8 @@ import { generatePreset } from "../utils/palette-generator.js"; import type { BrandColors, NeutralRamp, SemanticColors } from "./colors.js"; import { neutralRamp } from "./colors.js"; -/** Presets shipped in global.css. */ -export type BuiltinPresetId = - | "amber" - | "ocean" - | "crimson" - | "cyan" - | "violet" - | "emerald" - | "rose" - | "slate"; +/** Presets shipped in global.css: the ids of `BUILTIN_PRESETS` (theme/builtins.ts). */ +export type BuiltinPresetId = (typeof BUILTIN_PRESETS)[number]["id"]; /** A built-in preset or a brand preset registered via `ThemeOptions.presets`. */ export type PresetId = BuiltinPresetId | (string & {}); diff --git a/packages/ui/tests/preset-ids-source.test.ts b/packages/ui/tests/preset-ids-source.test.ts new file mode 100644 index 00000000..3811985f --- /dev/null +++ b/packages/ui/tests/preset-ids-source.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { BUILTIN_PRESETS } from "../src/lib/theme/builtins.ts"; + +/** + * #654: the eight built-in preset ids were written out in builtins.ts (the source), again as the + * `BuiltinPresetId` union in tokens/presets.ts, as a literal array in the theme studio state and in the landing + * theme lab, so adding a preset meant four edits and the three copies drifted silently. `BUILTIN_PRESETS` + * (theme/builtins.ts) is the one place that names them; everything else derives. + */ +const ROOT = join(import.meta.dir, "../../.."); +const OWNER = "packages/ui/src/lib/theme/builtins.ts"; +// A curated selection of six for the hero, typed `satisfies readonly BuiltinPresetId[]`: it is not a copy of the set. +const CURATED = new Set(["site/src/components/landing/HeroStage.svelte"]); + +function* sources(dir: string): Generator { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) { + if ( + !["node_modules", "dist", ".svelte-kit", "__screenshots__", "__traces__"].includes(e.name) + ) + yield* sources(p); + } else if (/\.(ts|svelte|astro)$/.test(e.name) && !/\.test\./.test(e.name)) yield p; + } +} + +describe("built-in preset ids are named once (#654)", () => { + it("no module but builtins.ts lists the built-in ids", () => { + const ids = BUILTIN_PRESETS.map((p) => p.id); + // three or more distinct built-in ids as string literals in one run: a hand-written list or union + const quoted = new RegExp(`(?:["'](?:${ids.join("|")})["'][\\s,|]*){3,}`); + const hits: string[] = []; + for (const root of ["packages/ui/src", "packages/ui/scripts", "site/src"]) + for (const f of sources(join(ROOT, root))) + if ( + relative(ROOT, f) !== OWNER && + !CURATED.has(relative(ROOT, f)) && + quoted.test(readFileSync(f, "utf8")) + ) + hits.push(relative(ROOT, f)); + expect(hits.sort()).toEqual([]); + }); +}); diff --git a/packages/ui/tests/theme-studio.test.ts b/packages/ui/tests/theme-studio.test.ts index 5ac77bd2..c9ddb50f 100644 --- a/packages/ui/tests/theme-studio.test.ts +++ b/packages/ui/tests/theme-studio.test.ts @@ -21,7 +21,6 @@ import { FONTS, familyOf, stackOf } from "../../../site/src/components/theme/fon import { LOOKS } from "../../../site/src/components/theme/looks.js"; import { extractCode } from "../../../site/src/components/theme/panels/output-model.js"; import { - BUILTIN_PRESETS_IDS, COALESCE_MS, commit, createHistory, @@ -36,7 +35,6 @@ import { 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"; import { DESIGN_STYLES, designStyle } from "../src/lib/theme/styles.js"; @@ -46,10 +44,6 @@ const edit = (p: Partial): StudioState => ({ }); 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", diff --git a/site/src/components/landing/HeroStage.svelte b/site/src/components/landing/HeroStage.svelte index bd5f13e5..d7d670d3 100644 --- a/site/src/components/landing/HeroStage.svelte +++ b/site/src/components/landing/HeroStage.svelte @@ -17,12 +17,14 @@ Toaster, colorScheme, toast, + type BuiltinPresetId, type SurfaceStyle, } from "@codeatlasdev/ui"; import { Activity, Boxes, Gauge, Layers, Rocket, Users } from "@lucide/svelte"; import { onMount } from "svelte"; - const PRESETS = ["ocean", "violet", "emerald", "rose", "cyan", "amber"] as const; + // A curated order of six built-ins for the hero, not the full set: a rename of one fails the type check. + const PRESETS = ["ocean", "violet", "emerald", "rose", "cyan", "amber"] as const satisfies readonly BuiltinPresetId[]; const STYLES: SurfaceStyle[] = ["glass", "frosted", "solid", "flat"]; let presetIdx = $state(0); diff --git a/site/src/components/landing/ThemeLab.svelte b/site/src/components/landing/ThemeLab.svelte index b57b5c8b..3d607230 100644 --- a/site/src/components/landing/ThemeLab.svelte +++ b/site/src/components/landing/ThemeLab.svelte @@ -2,10 +2,10 @@ // Theme chapter: pick any built-in preset or paste a hex; the preview below is a real set of // package components re-themed on its own wrapper. Brand presets are generated in the browser // with the package's own palette generator, the same function defineTheme() uses at build time. - import { Button, Chip, Card, Progress, SegmentedControl, SwatchGroup, Switch } from "@codeatlasdev/ui"; + import { BUILTIN_PRESETS, Button, Chip, Card, Progress, SegmentedControl, SwatchGroup, Switch } from "@codeatlasdev/ui"; import { generatePreset, hexToOklch, presetVars } from "@codeatlasdev/ui/utils/palette-generator"; - const BUILTIN = ["amber", "ocean", "crimson", "cyan", "violet", "emerald", "rose", "slate"] as const; + const BUILTIN = BUILTIN_PRESETS.map((p) => p.id); const STYLES = ["glass", "frosted", "solid", "flat"] as const; let preset = $state("ocean"); diff --git a/site/src/components/theme/studio-state.ts b/site/src/components/theme/studio-state.ts index 8b99519c..bdf434f2 100644 --- a/site/src/components/theme/studio-state.ts +++ b/site/src/components/theme/studio-state.ts @@ -3,6 +3,7 @@ // `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 { BUILTIN_PRESETS } from "../../../../packages/ui/src/lib/theme/builtins"; import type { ColorScheme, Density, @@ -14,20 +15,10 @@ import type { } 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. + * Ids of the built-in presets, derived from the package's one list. Imported from `theme/builtins` rather than the + * barrel (which pulls Svelte components) so this module stays testable under plain `bun test`. */ -export const BUILTIN_PRESETS_IDS = [ - "amber", - "ocean", - "crimson", - "cyan", - "violet", - "emerald", - "rose", - "slate", -]; +export const BUILTIN_PRESETS_IDS: string[] = BUILTIN_PRESETS.map((p) => p.id); /** Preset id the studio gives the user's own brand. Never clashes with a built-in. */ export const STUDIO_PRESET = "studio"; From 5855e5fc83e9c07147c1ab60188f7ef2c7c5369e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 22:04:35 -0300 Subject: [PATCH 3/3] chore: regenerate lockfile and manifest after rebase --- .agents/skills/codeatlas-ui/API.md | 51 +++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index feed74d8..87a2a243 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -3831,7 +3831,48 @@ function applyMove(lists: Record, e: DndDropEvent, key: (item: T `import { BUILTIN_PRESETS } from "@codeatlasdev/ui"` ```ts -const BUILTIN_PRESETS: BuiltinPresetSource[] +const BUILTIN_PRESETS: { + 0: { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; readonly hue: 55; reado…; + 1: { readonly id: "ocean"; readonly label: "Ocean"; readonly color: "#2b5fd9"; readonly hue: 260; read…; + 2: { readonly id: "crimson"; readonly label: "Crimson"; readonly color: "#c00020"; readonly hue: 26; r…; + 3: { readonly id: "cyan"; readonly label: "Cyan"; readonly color: "#0094a8"; readonly hue: 193; readon…; + 4: { readonly id: "violet"; readonly label: "Violet"; readonly color: "#8b3ec8"; readonly hue: 295; re…; + 5: { readonly id: "emerald"; readonly label: "Emerald"; readonly color: "#1a7a50"; readonly hue: 152; …; + 6: { readonly id: "rose"; readonly label: "Rose"; readonly color: "#c8405a"; readonly hue: 350; readon…; + 7: { readonly id: "slate"; readonly label: "Slate"; readonly color: "#3c5a8a"; readonly hue: 220; read…; + length: 8; + toString: () => string; + toLocaleString: { (): string; (locales: string | string[], options?: Intl.NumberFormatOptions & Intl.DateTimeFormat…; + concat: { (...items: ConcatArray<{ readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"…; + join: (separator?: string) => string; + slice: (start?: number, end?: number) => ({ readonly id: "amber"; readonly label: "Amber"; readonly color:…; + indexOf: (searchElement: { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; readonl…; + lastIndexOf: (searchElement: { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; readonl…; + every: { (callbackfn: (value: { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15";…; + filter: { ArrayIterator<[number, { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47…; + keys: () => ArrayIterator; + values: () => ArrayIterator<{ readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; rea…; + includes: (searchElement: { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; readonl…; + flatMap: (callback: (this: This, value: { readonly id: "amber"; readonly label: "Amber"…; + flat: (this: A, depth?: D | undefined) => FlatArray[]; + at: (index: number) => { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; read…; + findLast: { ({ readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; readonly hue: 55…; + toSorted: (compareFn?: ((a: { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; reado…; + toSpliced: { (start: number, deleteCount: number, ...items: ({ readonly id: "amber"; readonly label: "Amber"; …; + with: (index: number, value: { readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; …; + __@iterator@288: () => ArrayIterator<{ readonly id: "amber"; readonly label: "Amber"; readonly color: "#c47a15"; rea…; + readonly __@unscopables@290: { readonly [x: number]: boolean | undefined; readonly length?: boolean | undefined; toString?: bool…; +} ``` ### collectPaths @@ -4879,8 +4920,8 @@ Types: | `AvatarTokens` | `interface AvatarTokens extends AvatarFallbackTokens { size?: Partial< Record< "xs" \| "sm" \| "md" \| "lg" \| "xl", Pick > >; }` | | | `BrandColors` | `interface BrandColors { main: string; light: string; dark: string; contrast: string; }` | | | `Breakpoint` | `type Breakpoint = "xs" \| keyof typeof SCALE;` | | -| `BuiltinPresetId` | `type BuiltinPresetId = \| "amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate";` | Presets shipped in global.css. | -| `BuiltinPresetSource` | `interface BuiltinPresetSource { id: "amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate"; label: string; color: `#${string}`; hue: number; chroma: number; lightL: number; overrides: { light: TokenMap; dark: TokenMap }; }` | | +| `BuiltinPresetId` | `type BuiltinPresetId = (typeof BUILTIN_PRESETS)[number]["id"];` | Presets shipped in global.css: the ids of `BUILTIN_PRESETS` (theme/builtins.ts). | +| `BuiltinPresetSource` | `interface BuiltinPresetSource { id: string; label: string; color: `#${string}`; hue: number; chroma: number; lightL: number; overrides: { light: TokenMap; dark: TokenMap }; }` | | | `ButtonSizeTokens` | `interface ButtonSizeTokens { height?: CssValue; paddingX?: CssValue; fontSize?: CssValue; weight?: CssValue; tracking?: CssValue; }` | Typography and box tokens of one Button size. | | `ButtonTokens` | `interface ButtonTokens extends ButtonSizeTokens { weight?: CssValue; tracking?: CssValue; leading?: CssValue; gap?: CssValue; ghostBorderWidth?: CssValue; disabledBg?: CssValue; touchHeight?: CssValue; sm?: ButtonSizeTokens; md?: ButtonSizeTokens; lg?: ButtonSizeTokens; }` | | | `CardTokens` | `interface CardTokens { bg?: CssValue; radius?: CssValue; border?: CssValue; shadow?: CssValue; }` | | @@ -5013,7 +5054,7 @@ Types: | Field | Type | Notes | |---|---|---| -| `id` | `"amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate"` | | +| `id` | `string` | The one declaration of the built-in ids: `BuiltinPresetId` (tokens/presets.ts) is derived from the data below. | | `label` | `string` | | | `color` | ``#${string}`` | sRGB swatch for pickers and canvas. | | `hue` | `number` | | @@ -7532,7 +7573,7 @@ Types: | Type | Declaration | Notes | |---|---|---| | `BrandColors` | `interface BrandColors { main: string; light: string; dark: string; contrast: string; }` | | -| `BuiltinPresetId` | `type BuiltinPresetId = \| "amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate";` | Presets shipped in global.css. | +| `BuiltinPresetId` | `type BuiltinPresetId = (typeof BUILTIN_PRESETS)[number]["id"];` | Presets shipped in global.css: the ids of `BUILTIN_PRESETS` (theme/builtins.ts). | | `ElevationLevel` | `type ElevationLevel = keyof typeof elevation;` | | | `NeutralRamp` | `interface NeutralRamp { surfaceLight: SurfaceColors; surfaceDark: SurfaceColors; textLight: TextColors; textDark: TextColors; outlineLight: OutlineColors; outlineDark: OutlineColors; }` | | | `OutlineColors` | `interface OutlineColors { subtle: string; default: string; strong: string; }` | |