219 lines
10 KiB
Markdown
219 lines
10 KiB
Markdown
# `_schema/` — design-system contracts
|
|||
|
|
|
||
|
|
This directory codifies the structural contracts for design systems.
|
||
|
|
`tokens.schema.ts` re-exports the token contract that every tokenized brand under
|
||
|
|
`design-systems/<brand>/` must satisfy. The canonical runtime copy lives in
|
||
|
|
`packages/contracts/src/design-systems/token-schema.ts` so daemon importers and
|
||
|
|
repo guards consume one schema. `manifest.schema.ts` is the
|
||
|
|
project contract for Design System Project packages. Every bundled catalog
|
||
|
|
entry now ships `manifest.json`, `DESIGN.md`, and `tokens.css`; the daemon keeps
|
||
|
|
`DESIGN.md`-only discovery solely as a compatibility path for older or
|
||
|
|
user-installed folders.
|
||
|
|
|
||
|
|
```
|
||
|
|
_schema/
|
||
|
|
├── manifest.schema.ts ← project manifest schema (TS, machine-enforced when present)
|
||
|
|
├── tokens.schema.ts ← token schema re-export (TS, machine-enforced)
|
||
|
|
├── defaults.css ← A2 fallback values (CSS, human reference)
|
||
|
|
└── AGENTS.md ← this file
|
||
|
|
```
|
||
|
|
|
||
|
|
The TypeScript schemas are the source of truth. `defaults.css` is a
|
||
|
|
human-readable mirror of the A2 `fallback` fields in the token schema
|
||
|
|
and exists so that reviewers can scan real CSS without parsing a TS
|
||
|
|
array — drift between the two is enforced by the `design-system: A2
|
||
|
|
defaults parity` guard. Manifest shape is enforced by
|
||
|
|
`scripts/check-design-system-manifests.ts` for any
|
||
|
|
`design-systems/<brand>/manifest.json` that exists.
|
||
|
|
|
||
|
|
## Project manifest contract
|
||
|
|
|
||
|
|
Design System Project folders use fixed v1 file names:
|
||
|
|
|
||
|
|
- `manifest.json` — machine-readable project entry.
|
||
|
|
- `DESIGN.md` — canonical design prose.
|
||
|
|
- `tokens.css` — canonical compiled tokens.
|
||
|
|
- `design-tokens.json` — optional Design Tokens JSON derived from
|
||
|
|
`tokens.css` + `source/token-contract.report.json`.
|
||
|
|
- `tailwind-v4.css` — optional Tailwind v4 `@theme` CSS derived from
|
||
|
|
`tokens.css`; it must not redefine source values independently.
|
||
|
|
- `components.html` — optional standalone component fixture.
|
||
|
|
- `assets/` — optional brand assets.
|
||
|
|
- `preview/` — optional static preview pages.
|
||
|
|
- `USAGE.md` — optional agent-facing package guide.
|
||
|
|
- `components.manifest.json` — optional rebuildable cache derived from
|
||
|
|
`components.html` and `tokens.css`.
|
||
|
|
- `fonts/` — optional webfont files.
|
||
|
|
- `source/` — optional importer evidence (`scanned-files.json`,
|
||
|
|
`evidence.md`, `tokens.source.json`, `token-contract.report.json`,
|
||
|
|
and `snippets/INDEX.json`).
|
||
|
|
|
||
|
|
The manifest guard validates every bundled package through its `manifest.json`.
|
||
|
|
When rich fields are declared, paths must be safe and present, JSON indexes
|
||
|
|
must parse, and committed `components.manifest.json` files must match a fresh
|
||
|
|
derivation from `components.html` plus `tokens.css`. These fields are also
|
||
|
|
runtime inputs: catalog discovery consumes manifest metadata; prompt
|
||
|
|
composition consumes usage, tokens, component information, import mode, craft
|
||
|
|
bindings, and the manifest-derived pull index; package/static-file routes expose
|
||
|
|
only declared preview and source files. Legacy `DESIGN.md`-only folders remain
|
||
|
|
outside this manifest guard because they have no manifest to validate.
|
||
|
|
|
||
|
|
## Four layers, two questions
|
||
|
|
|
||
|
|
Every shared token answers two questions:
|
||
|
|
|
||
|
|
1. **Who decides the value?** — the brand author (Layer A) or the
|
||
|
|
schema author (Layer B-slot, when the brand has no opinion).
|
||
|
|
2. **What happens if the brand omits it?** — required, fallback, or
|
||
|
|
alias.
|
||
|
|
|
||
|
|
The four layers fall out of those answers:
|
||
|
|
|
||
|
|
| Layer | Who decides | If omitted | Examples |
|
||
|
|
| --- | --- | --- | --- |
|
||
|
|
| **A1-identity** | brand | guard fails | `--bg`, `--fg`, `--accent`, `--font-display` |
|
||
|
|
| **A1-structure** | brand | guard fails | type scale, `--container-max`, `--section-y-*` |
|
||
|
|
| **A2** | brand (with fallback) | guard fails today; derive script fills tomorrow | `--motion-fast`, `--success`, `--space-4`, `--font-mono` |
|
||
|
|
| **B-slot** | brand or schema-suggested alias | guard fails — brand must declare, either as `var(--sibling)` (collapsed) or independent value (richer) | `--fg-2 → var(--fg)`, `--surface-warm → var(--surface)` |
|
||
|
|
|
||
|
|
Brand-specific tokens that fall outside the shared schema are tracked
|
||
|
|
as **C-extensions** in `BRAND_EXTENSIONS` (per-brand allowlist) or
|
||
|
|
`BRAND_EXTENSION_PREFIXES` (global prefix allowlist for whole families
|
||
|
|
like `--tag-bg-*`).
|
||
|
|
|
||
|
|
## Why A2 fails the guard today (and might not later)
|
||
|
|
|
||
|
|
A2 conceptually means "optional with fallback" — but artifacts are
|
||
|
|
generated by agents pasting one brand's `:root` block into a single
|
||
|
|
`<style>`. There is no global stylesheet that loads alongside the
|
||
|
|
brand, so a missing `--motion-fast` resolves to nothing inside the
|
||
|
|
artifact and any `transition: var(--motion-fast)` rule silently breaks.
|
||
|
|
|
||
|
|
Until a future derive script lands and inlines `defaults.css` values
|
||
|
|
into every brand's `tokens.css`, the only safe contract is "every
|
||
|
|
brand must declare every A2 token". The `design-system: A2 required
|
||
|
|
tokens` guard enforces that strictly.
|
||
|
|
|
||
|
|
After the derive script ships, brand authors only need to write the
|
||
|
|
A1 tokens (and any A2 they want to override); the script populates A2
|
||
|
|
slots from `defaults.css`. The guard contract does not change — every
|
||
|
|
final `tokens.css` still contains every A2 token — but the work
|
||
|
|
shifts from human author to script.
|
||
|
|
|
||
|
|
## Why B-slot is required (and what the alias is for)
|
||
|
|
|
||
|
|
Same artifact-paste constraint applies to B-slot tokens. Shared
|
||
|
|
components reference richer tiers via `var(--fg-2)`, `var(--meta)`,
|
||
|
|
`var(--surface-warm)`, `var(--border-soft)` — if a brand omits the
|
||
|
|
slot, those references resolve to nothing and the artifact silently
|
||
|
|
breaks.
|
||
|
|
|
||
|
|
The `aliasTo` field on each B-slot entry is the **schema-suggested
|
||
|
|
default**, not a runtime fallback. A brand with no opinion on the
|
||
|
|
richer tier copies the alias verbatim into its `:root`:
|
||
|
|
|
||
|
|
```
|
||
|
|
--fg-2: var(--fg); /* default brand: 2-level fg */
|
||
|
|
--surface-warm: var(--surface); /* default brand: 2-level surface */
|
||
|
|
```
|
||
|
|
|
||
|
|
A brand that does have the richer tier binds an independent value:
|
||
|
|
|
||
|
|
```
|
||
|
|
--fg-2: #3d3d3a; /* kami brand: dark warm */
|
||
|
|
--surface-warm: #e8e6dc; /* kami brand: warm sand */
|
||
|
|
```
|
||
|
|
|
||
|
|
Either form satisfies the `design-system: B-slot required tokens`
|
||
|
|
guard. The pre-derive-script contract is identical to A2: every
|
||
|
|
brand's `:root` declares every shared slot.
|
||
|
|
|
||
|
|
## C → B-slot → A2 promotion path
|
||
|
|
|
||
|
|
Brand-specific tokens start in `BRAND_EXTENSIONS[brand]`. They earn
|
||
|
|
promotion when a second brand needs the same name:
|
||
|
|
|
||
|
|
```
|
||
|
|
C-extension B-slot A2
|
||
|
|
(one brand declares it) (multiple brands declare, (every brand declares
|
||
|
|
some alias to a sibling) with a sensible default)
|
||
|
|
|
||
|
|
kami: --leading-display → schema: --leading-display → schema: --leading-display
|
||
|
|
aliasTo: var(--leading-tight) fallback: 1.1
|
||
|
|
```
|
||
|
|
|
||
|
|
Concrete promotion rules:
|
||
|
|
|
||
|
|
1. **C → B-slot** when **≥2 brands** declare a token of the same name
|
||
|
|
*and* there is a meaningful sibling to alias to for brands that
|
||
|
|
lack the richer tier. Move the entry from `BRAND_EXTENSIONS` to
|
||
|
|
`TOKEN_SCHEMA` with `layer: "B-slot"` and `aliasTo: "var(--sibling)"`.
|
||
|
|
2. **C → A2** when **≥2 brands** declare a token of the same name
|
||
|
|
*and* a defensible cross-brand fallback exists (no aliasing
|
||
|
|
needed). Move to `TOKEN_SCHEMA` with `layer: "A2"` and a `fallback`,
|
||
|
|
then mirror the value in `defaults.css`.
|
||
|
|
3. **B-slot → A2** when a B-slot starts being independently bound by
|
||
|
|
≥2 brands (instead of aliasing). Replace `aliasTo` with `fallback`
|
||
|
|
and add a defaults.css declaration.
|
||
|
|
4. **A2 → A1** is rare. It happens when the previously-defaultable
|
||
|
|
value turns out to be brand-determining — e.g. if a future brand
|
||
|
|
redefines `--motion-base` from 200ms to 50ms because its identity
|
||
|
|
is "instant", and that change ripples meaningfully through the
|
||
|
|
brand voice. Drop the `fallback` and reclassify.
|
||
|
|
|
||
|
|
Demotion (A → B → C) is not currently supported. A token that is
|
||
|
|
genuinely no longer needed should be marked `@deprecated` in the
|
||
|
|
schema for one release and then deleted from every brand's
|
||
|
|
`tokens.css` in the same PR.
|
||
|
|
|
||
|
|
## When **not** to add a token
|
||
|
|
|
||
|
|
Schema growth has a cost — every new entry forces every bundled brand to
|
||
|
|
declare or alias the new name when the derive script next runs.
|
||
|
|
Resist adding tokens that are:
|
||
|
|
|
||
|
|
- **Component-internal**: a `.btn-primary` background offset that no
|
||
|
|
other component will ever read. Inline the value in the component
|
||
|
|
rule.
|
||
|
|
- **One-off**: a single layout's hero crop ratio. Not a token.
|
||
|
|
- **Speculative**: "we might want a `--motion-slow` someday." Add it
|
||
|
|
the first time a real interaction needs it, not before.
|
||
|
|
- **Already expressible**: a `--accent-tint-50` that resolves to
|
||
|
|
`color-mix(in oklab, var(--accent), transparent 50%)`. Inline the
|
||
|
|
`color-mix(...)` call until ≥2 components need the same tint with
|
||
|
|
the same alpha, then promote to a token.
|
||
|
|
|
||
|
|
## Editing this directory
|
||
|
|
|
||
|
|
When you change the token schema:
|
||
|
|
|
||
|
|
- Run `pnpm guard` and confirm the bundled catalog still passes every
|
||
|
|
design-system sub-check.
|
||
|
|
- If you added an A2 entry: also update `defaults.css` with the
|
||
|
|
matching declaration, byte-equivalent to the `fallback` field.
|
||
|
|
- If you renamed a token: bump every brand's `tokens.css` and the
|
||
|
|
matching `components.html` `:root` paste in the same commit.
|
||
|
|
Otherwise the drift guard will fail.
|
||
|
|
- If you removed a token from `TOKEN_SCHEMA` and the same name now
|
||
|
|
appears in only one brand: add it to that brand's
|
||
|
|
`BRAND_EXTENSIONS` entry so the unknown-token guard does not fail.
|
||
|
|
|
||
|
|
## Open questions for the future derive script
|
||
|
|
|
||
|
|
The checked-in schema and guards enforce the final token contract today, but
|
||
|
|
the following authoring-automation questions are intentionally not answered:
|
||
|
|
|
||
|
|
- **How does the derive script source A1 values from `DESIGN.md`?**
|
||
|
|
Some sections (color palette, type scale) parse cleanly; others
|
||
|
|
(visual atmosphere, do's and don'ts) do not. A frontmatter or
|
||
|
|
fenced-block convention will likely emerge.
|
||
|
|
- **What happens when a brand's `DESIGN.md` contradicts itself?**
|
||
|
|
e.g. accents listed as both cobalt and indigo. The derive script
|
||
|
|
will need a deterministic resolution (last-wins, manual override
|
||
|
|
flag, or hard fail).
|
||
|
|
- **Are A2 fallback formulas stable when re-derived?** Bit-for-bit
|
||
|
|
reproducibility of the script's output is required so that running
|
||
|
|
the script twice on the same input does not churn the brand token files.
|
||
|
|
|
||
|
|
These remain future work for a change that introduces
|
||
|
|
`scripts/derive-tokens-css.ts`; that script does not exist yet.
|