@ttoss/fsl-theme 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/dist/Types-6tR0_2Ss.d.ts +1452 -0
- package/dist/css.d.ts +164 -0
- package/dist/dataviz/index.d.ts +62 -0
- package/dist/dtcg.d.ts +49 -0
- package/dist/esm/chunk-4Q4P3JBB.js +185 -0
- package/dist/esm/chunk-5PWPAQMC.js +9 -0
- package/dist/esm/chunk-BXKVVQEP.js +29 -0
- package/dist/esm/chunk-DU4QDQUC.js +29 -0
- package/dist/esm/chunk-FBVUI2PK.js +147 -0
- package/dist/esm/chunk-HRNXVRS3.js +54 -0
- package/dist/esm/chunk-IJGA42O6.js +141 -0
- package/dist/esm/chunk-PQPQNZ73.js +262 -0
- package/dist/esm/chunk-SE5Z52RE.js +1898 -0
- package/dist/esm/chunk-TPMN75JM.js +29 -0
- package/dist/esm/chunk-UMRQ4OTX.js +11 -0
- package/dist/esm/chunk-VL6EGE6Z.js +222 -0
- package/dist/esm/chunk-WVQSTQD5.js +192 -0
- package/dist/esm/css.js +6 -0
- package/dist/esm/dataviz/index.js +19 -0
- package/dist/esm/dtcg.js +65 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/react.js +8 -0
- package/dist/esm/runtime-entry.js +4 -0
- package/dist/esm/themes/bruttal.js +6 -0
- package/dist/esm/themes/corporate.js +6 -0
- package/dist/esm/themes/oca.js +6 -0
- package/dist/esm/themes/ventures.js +6 -0
- package/dist/esm/vars.js +28 -0
- package/dist/index.d.ts +86 -0
- package/dist/react.d.ts +346 -0
- package/dist/runtime-entry.d.ts +95 -0
- package/dist/themes/bruttal.d.ts +5 -0
- package/dist/themes/corporate.d.ts +5 -0
- package/dist/themes/oca.d.ts +5 -0
- package/dist/themes/ventures.d.ts +5 -0
- package/dist/vars.d.ts +127 -0
- package/llms.txt +731 -0
- package/package.json +88 -0
package/llms.txt
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
# @ttoss/fsl-theme
|
|
2
|
+
|
|
3
|
+
> Design token system with a strict semantic contract. Two layers (`core` = raw values, `semantic` = stable design meaning); components consume only `semantic.*`. The semantic layer is the public API — type-safe, mode-agnostic, emitted as CSS custom properties with zero runtime cost.
|
|
4
|
+
|
|
5
|
+
This file is the LLM-facing guide for using the package in an external application. It covers two jobs: **using tokens** (creating components) and **customizing the theme** (overrides). All token paths and per-token semantics are also available via JSDoc on `vars.*` after `import { vars } from '@ttoss/fsl-theme/vars'`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Entry points
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createTheme, baseTheme, bruttal, corporate, oca, ventures } from '@ttoss/fsl-theme';
|
|
13
|
+
import { ThemeProvider, useColorMode, useResolvedTokens } from '@ttoss/fsl-theme/react';
|
|
14
|
+
import { vars } from '@ttoss/fsl-theme/vars'; // typed CSS var(--tt-*) refs — CSS environments
|
|
15
|
+
import { toCssVarName } from '@ttoss/fsl-theme/css'; // raw --tt-* names — tooling / PostCSS
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`vars.*` is a static map: every leaf is a `var(--tt-*)` string. Zero runtime cost. The values behind those CSS vars are injected by `ThemeProvider` — without it, the properties are unset and components render with no token values.
|
|
19
|
+
|
|
20
|
+
`useTokens()` returns **unresolved** `TokenRef` strings (e.g. `'{core.colors.brand.500}'`) — for introspection only, never for styling. Use `vars.*` for CSS, `useResolvedTokens()` for non-CSS.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Getting started — minimal app
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
// theme.ts — create once, import everywhere
|
|
28
|
+
import { createTheme } from '@ttoss/fsl-theme';
|
|
29
|
+
export const theme = createTheme(); // default base + dark alternate included
|
|
30
|
+
|
|
31
|
+
// main.tsx (or _app.tsx, layout.tsx, etc.)
|
|
32
|
+
import { ThemeProvider } from '@ttoss/fsl-theme/react';
|
|
33
|
+
import { theme } from './theme';
|
|
34
|
+
|
|
35
|
+
export function App() {
|
|
36
|
+
return (
|
|
37
|
+
<ThemeProvider theme={theme}>
|
|
38
|
+
<YourApp />
|
|
39
|
+
</ThemeProvider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`ThemeProvider` injects all `--tt-*` CSS custom properties into `<head>`, listens to `prefers-color-scheme`, and persists the user's choice to `localStorage`. Pass `defaultMode="dark"` to start in dark mode; pass `alternate={null}` to `createTheme` to opt out of dark mode entirely.
|
|
45
|
+
|
|
46
|
+
**Built-in themes** — extend instead of building from scratch:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { bruttal, corporate, oca, ventures } from '@ttoss/fsl-theme';
|
|
50
|
+
// Use directly as-is:
|
|
51
|
+
<ThemeProvider theme={bruttal}>...</ThemeProvider>
|
|
52
|
+
// Or extend with brand overrides:
|
|
53
|
+
import { createTheme } from '@ttoss/fsl-theme';
|
|
54
|
+
const myTheme = createTheme({ extends: bruttal, overrides: { core: { colors: { brand: { 500: '#FF6B00' } } } } });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Token architecture
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
ThemeTokens
|
|
63
|
+
├── core raw primitives (immutable across modes)
|
|
64
|
+
└── semantic {core.*} references (the public contract; remapped per mode)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Components consume **only** `semantic.*`. Core values never change between light and dark modes — only semantic references remap. Customization touches `core` (values) or `semantic` (references), never component code.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## CSS var naming scheme
|
|
72
|
+
|
|
73
|
+
`vars.*` gives TypeScript autocomplete. For raw CSS or CSS Modules, use the `--tt-*` names directly.
|
|
74
|
+
|
|
75
|
+
| Token path prefix | CSS var prefix | Example |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `semantic.colors.` | `--tt-colors-` | `vars.colors.action.primary.background.default` → `var(--tt-colors-action-primary-background-default)` |
|
|
78
|
+
| `semantic.text.` | `--tt-text-` | `vars.text.body.md.fontSize` → `var(--tt-text-body-md-fontSize)` |
|
|
79
|
+
| `semantic.spacing.` | `--tt-spacing-` | `vars.spacing.inset.control.md` → `var(--tt-spacing-inset-control-md)` |
|
|
80
|
+
| `semantic.sizing.` | `--tt-sizing-` | `vars.sizing.hit.base` → `var(--tt-sizing-hit-base)` |
|
|
81
|
+
| `semantic.radii.` | `--tt-radii-` | `vars.radii.control` → `var(--tt-radii-control)` |
|
|
82
|
+
| `semantic.border.` | `--tt-border-` | `vars.border.outline.control.width` → `var(--tt-border-outline-control-width)` |
|
|
83
|
+
| `semantic.focus.` | `--tt-focus-` | `vars.focus.ring.color` → `var(--tt-focus-ring-color)` |
|
|
84
|
+
| `semantic.elevation.` | `--tt-elevation-` | `vars.elevation.surface.raised` → `var(--tt-elevation-surface-raised)` |
|
|
85
|
+
| `semantic.opacity.` | `--tt-opacity-` | `vars.opacity.disabled` → `var(--tt-opacity-disabled)` |
|
|
86
|
+
| `semantic.overlay.` | `--tt-overlay-` | `vars.overlay.scrim` → `var(--tt-overlay-scrim)` |
|
|
87
|
+
| `semantic.motion.` | `--tt-motion-` | `vars.motion.feedback.duration` → `var(--tt-motion-feedback-duration)` |
|
|
88
|
+
| `semantic.zIndex.layer.` | `--tt-z-index-` | `vars.zIndex.layer.overlay` → `var(--tt-z-index-overlay)` |
|
|
89
|
+
|
|
90
|
+
The table is the contract — paths are not a pure mechanical transform (`zIndex` → `z-index`; `zIndex.layer.` collapses to `--tt-z-index-`). camelCase leaf properties (`fontSize`, `fontFamily`) preserve case.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
# JOB 1 — Using tokens to build components
|
|
95
|
+
|
|
96
|
+
## FSL color axes
|
|
97
|
+
|
|
98
|
+
Every `vars.colors.*` token is `{ux}.{role}.{dimension}.{state}`. The four axes are defined once here; later sections reference them.
|
|
99
|
+
|
|
100
|
+
**`ux` — FSL Entity Kind (what the element does):**
|
|
101
|
+
- `action` — button, link-as-CTA, command (mutates state outside the form lifecycle).
|
|
102
|
+
- `input` — text field, checkbox, radio, switch, segmented control (mutates a value the form submits).
|
|
103
|
+
- `navigation` — tab, menu item, breadcrumb, nav link (changes user location in IA).
|
|
104
|
+
- `feedback` — alert, banner, toast, progress (system-initiated communication about an event).
|
|
105
|
+
- `informational` — card, badge, chip, stat, page surface (passive content surface).
|
|
106
|
+
|
|
107
|
+
**`role` — Evaluation (emphasis / valence):**
|
|
108
|
+
- `primary` — the single most important instance per `{ux}` per view.
|
|
109
|
+
- `secondary` — coexisting alternative to `primary`.
|
|
110
|
+
- `accent` — brand pop / high-emphasis variant.
|
|
111
|
+
- `muted` — subdued / low-priority.
|
|
112
|
+
- `positive` / `caution` / `negative` — outcome valence (success / warning / failure or destructive).
|
|
113
|
+
|
|
114
|
+
**`dimension`:** `background` · `border` · `text`.
|
|
115
|
+
|
|
116
|
+
**`state`:** `default` · `hover` · `active` · `focused` · `disabled` · `selected` · `pressed` · `checked` · `current` · `visited` · `expanded` · `indeterminate` · `droptarget`.
|
|
117
|
+
|
|
118
|
+
Legal `{ux, role}` and `{ux, state}` combinations are enforced by the TypeScript types of `vars` — invalid paths fail at the call site, not at runtime:
|
|
119
|
+
|
|
120
|
+
| `ux` | valid `role` values |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `action` | `primary` · `secondary` · `accent` · `muted` · `negative` |
|
|
123
|
+
| `input` | `primary` · `secondary` · `muted` · `positive` · `caution` · `negative` |
|
|
124
|
+
| `navigation` | `primary` · `secondary` · `accent` · `muted` |
|
|
125
|
+
| `feedback` | `primary` · `muted` · `positive` · `caution` · `negative` (`feedback.primary` carries no valence — it's neutral feedback) |
|
|
126
|
+
| `informational` | `primary` · `secondary` · `accent` · `muted` · `positive` · `caution` · `negative` |
|
|
127
|
+
|
|
128
|
+
Valid `{ux, state}` combinations: see [Color state legality](#color-state-legality-per-ux) below.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Token grammar by family
|
|
133
|
+
|
|
134
|
+
| Family | Path shape | Leaf type |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| **colors** | `vars.colors.{ux}.{role}.{dimension}.{state?}` | CSS color |
|
|
137
|
+
| **spacing** | `vars.spacing.inset.{control,surface}.{sm,md,lg}` | CSS length |
|
|
138
|
+
| | `vars.spacing.gap.{stack,inline}.{xs,sm,md,lg,xl}` | CSS length |
|
|
139
|
+
| | `vars.spacing.gutter.{page,section}` | CSS length / `clamp()` |
|
|
140
|
+
| | `vars.spacing.separation.interactive.min` | CSS length |
|
|
141
|
+
| **text** | `vars.text.{display,headline,title,body,label,code}.{lg,md,sm}` | TextStyle (object — see below) |
|
|
142
|
+
| **sizing** | `vars.sizing.hit.{min,base,prominent}` | CSS length |
|
|
143
|
+
| | `vars.sizing.icon.{sm,md,lg}` | CSS length |
|
|
144
|
+
| | `vars.sizing.identity.{sm,md,lg,xl}` | CSS length |
|
|
145
|
+
| | `vars.sizing.measure.reading` | CSS `ch` / `clamp()` |
|
|
146
|
+
| | `vars.sizing.surface.maxWidth` | CSS length |
|
|
147
|
+
| | `vars.sizing.viewport.{height,width}.full` | dvh / dvw |
|
|
148
|
+
| **radii** | `vars.radii.{control,surface,round}` | CSS length |
|
|
149
|
+
| **border** | `vars.border.divider.{width,style}` | object |
|
|
150
|
+
| | `vars.border.outline.{surface,control,selected}.{width,style}` | object |
|
|
151
|
+
| **focus** | `vars.focus.ring.{width,style,color}` | object |
|
|
152
|
+
| **elevation** | `vars.elevation.surface.{flat,raised,overlay,blocking}` | box-shadow |
|
|
153
|
+
| | `vars.elevation.tonal.{raised,overlay,blocking}` (optional) | CSS color |
|
|
154
|
+
| **opacity** | `vars.opacity.{scrim,loading,disabled}` | number in (0,1) |
|
|
155
|
+
| **overlay** | `vars.overlay.scrim` | CSS color with alpha |
|
|
156
|
+
| **motion** | `vars.motion.{feedback,emphasis,decorative}.{duration,easing}` | object |
|
|
157
|
+
| | `vars.motion.transition.{enter,exit}.{duration,easing}` | object |
|
|
158
|
+
| **zIndex** | `vars.zIndex.layer.{base,sticky,overlay,blocking,transient}` | integer |
|
|
159
|
+
|
|
160
|
+
## Pick a token in 60 seconds — by CSS property
|
|
161
|
+
|
|
162
|
+
Find the CSS property you are setting; follow the branch.
|
|
163
|
+
|
|
164
|
+
**`color` / `background` / `border-color` / `fill` → `vars.colors.{ux}.{role}.{dimension}.{state}`** — axes defined in [FSL color axes](#fsl-color-axes); state legality in [Color state legality](#color-state-legality-per-ux).
|
|
165
|
+
|
|
166
|
+
**`padding` → `spacing.inset`**
|
|
167
|
+
|
|
168
|
+
- Interactive control (button, input, toggle) → `vars.spacing.inset.control.{sm|md|lg}`
|
|
169
|
+
- Container (card, panel, dialog, section) → `vars.spacing.inset.surface.{sm|md|lg}`
|
|
170
|
+
- Default step at any level → `md`
|
|
171
|
+
|
|
172
|
+
**`gap` → `spacing.gap`**
|
|
173
|
+
|
|
174
|
+
- Vertical sibling flow (form fields, list, stacked sections) → `vars.spacing.gap.stack.{xs|sm|md|lg|xl}`
|
|
175
|
+
- Horizontal sibling flow (icon + label, toolbar, chip row) → `vars.spacing.gap.inline.{xs|sm|md|lg|xl}`
|
|
176
|
+
- Adjacent independently focusable targets → `vars.spacing.separation.interactive.min`
|
|
177
|
+
|
|
178
|
+
**`padding` on a page or section wrapper → `spacing.gutter`**
|
|
179
|
+
|
|
180
|
+
- Page-level horizontal gutter → `vars.spacing.gutter.page`
|
|
181
|
+
- Section-level vertical gutter → `vars.spacing.gutter.section`
|
|
182
|
+
|
|
183
|
+
**`font-*` / `line-height` → `text.{role}.{step}`**
|
|
184
|
+
|
|
185
|
+
| Element role | Token |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| Hero / landing emphasis | `vars.text.display.{lg,md,sm}` |
|
|
188
|
+
| Page or section heading | `vars.text.headline.{lg,md,sm}` |
|
|
189
|
+
| Surface heading (card, panel, dialog) | `vars.text.title.{lg,md,sm}` |
|
|
190
|
+
| Reading prose / description | `vars.text.body.{lg,md,sm}` |
|
|
191
|
+
| UI label / button text / badge | `vars.text.label.{lg,md,sm}` |
|
|
192
|
+
| Code / identifiers / monospace | `vars.text.code.{md,sm}` |
|
|
193
|
+
|
|
194
|
+
`vars.text.*.*` is a **TextStyle object** — each field is its own `var(--tt-text-*)` string. Apply each field individually to the corresponding CSS property:
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
// Inline styles:
|
|
198
|
+
style={{
|
|
199
|
+
fontFamily: vars.text.body.md.fontFamily,
|
|
200
|
+
fontSize: vars.text.body.md.fontSize,
|
|
201
|
+
fontWeight: vars.text.body.md.fontWeight,
|
|
202
|
+
lineHeight: vars.text.body.md.lineHeight,
|
|
203
|
+
letterSpacing: vars.text.body.md.letterSpacing,
|
|
204
|
+
}}
|
|
205
|
+
|
|
206
|
+
// CSS / CSS Modules:
|
|
207
|
+
// font-family: var(--tt-text-body-md-fontFamily);
|
|
208
|
+
// font-size: var(--tt-text-body-md-fontSize);
|
|
209
|
+
// font-weight: var(--tt-text-body-md-fontWeight);
|
|
210
|
+
// line-height: var(--tt-text-body-md-lineHeight);
|
|
211
|
+
// letter-spacing: var(--tt-text-body-md-letterSpacing);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Available fields: `fontFamily` · `fontSize` · `fontWeight` · `lineHeight` · `letterSpacing` · `fontOpticalSizing?` · `fontVariantNumeric?`
|
|
215
|
+
|
|
216
|
+
**`width` / `height` / `min-width` / `min-height` → `sizing`**
|
|
217
|
+
|
|
218
|
+
- Interactive area (hit target) → `vars.sizing.hit.{min|base|prominent}` (apply via `min-width`/`min-height`, not visual size)
|
|
219
|
+
- Icon glyph → `vars.sizing.icon.{sm|md|lg}`
|
|
220
|
+
- Avatar / identity → `vars.sizing.identity.{sm|md|lg|xl}`
|
|
221
|
+
- Reading line length → `vars.sizing.measure.reading` (use as `max-width`)
|
|
222
|
+
- Surface max width → `vars.sizing.surface.maxWidth`
|
|
223
|
+
- Full viewport → `vars.sizing.viewport.{height|width}.full`
|
|
224
|
+
|
|
225
|
+
**`border-radius` → `radii`**
|
|
226
|
+
|
|
227
|
+
- Interactive control → `vars.radii.control`
|
|
228
|
+
- Containing surface → `vars.radii.surface`
|
|
229
|
+
- Pill / capsule / circular avatar → `vars.radii.round`
|
|
230
|
+
|
|
231
|
+
**`border-width` / `border-style` → `border`** (pair with a `colors` token for the line color)
|
|
232
|
+
|
|
233
|
+
- Separator between content groups → `vars.border.divider`
|
|
234
|
+
- Surface enclosing edge → `vars.border.outline.surface`
|
|
235
|
+
- Control enclosing edge → `vars.border.outline.control`
|
|
236
|
+
- Selected/current state line → `vars.border.outline.selected`
|
|
237
|
+
|
|
238
|
+
**`outline` (keyboard focus) → `focus`** — see co-occurrence rule §1 below.
|
|
239
|
+
|
|
240
|
+
- Component has a clear `{ux}` → `vars.colors.{ux}.{role}.border.focused`
|
|
241
|
+
- No clear `{ux}` (focusable card, custom widget) → `vars.focus.ring.{width,style,color}`
|
|
242
|
+
|
|
243
|
+
**`box-shadow` → `elevation.surface`**
|
|
244
|
+
|
|
245
|
+
- Flush with page → `vars.elevation.surface.flat`
|
|
246
|
+
- Card / panel → `vars.elevation.surface.raised`
|
|
247
|
+
- Dropdown / popover → `vars.elevation.surface.overlay`
|
|
248
|
+
- Dialog / sheet → `vars.elevation.surface.blocking`
|
|
249
|
+
|
|
250
|
+
**`z-index` → `zIndex.layer`** — pick by *blocking behaviour and persistence*, not by component name.
|
|
251
|
+
|
|
252
|
+
- Normal flow → `vars.zIndex.layer.base`
|
|
253
|
+
- Sticky header / nav → `vars.zIndex.layer.sticky`
|
|
254
|
+
- Non-blocking floating surface (dropdown, popover) → `vars.zIndex.layer.overlay`
|
|
255
|
+
- Blocking surface (dialog with scrim) → `vars.zIndex.layer.blocking`
|
|
256
|
+
- Transient non-blocking (toast) → `vars.zIndex.layer.transient`
|
|
257
|
+
|
|
258
|
+
**`opacity` (whole element) → `opacity`** — see co-occurrence rule §3 below.
|
|
259
|
+
|
|
260
|
+
- Modal backdrop dimming → `vars.opacity.scrim`
|
|
261
|
+
- Content visible during async work → `vars.opacity.loading`
|
|
262
|
+
- Disabled visual asset (image, avatar) → `vars.opacity.disabled`
|
|
263
|
+
|
|
264
|
+
**`transition` / `animation` → `motion`**
|
|
265
|
+
|
|
266
|
+
- Response to user input on a single element → `vars.motion.feedback`
|
|
267
|
+
- Element entering layout → `vars.motion.transition.enter`
|
|
268
|
+
- Element leaving layout → `vars.motion.transition.exit`
|
|
269
|
+
- Must-notice in-place change → `vars.motion.emphasis`
|
|
270
|
+
- Ambient / decorative loop → `vars.motion.decorative`
|
|
271
|
+
|
|
272
|
+
**Modal backdrop fill → `vars.overlay.scrim`**
|
|
273
|
+
|
|
274
|
+
## Color state legality (per `ux`)
|
|
275
|
+
|
|
276
|
+
Not every state is legal in every `ux` context. Illegal combinations are TypeScript errors at the call site. Missing optional states on a sparse theme fall back to `default` (§8).
|
|
277
|
+
|
|
278
|
+
| State | action | input | navigation | feedback | informational |
|
|
279
|
+
| --- | --- | --- | --- | --- | --- |
|
|
280
|
+
| `default` | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
281
|
+
| `hover` | ✅ | ✅ | ✅ | — | ✅ |
|
|
282
|
+
| `active` | ✅ | ✅ | ✅ | — | ✅ |
|
|
283
|
+
| `focused` | ✅ | ✅ | ✅ | ✅ (focusable wrapper or close btn) | ✅ |
|
|
284
|
+
| `disabled` | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
285
|
+
| `selected` | — | ✅ (set membership) | ✅ | — | ✅ |
|
|
286
|
+
| `pressed` | ✅ (toggle) | ✅ (toggle input) | — | — | — |
|
|
287
|
+
| `expanded` | ✅ (disclosure trigger) | ✅ (combobox) | ✅ (collapsible nav) | — | ✅ (accordion) |
|
|
288
|
+
| `checked` | — | ✅ (two-state control) | — | — | — |
|
|
289
|
+
| `indeterminate` | — | ✅ | — | — | — |
|
|
290
|
+
| `current` | — | — | ✅ (active route) | — | — |
|
|
291
|
+
| `visited` | — | — | ✅ | — | ✅ (link in content) |
|
|
292
|
+
| `droptarget` | — | ✅ (file input) | — | — | ✅ |
|
|
293
|
+
|
|
294
|
+
State disambiguation rules:
|
|
295
|
+
- **`active` vs `pressed`**: `active` = the brief moment of clicking (transient). `pressed` = persistent toggle engaged ("Bold" button). Never use `active` for toggle state.
|
|
296
|
+
- **`selected` vs `checked`**: `selected` = one-of-many in a set (segment, picker option). `checked` = two-state on/off (checkbox, radio, switch).
|
|
297
|
+
- **`selected` vs `current`**: `selected` = set membership. `current` = the user's actual location in the navigation set.
|
|
298
|
+
- **Validation failure is not a state**: an invalid input renders with the `input.negative.*` role, not with an `invalid` state on `input.primary.*`.
|
|
299
|
+
|
|
300
|
+
## Co-occurrence rules (cross-family)
|
|
301
|
+
|
|
302
|
+
These rules cannot be expressed in a single token's JSDoc — they are obligations between families.
|
|
303
|
+
|
|
304
|
+
**§1 — Every focusable element needs a focus indicator.** Pick one:
|
|
305
|
+
- Component has a clear FSL Entity Kind (`Action`, `Input`, `Navigation`, `Feedback`) → use `vars.colors.{ux}.{role}.border.focused`.
|
|
306
|
+
- Component has no clear `{ux}` (focusable card, custom widget) → use `vars.focus.ring.{width,style,color}`.
|
|
307
|
+
- Input with `negative` or `caution` valence where focus must inherit valence → `vars.colors.input.{negative|caution}.border.focused` overrides the system default.
|
|
308
|
+
|
|
309
|
+
Never both. Never neither.
|
|
310
|
+
|
|
311
|
+
**§2 — `colors.background` requires a paired `colors.text`.** Whenever you set a semantic background, set the matching `colors.{ux}.{role}.text.{state}` for the contained text. Mixing a `feedback.positive.background.default` with an unrelated text token breaks the contrast contract.
|
|
312
|
+
|
|
313
|
+
**§3 — `opacity.disabled` is for visual media only.** Disabled controls and disabled text **must** use `vars.colors.{ux}.{role}.{dimension}.disabled` color tokens (which carry contrast guarantees). `vars.opacity.disabled` is exclusive to images, avatars, illustrations — assets without a color contract.
|
|
314
|
+
|
|
315
|
+
**§4 — `sizing.hit.*` is enforced via `min-*`, not visual size.** Apply as `min-width` / `min-height`. The CSS layer auto-injects coarse-pointer overrides under `@media (any-pointer: coarse)` — do not write that media query yourself.
|
|
316
|
+
|
|
317
|
+
**§5 — `tonal` elevation requires its shadow counterpart.** Using `vars.elevation.tonal.raised` is only valid when `vars.elevation.surface.raised` is also applied to the same element. Tonal alone breaks depth perception when the theme switches modes.
|
|
318
|
+
|
|
319
|
+
**§6 — Density propagates across families.** A `sm` / `md` / `lg` decision must be applied uniformly across `spacing.inset.*`, `spacing.gap.*`, `sizing.icon.*`, and `text.label.*` for the same component. Mixing `spacing.inset.control.sm` with `sizing.icon.lg` produces visually inconsistent components.
|
|
320
|
+
|
|
321
|
+
**§7 — `motion` auto-suppression.** `prefers-reduced-motion` is handled by the CSS pipeline emitted by `toCssVars` — never re-implement reduced-motion logic in component code.
|
|
322
|
+
|
|
323
|
+
**§8 — Optional families have fallback rules:**
|
|
324
|
+
- `elevation.tonal` absent → fall back to `elevation.surface` alone.
|
|
325
|
+
- `dataviz` absent → the consumer's app does not opt in to dataviz; do not generate dataviz components.
|
|
326
|
+
- Optional state (e.g. `hover`) absent on a leaf → fall back to `default`.
|
|
327
|
+
|
|
328
|
+
## Disambiguation — confusable picks
|
|
329
|
+
|
|
330
|
+
For each common LLM mistake, the wrong pick → the correct pick + the rule that disambiguates:
|
|
331
|
+
|
|
332
|
+
| User intent | Tempting (wrong) | Correct | Rule |
|
|
333
|
+
| --- | --- | --- | --- |
|
|
334
|
+
| Page background | `core.colors.neutral.0` | `vars.colors.informational.primary.background.default` | Components never consume `core.*`. |
|
|
335
|
+
| Cancel / dismiss button | `vars.colors.action.negative.*` | `vars.colors.action.muted.*` (or `.secondary.*`) | `negative` = *destructive* intent (delete). Low priority is `muted`. |
|
|
336
|
+
| Delete / destroy button | `vars.colors.feedback.negative.*` | `vars.colors.action.negative.*` | Triggers → `action`. Reporting an outcome → `feedback`. |
|
|
337
|
+
| Success toast | `vars.colors.informational.positive.*` | `vars.colors.feedback.positive.*` | System *reports* a result → `feedback`. Passive surface → `informational`. |
|
|
338
|
+
| Warning text inside a card | `vars.colors.feedback.caution.*` | `vars.colors.informational.caution.*` | If it's part of static content (not a system event), it's `informational`. |
|
|
339
|
+
| Form field with validation error | `input.primary.*` + `invalid` state | `vars.colors.input.negative.*` | Validation failure is a *role*, not a state. (FSL Lexicon §5) |
|
|
340
|
+
| Disabled icon button | `opacity: vars.opacity.disabled` | `color: vars.colors.action.{role}.text.disabled` | §3 — controls/text carry contrast; only assets without color contract use `opacity.disabled`. |
|
|
341
|
+
| Active tab indicator | `border.outline.selected` *only* | `border.outline.selected` + `colors.navigation.{role}.border.current` | Width via `border.outline.selected`; color via the `current` state. |
|
|
342
|
+
| Toggle button engaged | `state: active` | `state: pressed` | `active` is transient (mouse down); `pressed` is persistent toggle. |
|
|
343
|
+
| Focus on input in error | `focus.ring.color` | `vars.colors.input.negative.border.focused` | §1 — valence inputs override the system default. |
|
|
344
|
+
|
|
345
|
+
## Focus ring — implementation patterns
|
|
346
|
+
|
|
347
|
+
`:focus-visible` cannot be expressed in inline styles. Use one of:
|
|
348
|
+
|
|
349
|
+
**Option A — CSS class (canonical)**
|
|
350
|
+
|
|
351
|
+
```css
|
|
352
|
+
/* button.css (or a global stylesheet) */
|
|
353
|
+
/* §1 — action ux: per-context focused border token */
|
|
354
|
+
.btn:focus-visible {
|
|
355
|
+
outline-width: var(--tt-focus-ring-width);
|
|
356
|
+
outline-style: var(--tt-focus-ring-style);
|
|
357
|
+
outline-color: var(--tt-colors-action-primary-border-focused);
|
|
358
|
+
outline-offset: 2px;
|
|
359
|
+
}
|
|
360
|
+
.btn:hover:not(:disabled) {
|
|
361
|
+
background: var(--tt-colors-action-primary-background-hover);
|
|
362
|
+
}
|
|
363
|
+
.btn:active:not(:disabled) {
|
|
364
|
+
background: var(--tt-colors-action-primary-background-active);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* No clear ux (focusable card, custom widget) — system default ring */
|
|
368
|
+
.focusable-card:focus-visible {
|
|
369
|
+
outline-width: var(--tt-focus-ring-width);
|
|
370
|
+
outline-style: var(--tt-focus-ring-style);
|
|
371
|
+
outline-color: var(--tt-focus-ring-color);
|
|
372
|
+
outline-offset: 2px;
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Option B — CSS-in-JS / Ark UI / React Aria**
|
|
377
|
+
|
|
378
|
+
Headless libraries (Ark UI, React Aria) manage `data-focused` / `data-hovered` attributes automatically. Wire them to token vars in your CSS-in-JS framework.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Component pattern — complete example
|
|
383
|
+
|
|
384
|
+
```tsx
|
|
385
|
+
import { vars } from '@ttoss/fsl-theme/vars';
|
|
386
|
+
import './button.css'; // contains :focus-visible, :hover, :active rules (see above)
|
|
387
|
+
|
|
388
|
+
// Applies: §1 (focus via CSS), §2 (bg+text pair), §3 (disabled via color token not opacity),
|
|
389
|
+
// §4 (hit target via min-height), §6 (density uniform across padding/sizing/label)
|
|
390
|
+
function PrimaryButton({
|
|
391
|
+
children,
|
|
392
|
+
disabled,
|
|
393
|
+
...props
|
|
394
|
+
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
395
|
+
return (
|
|
396
|
+
<button
|
|
397
|
+
{...props}
|
|
398
|
+
disabled={disabled}
|
|
399
|
+
className="btn"
|
|
400
|
+
style={{
|
|
401
|
+
// §2 — background + text always paired
|
|
402
|
+
background: disabled
|
|
403
|
+
? vars.colors.action.primary.background.disabled
|
|
404
|
+
: vars.colors.action.primary.background.default,
|
|
405
|
+
color: disabled
|
|
406
|
+
? vars.colors.action.primary.text.disabled // §3: color token, not opacity
|
|
407
|
+
: vars.colors.action.primary.text.default,
|
|
408
|
+
// §6 — density uniform: inset / sizing / typography all md
|
|
409
|
+
padding: `0 ${vars.spacing.inset.control.md}`,
|
|
410
|
+
minHeight: vars.sizing.hit.base, // §4: min-height enforces hit target
|
|
411
|
+
// typography — each TextStyle field is its own var() string
|
|
412
|
+
fontFamily: vars.text.label.md.fontFamily,
|
|
413
|
+
fontSize: vars.text.label.md.fontSize,
|
|
414
|
+
fontWeight: vars.text.label.md.fontWeight,
|
|
415
|
+
lineHeight: vars.text.label.md.lineHeight,
|
|
416
|
+
letterSpacing: vars.text.label.md.letterSpacing,
|
|
417
|
+
// surface
|
|
418
|
+
borderRadius: vars.radii.control,
|
|
419
|
+
borderWidth: vars.border.outline.control.width,
|
|
420
|
+
borderStyle: vars.border.outline.control.style,
|
|
421
|
+
borderColor: disabled
|
|
422
|
+
? vars.colors.action.primary.border.disabled
|
|
423
|
+
: vars.colors.action.primary.border.default,
|
|
424
|
+
// motion
|
|
425
|
+
transitionDuration: vars.motion.feedback.duration,
|
|
426
|
+
transitionTimingFunction: vars.motion.feedback.easing,
|
|
427
|
+
transitionProperty: 'background, color, border-color',
|
|
428
|
+
// layout
|
|
429
|
+
display: 'inline-flex',
|
|
430
|
+
alignItems: 'center',
|
|
431
|
+
gap: vars.spacing.gap.inline.sm,
|
|
432
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
{children}
|
|
436
|
+
</button>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Pattern: Input with validation error
|
|
442
|
+
|
|
443
|
+
New vs. Button: validation is a *role swap*, not a state (`input.primary.*` → `input.negative.*`); focus inherits valence (§1 valence override); the validation message text consumes the same negative role.
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
function TextField({
|
|
447
|
+
invalid,
|
|
448
|
+
message,
|
|
449
|
+
...props
|
|
450
|
+
}: React.InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean; message?: string }) {
|
|
451
|
+
const role = invalid ? 'negative' : 'primary'; // role swap, not state
|
|
452
|
+
return (
|
|
453
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: vars.spacing.gap.stack.xs }}>
|
|
454
|
+
<input
|
|
455
|
+
{...props}
|
|
456
|
+
aria-invalid={invalid || undefined}
|
|
457
|
+
className={invalid ? 'input input--negative' : 'input'}
|
|
458
|
+
style={{
|
|
459
|
+
background: vars.colors.input[role].background.default,
|
|
460
|
+
color: vars.colors.input[role].text.default,
|
|
461
|
+
borderColor: vars.colors.input[role].border.default,
|
|
462
|
+
borderWidth: vars.border.outline.control.width,
|
|
463
|
+
borderStyle: vars.border.outline.control.style,
|
|
464
|
+
borderRadius: vars.radii.control,
|
|
465
|
+
minHeight: vars.sizing.hit.base,
|
|
466
|
+
padding: `0 ${vars.spacing.inset.control.md}`,
|
|
467
|
+
fontSize: vars.text.body.md.fontSize,
|
|
468
|
+
lineHeight: vars.text.body.md.lineHeight,
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
{message && (
|
|
472
|
+
<span style={{
|
|
473
|
+
color: vars.colors.input.negative.text.default, // negative role serves both control + message
|
|
474
|
+
fontSize: vars.text.label.sm.fontSize,
|
|
475
|
+
}}>
|
|
476
|
+
{message}
|
|
477
|
+
</span>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
```css
|
|
485
|
+
.input:focus-visible { outline-color: var(--tt-colors-input-primary-border-focused); }
|
|
486
|
+
.input--negative:focus-visible { outline-color: var(--tt-colors-input-negative-border-focused); /* §1 valence override */ }
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## Pattern: Modal dialog (five families coordinated)
|
|
490
|
+
|
|
491
|
+
New vs. Button: scrim composes `overlay.scrim` (color) with `opacity.scrim` (alpha); blocking surface uses `elevation.surface.blocking` + `zIndex.layer.blocking`; enter/exit motion is `motion.transition.*` (not `motion.feedback`).
|
|
492
|
+
|
|
493
|
+
```tsx
|
|
494
|
+
import { useResolvedTokens } from '@ttoss/fsl-theme/react';
|
|
495
|
+
|
|
496
|
+
function Modal({ children }: { children: React.ReactNode }) {
|
|
497
|
+
// Numeric z-index needed for arithmetic (scrim = blocking - 1)
|
|
498
|
+
const resolved = useResolvedTokens();
|
|
499
|
+
const blockingZ = Number(resolved['semantic.zIndex.layer.blocking']);
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<>
|
|
503
|
+
{/* Scrim: dim everything behind */}
|
|
504
|
+
<div style={{
|
|
505
|
+
position: 'fixed', inset: 0,
|
|
506
|
+
background: vars.overlay.scrim,
|
|
507
|
+
opacity: vars.opacity.scrim,
|
|
508
|
+
zIndex: blockingZ - 1,
|
|
509
|
+
}} />
|
|
510
|
+
{/* Dialog surface: blocking stratum, max depth */}
|
|
511
|
+
<div role="dialog" aria-modal="true" style={{
|
|
512
|
+
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
|
513
|
+
zIndex: blockingZ,
|
|
514
|
+
background: vars.colors.informational.primary.background.default,
|
|
515
|
+
color: vars.colors.informational.primary.text.default,
|
|
516
|
+
boxShadow: vars.elevation.surface.blocking,
|
|
517
|
+
borderRadius: vars.radii.surface,
|
|
518
|
+
padding: vars.spacing.inset.surface.md,
|
|
519
|
+
maxWidth: vars.sizing.surface.maxWidth,
|
|
520
|
+
// enter/exit motion — transition.* not feedback.*
|
|
521
|
+
transitionProperty: 'opacity, transform',
|
|
522
|
+
transitionDuration: vars.motion.transition.enter.duration,
|
|
523
|
+
transitionTimingFunction: vars.motion.transition.enter.easing,
|
|
524
|
+
}}>
|
|
525
|
+
{children}
|
|
526
|
+
</div>
|
|
527
|
+
</>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## Self-audit checklist (run before emitting code)
|
|
535
|
+
|
|
536
|
+
For every component you generate, verify:
|
|
537
|
+
|
|
538
|
+
1. **Path validity** — every `vars.colors.*` path exists in the [role matrix](#fsl-color-axes); no `action.positive`, no `feedback.accent`, no `navigation.{positive,caution,negative}`.
|
|
539
|
+
2. **Focus** (§1) — every focusable element has *exactly one* of: per-ux `{ux}.{role}.border.focused`, system `focus.ring.*`, or valence-aware input override. Never both. Never neither.
|
|
540
|
+
3. **Pairing** (§2) — every `colors.{ux}.{role}.background.{state}` is paired with `colors.{ux}.{role}.text.{state}` at the same state.
|
|
541
|
+
4. **Disabled** (§3) — disabled controls and text use `colors.{ux}.{role}.{dimension}.disabled`; `opacity.disabled` appears only on `<img>`, `<Avatar>`, illustrations.
|
|
542
|
+
5. **Hit target** (§4) — interactive elements set `minWidth` or `minHeight` (not `width`/`height`) to `vars.sizing.hit.*`.
|
|
543
|
+
6. **Density** (§6) — the same step (`sm`/`md`/`lg`) is used across `spacing.inset.*`, `spacing.gap.*`, `sizing.icon.*`, and `text.label.*` within the component.
|
|
544
|
+
7. **Validation** — invalid inputs use `input.negative.*` *role*, never `input.primary.*` + a fictitious `invalid` state.
|
|
545
|
+
8. **Stateful CSS** — `:hover`, `:focus-visible`, `:active`, `:disabled` are written in a `.css` file or CSS-in-JS; inline `style` cannot express pseudo-classes.
|
|
546
|
+
9. **No `core.*` consumption** — the component imports `vars` only; `core.*` paths appear only inside `createTheme({ overrides })`.
|
|
547
|
+
10. **Mode-agnostic** — the component consumes `semantic.*` only; no `useColorMode()` branching to swap tokens (the cascade handles it).
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## Mode switching
|
|
552
|
+
|
|
553
|
+
```tsx
|
|
554
|
+
import { useColorMode } from '@ttoss/fsl-theme/react';
|
|
555
|
+
|
|
556
|
+
// Must be a descendant of <ThemeProvider>
|
|
557
|
+
const DarkModeToggle = () => {
|
|
558
|
+
const { mode, resolvedMode, setMode } = useColorMode();
|
|
559
|
+
return (
|
|
560
|
+
<button onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}>
|
|
561
|
+
{resolvedMode === 'dark' ? 'Switch to light' : 'Switch to dark'}
|
|
562
|
+
</button>
|
|
563
|
+
);
|
|
564
|
+
};
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`mode` = user intent: `'light' | 'dark' | 'system'`. `resolvedMode` = actual rendered mode: `'light' | 'dark'`. Persisted automatically to `localStorage` (`'tt-theme'` key by default).
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## Non-CSS environments — React Native, canvas, PDF
|
|
572
|
+
|
|
573
|
+
`vars.*` produces CSS custom property strings — they resolve only in a browser with `ThemeProvider`. For non-CSS environments, use `useResolvedTokens()`:
|
|
574
|
+
|
|
575
|
+
```tsx
|
|
576
|
+
import { useResolvedTokens } from '@ttoss/fsl-theme/react';
|
|
577
|
+
|
|
578
|
+
// Keys are full semantic dot-paths; values are resolved raw CSS values (hex, px, etc.)
|
|
579
|
+
const NativeButton = ({ disabled }: { disabled?: boolean }) => {
|
|
580
|
+
const resolved = useResolvedTokens();
|
|
581
|
+
return (
|
|
582
|
+
<View
|
|
583
|
+
style={{
|
|
584
|
+
backgroundColor: disabled
|
|
585
|
+
? resolved['semantic.colors.action.primary.background.disabled']
|
|
586
|
+
: resolved['semantic.colors.action.primary.background.default'],
|
|
587
|
+
minHeight: resolved['semantic.sizing.hit.base'],
|
|
588
|
+
paddingHorizontal: resolved['semantic.spacing.inset.control.md'],
|
|
589
|
+
}}
|
|
590
|
+
/>
|
|
591
|
+
);
|
|
592
|
+
};
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
`useResolvedTokens()` automatically applies coarse-pointer hit target overrides — mirrors the `@media (any-pointer: coarse)` block that `toCssVars` injects for CSS consumers.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
# JOB 2 — Customizing a theme
|
|
600
|
+
|
|
601
|
+
`createTheme({ overrides })` accepts a `DeepPartial<ThemeTokens>` — TypeScript validates *shape*, not semantic coherence. The intent → override map below specifies the minimal correct set of paths to change for each common design intent, and the invariants to verify after.
|
|
602
|
+
|
|
603
|
+
## API
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
import { createTheme } from '@ttoss/fsl-theme';
|
|
607
|
+
|
|
608
|
+
const theme = createTheme({
|
|
609
|
+
extends?: ThemeBundle, // built-in theme to extend
|
|
610
|
+
baseMode?: 'light' | 'dark', // which mode the base represents
|
|
611
|
+
overrides?: DeepPartial<ThemeTokens>, // changes to the base
|
|
612
|
+
alternate?: ModeOverride | null, // dark alternate (null = single-mode)
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
- Default: `createTheme()` returns the built-in `baseTheme` + built-in `darkAlternate`.
|
|
617
|
+
- `extends`: inherit from a built-in (`bruttal`, `corporate`, `oca`, `ventures`).
|
|
618
|
+
- `alternate: null`: opt out of dark mode entirely (single-mode theme).
|
|
619
|
+
|
|
620
|
+
## Intent → override map
|
|
621
|
+
|
|
622
|
+
### Apply brand colors
|
|
623
|
+
|
|
624
|
+
**Override path:** `core.colors.brand.{50..900}` — set the full scale.
|
|
625
|
+
|
|
626
|
+
```ts
|
|
627
|
+
createTheme({
|
|
628
|
+
overrides: {
|
|
629
|
+
core: { colors: { brand: { 50: '#…', 100: '#…', /* … */ 900: '#…' } } },
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
**Invariants to verify:**
|
|
635
|
+
- `brand.500` is the canonical mid-point and is required. Tune it for AA contrast against `neutral.0` if it is used as a filled background with light text (see `bruttal.ts` for the worked example: `brand.500 = #6D5D4F` ≈ 7.3:1 contrast).
|
|
636
|
+
- All semantic tokens that reference `{core.colors.brand.*}` automatically pick up the new values. No semantic remap needed unless contrast breaks.
|
|
637
|
+
- For dark mode, the built-in `darkAlternate` already remaps semantic references to lighter brand steps — usually no extra work.
|
|
638
|
+
|
|
639
|
+
**Reference patterns:** `corporate.ts`, `oca.ts`, `ventures.ts` are minimal brand-only overrides.
|
|
640
|
+
|
|
641
|
+
### Adjust density
|
|
642
|
+
|
|
643
|
+
**Override paths (all together):**
|
|
644
|
+
- `core.spacing.{1..16}` — base spacing scale steps
|
|
645
|
+
- `core.sizing.hit.fine.{min,base,prominent}` — fine-pointer hit targets
|
|
646
|
+
- `core.sizing.icon.*` — icon ramp
|
|
647
|
+
- `core.font.size.*` (optional) — type scale
|
|
648
|
+
|
|
649
|
+
**Invariants:**
|
|
650
|
+
- Touch targets (`core.sizing.hit.coarse.*`) must remain ≥ 44px regardless of fine-pointer values (a11y minimum).
|
|
651
|
+
- Density propagates uniformly: if you reduce `core.spacing.4` by 25%, reduce other spacing steps proportionally to preserve the scale ratios.
|
|
652
|
+
- Hit target floors in `clamp()` expressions must remain ergonomic (≥ 32px for fine pointer).
|
|
653
|
+
|
|
654
|
+
### Flat / no-shadow style
|
|
655
|
+
|
|
656
|
+
**Override paths:**
|
|
657
|
+
- `semantic.radii.{control,surface}` → `'{core.radii.none}'` (sharp corners)
|
|
658
|
+
- `semantic.elevation.surface.{flat,raised,overlay,blocking}` → all → `'{core.elevation.level.0}'`
|
|
659
|
+
- `alternate.semantic.elevation.surface.*` → all → `'{core.elevation.emphatic.0}'` (preserves flatness in dark mode)
|
|
660
|
+
|
|
661
|
+
**Reference pattern:** `bruttal.ts` is the canonical implementation — copy its `semantic.radii` + `semantic.elevation.surface` + `alternate.semantic.elevation.surface` blocks together.
|
|
662
|
+
|
|
663
|
+
**Invariant:** If you flatten elevation in `base`, you must also flatten in `alternate` — otherwise dark mode will render shadows that contradict the visual identity.
|
|
664
|
+
|
|
665
|
+
### Custom typography
|
|
666
|
+
|
|
667
|
+
**Override paths (likely all together):**
|
|
668
|
+
- `core.font.family.{sans,mono,serif?}` — font stack
|
|
669
|
+
- `core.font.weight.*` — verify the new typeface supports the weights used
|
|
670
|
+
- `core.font.leading.*` (optional) — adjust if the typeface needs different line heights
|
|
671
|
+
- `core.font.tracking.*` (optional) — letter-spacing per typeface
|
|
672
|
+
|
|
673
|
+
**Invariants:**
|
|
674
|
+
- A new typeface may not support all referenced weights — verify `regular`, `medium`, `semibold`, `bold` all exist in the chosen font.
|
|
675
|
+
- `core.font.leading.normal` is calibrated for the default `sans`; some typefaces need wider leading to remain legible.
|
|
676
|
+
- Do **not** override `semantic.text.*` styles unless changing the *intent* of a text role. Family swaps live in `core.font.family.sans`; semantic styles inherit automatically.
|
|
677
|
+
|
|
678
|
+
### Custom dark mode
|
|
679
|
+
|
|
680
|
+
**Override path:** `alternate.semantic.*` only. **Never** `alternate.core.*` — core is immutable across modes (model invariant §1 / §4).
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
createTheme({
|
|
684
|
+
overrides: { core: { colors: { brand: { 500: '#…' } } } },
|
|
685
|
+
alternate: {
|
|
686
|
+
semantic: {
|
|
687
|
+
colors: {
|
|
688
|
+
informational: {
|
|
689
|
+
primary: { background: { default: '{core.colors.neutral.900}' } },
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
**Invariants:**
|
|
698
|
+
- Modes remap **semantic references**, never core values. If a semantic token needs a value that doesn't exist in core, enrich `core` (extending the palette), don't mutate it per mode.
|
|
699
|
+
- Validate every semantic *pairing* (text on background, border on adjacent surface) in both modes — not isolated swatches.
|
|
700
|
+
- Remap neutrals first; remap brand/hue only when contrast or legibility breaks.
|
|
701
|
+
|
|
702
|
+
### Single-mode theme (no dark)
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
createTheme({ alternate: null });
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
## What you must NOT do when customizing
|
|
709
|
+
|
|
710
|
+
- **Never** consume `core.*` directly from a component. Core is theme-definition only.
|
|
711
|
+
- **Never** create parallel semantic vocabulary (e.g. `semantic.colors.brandDark`, `semantic.text.buttonLarge`). If the meaning exists, reuse it; if it truly doesn't, follow governance to add it.
|
|
712
|
+
- **Never** mutate `core.*` per mode. Modes remap semantic references only.
|
|
713
|
+
- **Never** name semantic tokens by appearance (hue, raw style, component, mode). Names express *intent*.
|
|
714
|
+
- **Never** override `semantic.*` to reference a raw value — always reference a `{core.*}` token. Raw values in semantic are a registered exception list (see `model.md`), not a customization path.
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## Validation in development
|
|
719
|
+
|
|
720
|
+
In non-production builds, `createTheme` runs `validateRefs` over the merged theme — broken `{core.*}` references throw early. In production builds the validation is stripped for performance.
|
|
721
|
+
|
|
722
|
+
Cross-token semantic correctness (the rules above) is **not** statically validated today. The rules in this file are the authoritative checklist for an LLM producing component or customization code.
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
## Reference index
|
|
727
|
+
|
|
728
|
+
- README — full grammar tables, usage examples per import path
|
|
729
|
+
- `dist/Types-*.d.ts` — every token has co-located JSDoc with semantics + anti-pattern notes
|
|
730
|
+
- `src/themes/{bruttal,corporate,oca,ventures}.ts` — worked customization patterns
|
|
731
|
+
- Family specs (full grammar, decision matrices, ADRs): `docs/design/01-design-system/02-design-tokens/02-families/{family}.md` in the ttoss monorepo
|