diff --git a/README.md b/README.md index 10ff72e..8aad8f3 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ every consumer, and `shadcn` is the CLI. ```text src/components/ the components, one file each src/hooks/ the hooks the components are built on -src/theme/ styles.css, the single visual contract; shadcn.css, shadcn's layer; tokens.ts +src/theme/ styles.css, the single visual contract; shadcn.css, shadcn's layer src/index.ts the public surface, exported by name components.json how the shadcn CLI installs a new component here tests/ behaviour tests, the token and shadcn-layer contracts, the packed-consumer test diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8fbe9e8..888c92e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -86,7 +86,7 @@ that file; a utility we need lives in `styles.css` after the import. A token is a role. Declare it in `:root` and `.dark` in `src/theme/styles.css`, expose it in `@theme inline` as `--color-: var(--)`, add it to `ROLE_NAMES` in -`tests/theme/tokens.test.ts` and to both maps in `src/theme/tokens.ts`, and give it a swatch in +`tests/theme/tokens.test.ts`, and give it a swatch in `examples/catalog/src/components/Swatches.astro`. If it is not a role — if the name describes a pigment rather than a purpose — it does not belong here. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8cb82c4..5ef80c3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -29,7 +29,6 @@ src/components/*.tsx the components, one file each, exported by name from src/ src/hooks/*.tsx the hooks the components are built on (useIsMobile) src/theme/styles.css the one visual contract: tokens, the closed colour namespace, base layer src/theme/shadcn.css shadcn's utility and variant layer, vendored byte for byte, never edited -src/theme/tokens.ts a runtime mirror of the token values, for callers that cannot read CSS components.json the shadcn CLI's configuration: style, stylesheet, aliases tests/ behaviour tests (jsdom), token and shadcn-layer contracts, consumer test examples/catalog/ the documentation site (Astro), importing the real package @@ -84,10 +83,10 @@ is an arbitrary value — `bg-[#eb5a47]`, `ring-[var(--x)]`. ESLint refuses it selectors. The Button's hover step, a `color-mix` of two roles, names no colour of its own and stays legal. -[`src/theme/tokens.ts`](../src/theme/tokens.ts) mirrors the values as TypeScript for a ``, -an `` or a styleguide that has to print a value. It is a compatibility mirror: CSS is -authoritative, `var(--foreground)` or `getComputedStyle` is preferred where the DOM is available, -and `tests/theme/tokens.test.ts` holds the two in agreement declaration for declaration. +There is no TypeScript copy of these values. A caller that needs one as a string reads it from the +document with `getComputedStyle(element).getPropertyValue("--foreground")`, or writes +`var(--foreground)` and lets CSS resolve it. A second copy in TypeScript is a second thing to keep +true, and the stylesheet is the only one that paints. ### Where the brand is diff --git a/docs/MIGRATION-0.3.md b/docs/MIGRATION-0.3.md index 5a6df98..91c923d 100644 --- a/docs/MIGRATION-0.3.md +++ b/docs/MIGRATION-0.3.md @@ -1,8 +1,9 @@ # Migrating to @robomous/ui-core 0.3 0.3 makes the design system's invariants structural and removes the machinery that used to -enforce them. Four things a consumer may have depended on are gone: the `./gates` export, the -`statusTone` exports, the `chart-*` tokens, and the `bg-brand` family of utilities. One thing is +enforce them. Five things a consumer may have depended on are gone: the `./gates` export, the +`statusTone` exports, the `chart-*` tokens, the `bg-brand` family of utilities, and the token +mirror `LIGHT_THEME` / `DARK_THEME` / `THEME` / `cssVar`. One thing is new and may break a screen that leaned on Tailwind's default palette: the colour namespace is closed. Each change is listed with the sites found in the two consumers at the time of writing — `Robomous/VisionSet` and `Robomous/robomous-cloud` — and the exact edit. @@ -49,7 +50,7 @@ Removed. The rules those scanners held are now held elsewhere or no longer need | `statusPaletteIn`, `competingStatusPaletteIn` | Nothing to hold: with the palette closed, `bg-emerald-500` or `text-red-700` produces no CSS. Delete the tests. | | `brandUsagesIn` | Nothing to hold: `bg-brand` produces no CSS. Delete the tests and the `BRAND_SITES` lists. | | `colouredClassesIn` | ESLint. Copy the `no-restricted-syntax` entry from this repository's `eslint.config.js` (two selectors, one regex) into the consumer's flat config. | -| `foundationTokenNames`, `blockBody`, `declarations`, `rawDeclarations` | Keep locally if the extension-mirror test is still wanted; the parsers are thirty lines and live in `tests/theme/tokens.test.ts` here. The foundation names are the keys of `LIGHT_THEME`. | +| `foundationTokenNames`, `blockBody`, `declarations`, `rawDeclarations` | Keep locally if the extension-mirror test is still wanted; the parsers are thirty lines and live in `tests/theme/tokens.test.ts` here. The foundation names are the `ROLE_NAMES` list in that file. | Sites: VisionSet `tests/scripts/design_tokens.test.mjs`, `tests/scripts/design_system.test.mjs`, `frontend/ui-core/src/tokens.test.ts`; robomous-cloud `web/tests/gates.test.ts`, @@ -92,12 +93,27 @@ need nothing. Removed from the stylesheet and from `LIGHT_THEME`/`DARK_THEME`. No consumer painted with them. A product that charts declares its own series colours as an extension. +## 6. `LIGHT_THEME`, `DARK_THEME`, `THEME`, `cssVar` + +Removed, along with `src/theme/tokens.ts`. They were a TypeScript copy of the values in +`styles.css`, kept for a caller that could not read CSS, and held in step by a test. No consumer +imported them, and a second copy of the palette is a second thing that can go stale. + +The stylesheet is now the only copy. Where a value is genuinely needed as a string, read it from +the document, which has the further advantage of answering for the theme the reader is in: + +```ts +const ink = getComputedStyle(document.documentElement).getPropertyValue("--foreground"); +``` + +Everywhere else write `var(--foreground)`. `cssVar("popover")` returned `"var(--popover)"` and was +three lines; inline the template string if a call site really wants it. + +Sites: none found in either consumer. + ## Unchanged - Every component and subcomponent export. The `sidebar-*` roles. -- `LIGHT_THEME`, `DARK_THEME`, `THEME`, `cssVar`: kept as a runtime mirror (with `success`, - `warning`, `info` and `overlay` added and the `chart-*` keys removed). Prefer `var(--role)` or - `getComputedStyle` where the DOM is available. - `toast` and `Toaster`; `cn`. - `import "@robomous/ui-core/styles.css"` and a consumer `@source` for its own sources. diff --git a/docs/components/README.md b/docs/components/README.md index efab8c7..b6c76a5 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -51,5 +51,5 @@ generated API table, in the docs site under `examples/catalog/` (`pnpm docs:dev` | ToggleGroup | `ToggleGroup`, `ToggleGroupItem` | Radix ToggleGroup | `type`: `single` (radios) or `multiple` (pressed buttons). `spacing={0}` welds the segments into one control; `orientation`. Items inherit the group's `variant` and `size`. | | Tooltip | `TooltipProvider`, `Tooltip`, `TooltipTrigger`, `TooltipContent` | Radix Tooltip | `delayDuration` defaults to `0`. Painted `bg-foreground text-background`. | -Also exported: `cn`, the `useIsMobile` hook the Sidebar decides its mode with, and the token mirror -`LIGHT_THEME`, `DARK_THEME`, `THEME`, `cssVar`. +Also exported: `cn`, and the `useIsMobile` hook the Sidebar decides its mode with. The tokens +themselves are not exported: they live in `src/theme/styles.css` and are reached as `var(--role)`. diff --git a/examples/catalog/src/content/sections/theming.mdx b/examples/catalog/src/content/sections/theming.mdx index 6cb025f..5b5658c 100644 --- a/examples/catalog/src/content/sections/theming.mdx +++ b/examples/catalog/src/content/sections/theming.mdx @@ -81,21 +81,15 @@ A consumer may not reach past its own layer to restyle a component from this pac component here may not encode a product's decision. An extension that turns out to be universal is a change to this package, with the reason written down. -## The runtime mirror +## Reading a value at runtime -[`src/theme/tokens.ts`](https://github.com/Robomous/ui-core/blob/main/src/theme/tokens.ts) mirrors -the values as TypeScript, for a caller that cannot read CSS — a ``, an ``, or a -styleguide printing a value beside a swatch. `LIGHT_THEME` and `DARK_THEME` are the two token maps, -`THEME` carries the two provenance facts a colour string alone cannot (`radius`, `fontSans`), and -`cssVar(name)` turns a role into `var(--)`. +There is no TypeScript copy of the tokens to import. A ``, an `` or anything else that +needs a role as a string reads it from the document, which also means it follows the theme the +reader is actually in: ```tsx -import { cssVar, LIGHT_THEME } from "@robomous/ui-core"; - -LIGHT_THEME.success; // "oklch(0.508 0.118 165.612)" -cssVar("popover"); // "var(--popover)" +const ink = getComputedStyle(document.documentElement).getPropertyValue("--foreground"); ``` -It is a compatibility mirror, not a source: CSS is authoritative, `var(--foreground)` or -`getComputedStyle` is preferred wherever the DOM is available, and a test holds the two in -agreement declaration for declaration. +Everywhere else, write `var(--foreground)` and let CSS resolve it. The stylesheet is the only copy +of these values, so there is nothing that can fall out of step with it. diff --git a/src/index.ts b/src/index.ts index 54a414a..2f1f41b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,9 +15,6 @@ * utility (`bg-success`, `text-warning`, `border-info`), not an export. */ -// The runtime mirror of the stylesheet's tokens, for a caller that cannot read CSS. -export { cssVar, DARK_THEME, LIGHT_THEME, THEME } from "./theme/tokens.js"; - export { cn } from "cn"; // The components — Radix, Base UI, cmdk and vaul behaviour, iconed with lucide. diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts deleted file mode 100644 index 0f53fe5..0000000 --- a/src/theme/tokens.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * The design tokens, as TypeScript — a compatibility mirror, not a source. - * - * `styles.css` is authoritative: Tailwind reads its `:root`, `.dark` and - * `@theme` blocks and every utility comes out of them. This module exists for - * the runtime caller that cannot read CSS — a `` or an `` that - * needs a colour as a string, or a styleguide printing a value beside a swatch. - * Prefer `var(--foreground)` or - * `getComputedStyle(element).getPropertyValue("--foreground")` where the DOM - * is available; reach for these maps only when it is not. - * - * `tests/theme/tokens.test.ts` parses the stylesheet and asserts the two agree - * declaration for declaration, so the mirror cannot drift silently. - */ - -export const LIGHT_THEME: Readonly> = Object.freeze({ - background: "oklch(1 0 0)", - foreground: "oklch(0.2 0 0)", - card: "oklch(1 0 0)", - "card-foreground": "oklch(0.145 0 0)", - popover: "oklch(1 0 0)", - "popover-foreground": "oklch(0.145 0 0)", - primary: "oklch(0.2 0 0)", - "primary-foreground": "oklch(0.985 0 0)", - secondary: "oklch(0.97 0 0)", - "secondary-foreground": "oklch(0.269 0 0)", - muted: "oklch(0.97 0 0)", - "muted-foreground": "oklch(0.556 0 0)", - accent: "oklch(0.97 0 0)", - "accent-foreground": "oklch(0.205 0 0)", - success: "oklch(0.508 0.118 165.612)", - warning: "oklch(0.555 0.163 48.998)", - info: "oklch(0.5 0.134 242.749)", - destructive: "oklch(0.577 0.245 27.325)", - border: "oklch(0.922 0 0)", - input: "oklch(0.922 0 0)", - ring: "oklch(0.708 0 0)", - overlay: "oklch(0 0 0 / 10%)", - sidebar: "oklch(0.985 0 0)", - "sidebar-foreground": "oklch(0.145 0 0)", - "sidebar-primary": "oklch(0.205 0 0)", - "sidebar-primary-foreground": "oklch(0.985 0 0)", - "sidebar-accent": "oklch(0.97 0 0)", - "sidebar-accent-foreground": "oklch(0.205 0 0)", - "sidebar-border": "oklch(0.922 0 0)", - "sidebar-ring": "oklch(0.708 0 0)", - - // Robomous orange (#F5580B). Identity only; a CSS variable, never a utility. - brand: "oklch(0.663 0.205 39.9)", -}); - -export const DARK_THEME: Readonly> = Object.freeze({ - background: "oklch(0.145 0 0)", - foreground: "oklch(0.985 0 0)", - card: "oklch(0.205 0 0)", - "card-foreground": "oklch(0.985 0 0)", - popover: "oklch(0.205 0 0)", - "popover-foreground": "oklch(0.985 0 0)", - primary: "oklch(0.922 0 0)", - "primary-foreground": "oklch(0.205 0 0)", - secondary: "oklch(0.269 0 0)", - "secondary-foreground": "oklch(0.985 0 0)", - muted: "oklch(0.269 0 0)", - "muted-foreground": "oklch(0.708 0 0)", - accent: "oklch(0.269 0 0)", - "accent-foreground": "oklch(0.985 0 0)", - success: "oklch(0.765 0.177 163.223)", - warning: "oklch(0.828 0.189 84.429)", - info: "oklch(0.746 0.16 232.661)", - destructive: "oklch(0.704 0.191 22.216)", - border: "oklch(1 0 0 / 10%)", - input: "oklch(1 0 0 / 15%)", - ring: "oklch(0.556 0 0)", - overlay: "oklch(0 0 0 / 10%)", - sidebar: "oklch(0.205 0 0)", - "sidebar-foreground": "oklch(0.985 0 0)", - "sidebar-primary": "oklch(0.488 0.243 264.376)", - "sidebar-primary-foreground": "oklch(0.985 0 0)", - "sidebar-accent": "oklch(0.269 0 0)", - "sidebar-accent-foreground": "oklch(0.985 0 0)", - "sidebar-border": "oklch(1 0 0 / 10%)", - "sidebar-ring": "oklch(0.556 0 0)", - - brand: "oklch(0.663 0.205 39.9)", -}); - -/** The two provenance facts a colour string alone cannot carry. */ -export const THEME = { - radius: "0.625rem", - fontSans: "'Geist Variable', sans-serif", - fontHeading: "var(--font-sans)", -} as const; - -/** `cssVar("popover")` → `"var(--popover)"` — for a runtime caller that needs a string. */ -export function cssVar(name: string): string { - return `var(--${name})`; -} diff --git a/tests/theme/tokens.test.ts b/tests/theme/tokens.test.ts index 3dd6594..e366cc3 100644 --- a/tests/theme/tokens.test.ts +++ b/tests/theme/tokens.test.ts @@ -1,11 +1,10 @@ /** * @vitest-environment node * - * The token contract. `styles.css` is authoritative and `tokens.ts` is its - * runtime mirror; this suite parses the stylesheet structurally and asserts - * the two agree declaration for declaration, that the colour namespace is - * closed, and that the few stylesheet-level rules the components depend on are - * still there. + * The token contract. `styles.css` is the one home of the tokens, so this + * suite parses it structurally and asserts that both themes declare every role + * and nothing else, that the colour namespace is closed, and that the few + * stylesheet-level rules the components depend on are still there. * * Parsed rather than imported: nothing here evaluates `@theme`, and the * packed-consumer test is where a real Tailwind compiles this file. @@ -16,8 +15,6 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { cssVar, DARK_THEME, LIGHT_THEME, THEME } from "../../src/theme/tokens"; - const STYLESHEET = readFileSync( fileURLToPath(new URL("../../src/theme/styles.css", import.meta.url)), "utf8", @@ -52,7 +49,7 @@ function rawDeclarations(block: string): Map { return map; } -/** The same, keyed WITHOUT the leading `--` — the shape `LIGHT_THEME` uses. */ +/** The same, keyed WITHOUT the leading `--`, which is how a role is named. */ function declarations(block: string): Map { const map = new Map(); for (const [name, value] of rawDeclarations(block)) { @@ -61,8 +58,8 @@ function declarations(block: string): Map { return map; } -// The vocabulary, written out rather than derived from `tokens.ts`, so a -// mistake in the mirror cannot also erase the thing it was supposed to mirror. +// The vocabulary, written out rather than parsed out of the stylesheet, so a +// role deleted there fails here instead of quietly shrinking the expectation. const ROLE_NAMES = [ "background", "foreground", @@ -106,15 +103,11 @@ describe(":root", () => { expect([...root.keys()].sort()).toEqual([...ROLE_NAMES, ...VARIABLE_ONLY, "radius"].sort()); }); - it("agrees with LIGHT_THEME declaration for declaration", () => { - const light = new Map(Object.entries(LIGHT_THEME)); - root.delete("radius"); - expect(Object.fromEntries(root)).toEqual(Object.fromEntries(light)); - }); - - it("pins --radius in both the CSS and THEME", () => { - expect(declarations(blockBody(STYLESHEET, ":root {")).get("radius")).toBe("0.625rem"); - expect(THEME.radius).toBe("0.625rem"); + it("gives every role a value, and pins --radius", () => { + for (const [name, value] of root) { + expect(value, `--${name} is declared empty`).not.toBe(""); + } + expect(root.get("radius")).toBe("0.625rem"); }); }); @@ -125,8 +118,15 @@ describe(".dark", () => { expect([...dark.keys()].sort()).toEqual([...ROLE_NAMES, ...VARIABLE_ONLY].sort()); }); - it("agrees with DARK_THEME declaration for declaration", () => { - expect(Object.fromEntries(dark)).toEqual({ ...DARK_THEME }); + /** + * A role that repeats its light value is the bug this catches: the dark + * theme is a counterpart, not a copy. `background` and `foreground` swap + * ends of the scale, so those two are the cheapest thing to pin. + */ + it("repoints the roles rather than restating them", () => { + const light = declarations(blockBody(STYLESHEET, ":root {")); + expect(dark.get("background")).not.toBe(light.get("background")); + expect(dark.get("foreground")).not.toBe(light.get("foreground")); }); }); @@ -150,9 +150,9 @@ describe("@theme", () => { expect(colours.sort()).toEqual(ROLE_NAMES.map((name) => `--color-${name}`).sort()); }); - it("declares the two font variables from THEME", () => { - expect(inline.get("--font-sans")).toBe(normalize(THEME.fontSans)); - expect(inline.get("--font-heading")).toBe(normalize(THEME.fontHeading)); + it("declares the two font variables", () => { + expect(inline.get("--font-sans")).toBe("'Geist Variable', sans-serif"); + expect(inline.get("--font-heading")).toBe("var(--font-sans)"); }); it("derives every radius step from --radius", () => { @@ -189,10 +189,3 @@ describe("base layer", () => { expect(/h1,\s*h2,\s*h3,\s*h4\s*\{\s*@apply font-heading;\s*\}/.test(STYLESHEET)).toBe(true); }); }); - -describe("cssVar", () => { - it("wraps a bare token name as a CSS var() reference", () => { - expect(cssVar("popover")).toBe("var(--popover)"); - expect(cssVar("brand")).toBe("var(--brand)"); - }); -});