diff --git a/.agents/skills/frontend/nodejs-setup/SKILL.md b/.agents/skills/frontend/nodejs-setup/SKILL.md index dec7c6c0..b6c20430 100644 --- a/.agents/skills/frontend/nodejs-setup/SKILL.md +++ b/.agents/skills/frontend/nodejs-setup/SKILL.md @@ -63,8 +63,9 @@ pnpm add -w -D # root tooling only - Cross-package references use the workspace protocol: `"@visionset/annotator": "workspace:*"`. - `@visionset/annotator` keeps `react` as an **optional peer dependency** — never promote it to a hard dependency; the core must stay usable without React. -- `@visionset/ui-core` UI primitives are **Radix + lucide only**. Do not add another component - library. +- `@visionset/ui-core` UI primitives are **Radix behaviour + shadcn Nova styling, iconed with + Tabler**. Do not add another component library, and do not reach for `lucide-react` in new + code — it survives only in `src/annotator/`, as migration debt. - `@visionset/app` is `private: true` — never published to npm; its bundle ships inside the Python wheel. diff --git a/AGENTS.md b/AGENTS.md index 19da2040..1e391beb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ client of the same SDK, and the release artifact is a plain `pip` package. | Component | Location | Stack | | --- | --- | --- | | Python distribution | `src/visionset/` | Python 3.12+, pydantic v2, FastAPI, Typer, MCP, SQLAlchemy, uv | -| Frontend workspace | `frontend/` | Node 24, pnpm, TypeScript, React 19, Vite, vitest, Radix + lucide | +| Frontend workspace | `frontend/` | Node 24, pnpm, TypeScript, React 19, Vite, vitest, Radix + Tabler | | Dev infra | `docker/` | Docker Compose (dev only) | See `README.md` for the monorepo map and `CONTRIBUTING.md` for the full check list. diff --git a/DESIGN.md b/DESIGN.md index e92896e2..46dce52c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,16 +31,32 @@ CLI's own output. | File | Role | | --- | --- | -| [`frontend/ui-core/components.json`](frontend/ui-core/components.json) | The decoded preset configuration shadcn's own tools read | +| [`frontend/ui-core/components.json`](frontend/ui-core/components.json) | The preset properties shadcn's own tools read — the fields its config schema defines, and no others | | [`frontend/ui-core/src/styles.css`](frontend/ui-core/src/styles.css) | The tokens that run — `:root`, `.dark`, `@theme inline`, the base layer | | [`frontend/ui-core/src/tokens.ts`](frontend/ui-core/src/tokens.ts) | The TypeScript mirror, for a caller that cannot read CSS | +**One intent, three layers — and they do not hold the same fields.** `components.json` +carries only what shadcn's config schema defines (`style`, `tailwind.baseColor`, +`iconLibrary`, `menuColor`, `menuAccent`, `rsc`/`tsx`/`rtl`, the aliases, the registries). +That schema is **strict**: a decoded property it has no field for is *rejected*, not +ignored, so the file cannot be made to restate the whole preset even as documentation. +Everything else the preset decides — the radius, both fonts, the chart palette, every +colour — is a *value*, and values live in `styles.css`. A decoded property missing from +`components.json` is therefore the design working as intended, never drift: + +| Layer | Owns | Radius, as the worked example | +| --- | --- | --- | +| Preset intent | What the code decodes to (`shadcn preset decode b3bXyyPdWj`) | `radius: medium` | +| Runtime | The value that actually paints, in `styles.css` | `--radius: 0.625rem` | +| CLI configuration | The schema-supported fields, in `components.json` | no `radius` field — the schema defines none | + Three machine gates hold this contract, each in one line: `tokens.test.ts` asserts `styles.css` and `tokens.ts` agree, declaration for declaration, and that no retired token has crept back in; `tests/scripts/design_tokens.test.mjs` bans a raw colour in any class string, bans a second `tailwind.config.js`, confines `brand` to its two identity -sites, and re-checks the retired vocabulary by an independent method; `tests/scripts/ -docs_links.test.mjs` keeps every link and heading anchor in this document itself honest. +sites, holds `components.json` to the schema-supported field set, and re-checks the +retired vocabulary by an independent method; `tests/scripts/docs_links.test.mjs` keeps +every link and heading anchor in this document itself honest. Radix stays the behaviour layer under every primitive — this is a visual foundation rewrite, not a component replacement. @@ -184,8 +200,14 @@ this document sanctions, and only where the content's own height is the point. ## Radius -`--radius: 0.625rem` (10px) is the one constant; every other radius step is derived from -it in `@theme inline`, verbatim: +The preset's `radius: medium` materialises here and nowhere else: `--radius: 0.625rem` +(10px) in `styles.css` is the authoritative runtime value, mirrored by `tokens.ts` and +pinned by `tokens.test.ts`. `components.json` holds no radius field — shadcn's config +schema defines none, and being strict it rejects one. This stylesheet is the single place +the medium step is spelled. + +`--radius` is the one constant; every other radius step is derived from it in +`@theme inline`, verbatim: | Step | Formula | Result | | --- | --- | --- | @@ -360,12 +382,12 @@ First-class, and part of every rule above rather than a section to satisfy after - **Ad-hoc geometry that fights Nova.** A control's height, padding, or radius is not a per-screen decision; reaching past the geometry table above for a bespoke size is a design decision to make in this document, not in a component diff. -- **Mixing icon sets in new code.** Tabler is the set, and inside the primitives it is - already the only one: every icon `frontend/ui-core/src/primitives/` draws is - `@tabler/icons-react` — the select's chevrons and check, the dialog's close — and no - file there imports `lucide-react` at all. Lucide survives at *screen* level, in views - this rewrite did not open, and that is the whole of the remaining debt: a screen still - importing it is a file to migrate, not a precedent to follow. +- **Mixing icon sets in new code.** Tabler is the set, and across the primitives *and* + the screens it is already the only one: every icon `frontend/ui-core/src/primitives/` + and `frontend/ui-core/src/screens/` draws is `@tabler/icons-react`, and neither + directory imports `lucide-react` at all. Lucide survives in exactly one place — + `frontend/ui-core/src/annotator/` — and that is the whole of the remaining debt: a file + there still importing it is a file to migrate, not a precedent to follow. - **Brand in a functional control.** Robomous coral is identity — the wordmark and its styleguide swatch, nothing else. A functional control reaching for `brand` is a semantic-colour violation regardless of how many other sites already use it correctly. diff --git a/docs/content/architecture/frontend/ui-core.md b/docs/content/architecture/frontend/ui-core.md index 303999b7..8205df2d 100644 --- a/docs/content/architecture/frontend/ui-core.md +++ b/docs/content/architecture/frontend/ui-core.md @@ -76,11 +76,15 @@ whenever its asserted type is and `tsc` cannot see the mismatch. `styles.css` is the shadcn preset `b3bXyyPdWj` (style `nova` on the Radix base, base colour `neutral`, chart palette `orange`, icons `tabler`, Inter body / Geist heading fonts, radius `medium`, menu `inverted`/`subtle`) - the CLI's own -generated output, transcribed verbatim, plus four VisionSet extensions -(`stage`, `brand`, `success`, `warning`) added through shadcn's own extension -convention. `components.json` (`style: "radix-nova"`, `iconLibrary: "tabler"`, -`menuColor: "inverted"`, `menuAccent: "subtle"`) is the decoded configuration -those tools read. `tokens.ts` is the TypeScript mirror for a caller that +generated output, transcribed verbatim, plus five VisionSet extension roles +(`stage`, `brand`, `success`, `warning`, `origin-*`) added through shadcn's own +extension convention. `components.json` (`style: "radix-nova"`, +`iconLibrary: "tabler"`, `menuColor: "inverted"`, `menuAccent: "subtle"`) holds +the preset properties shadcn's own tools read - the fields its config schema +defines, and no others. The schema is strict, so the properties it has no field +for - the radius, the fonts, the chart palette, every colour - are values +carried by `styles.css` instead; see [`DESIGN.md`](../../../../DESIGN.md)'s +Source of Truth for the three layers. `tokens.ts` is the TypeScript mirror for a caller that cannot read CSS. Both themes - light and dark - are declared in full from the preset, so `bg-primary` in a component here and `bg-primary` in a screen mean the same colour by construction. There is no `tailwind.config.js` in this @@ -89,8 +93,9 @@ repository and there must not be one - the tokens would acquire a second home. Three gates hold this: `tokens.test.ts` asserts `styles.css` and `tokens.ts` agree, declaration for declaration, and that no retired token has returned; `tests/scripts/design_tokens.test.mjs` scans every tracked frontend file for a -raw colour in a class string, refuses a second `tailwind.config.js`, and -confines `brand` to its two identity sites; `tests/scripts/docs_links.test.mjs` +raw colour in a class string, refuses a second `tailwind.config.js`, confines +`brand` to its two identity sites, and holds `components.json` to the +schema-supported field set; `tests/scripts/docs_links.test.mjs` keeps [`DESIGN.md`](../../../../DESIGN.md)'s own cross-references honest. ## Libraries @@ -101,7 +106,7 @@ visual-design rule. The current choices: | Concern | Choice | | --- | --- | | UI primitives | Radix (+ shadcn-style composition with `cva` and `cn`) - the open-code shadcn maintenance model is the direction: a primitive is VisionSet-owned source in `frontend/ui-core/src/primitives/`, edited directly, not a package dependency upgraded blindly | -| Icons | `@tabler/icons-react` is the target for new and touched components; `lucide-react` remains in components this rewrite did not touch, as migration debt | +| Icons | `@tabler/icons-react` is the set: the primitives and the screens draw nothing else. `lucide-react` remains only in `src/annotator/`, as migration debt | | Styling | Tailwind v4, CSS-first `@theme`, on the shadcn preset `b3bXyyPdWj` - no `tailwind.config.js`, ever | | Toasts | sonner | | Component tests | vitest + jsdom + @testing-library/react | diff --git a/frontend/ui-core/package.json b/frontend/ui-core/package.json index e5998638..9dcd4a77 100644 --- a/frontend/ui-core/package.json +++ b/frontend/ui-core/package.json @@ -1,7 +1,7 @@ { "name": "@visionset/ui-core", "version": "0.0.1-beta.2", - "description": "VisionSet domain components, design tokens, and the generated typed API client. UI primitives: Radix + lucide only.", + "description": "VisionSet domain components, design tokens, and the generated typed API client. UI primitives: Radix behaviour under shadcn Nova styling, with Tabler icons.", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/tests/scripts/design_tokens.test.mjs b/tests/scripts/design_tokens.test.mjs index f8b26d8d..142a523f 100644 --- a/tests/scripts/design_tokens.test.mjs +++ b/tests/scripts/design_tokens.test.mjs @@ -269,6 +269,55 @@ test("the retired foundation vocabulary is absent from the stylesheet", () => { ); }); +/** + * `components.json` carries the preset properties shadcn's own tools read, and + * only those: the fields its config schema defines. The schema is **strict** — + * `rawConfigSchema.safeParse` answers `unrecognized_keys` for anything else — so + * a decoded preset property the schema has no field for cannot be added here + * even as documentation. It would not be ignored; it would break every `shadcn` + * invocation that reads the file. + * + * `radius` is the property that keeps inviting the mistake: the preset decodes to + * `radius: medium`, and the obvious repair for "the config does not say so" is to + * write it in. The medium step's one home is `styles.css`'s `--radius: 0.625rem` + * (asserted by `tokens.test.ts`); this test is the other half, refusing the field + * that would look like a second home while doing nothing. Keys rather than a + * count, so a failure names what moved. + */ +const CONFIG_PATH = "frontend/ui-core/components.json"; +const SCHEMA_SUPPORTED_KEYS = [ + "$schema", + "aliases", + "iconLibrary", + "menuAccent", + "menuColor", + "registries", + "rsc", + "rtl", + "style", + "tailwind", + "tsx", +]; + +test("components.json holds the schema-supported preset fields, and no others", () => { + const config = JSON.parse(readFileSync(path.join(REPO, CONFIG_PATH), "utf8")); + assert.deepEqual( + Object.keys(config).sort(), + SCHEMA_SUPPORTED_KEYS, + `${CONFIG_PATH} must carry exactly the fields shadcn's strict config schema defines. ` + + "A decoded preset property with no field here belongs in frontend/ui-core/src/styles.css " + + "as a value — see DESIGN.md 'Source of Truth'", + ); + + // The preset's own values, where the schema does have a field for them. + assert.equal(config.style, "radix-nova"); + assert.equal(config.iconLibrary, "tabler"); + assert.equal(config.menuColor, "inverted"); + assert.equal(config.menuAccent, "subtle"); + assert.equal(config.tailwind.baseColor, "neutral"); + assert.equal(config.tailwind.css, "src/styles.css"); +}); + test("the tokens have exactly one home, and it is the stylesheet", () => { const listed = spawnSync("git", ["ls-files", "-z"], { cwd: REPO, encoding: "utf8" }); assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`);