Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<role>: var(--<role>)`, 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.

Expand Down
9 changes: 4 additions & 5 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<canvas>`,
an `<svg>` 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

Expand Down
28 changes: 22 additions & 6 deletions docs/MIGRATION-0.3.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
20 changes: 7 additions & 13 deletions examples/catalog/src/content/sections/theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<canvas>`, an `<svg>`, 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(--<role>)`.
There is no TypeScript copy of the tokens to import. A `<canvas>`, an `<svg>` 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.
3 changes: 0 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
97 changes: 0 additions & 97 deletions src/theme/tokens.ts

This file was deleted.

55 changes: 24 additions & 31 deletions tests/theme/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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",
Expand Down Expand Up @@ -52,7 +49,7 @@ function rawDeclarations(block: string): Map<string, string> {
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<string, string> {
const map = new Map<string, string>();
for (const [name, value] of rawDeclarations(block)) {
Expand All @@ -61,8 +58,8 @@ function declarations(block: string): Map<string, string> {
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",
Expand Down Expand Up @@ -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");
});
});

Expand All @@ -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"));
});
});

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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)");
});
});
Loading