-
Notifications
You must be signed in to change notification settings - Fork 10.5k
Expand file tree
/
Copy pathtoken-schema.ts
More file actions
287 lines (259 loc) Β· 17.5 KB
/
Copy pathtoken-schema.ts
File metadata and controls
287 lines (259 loc) Β· 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
* packages/contracts/src/design-systems/token-schema.ts
*
* The structural contract for every brand's `tokens.css`.
*
* Each token in the open-design ecosystem belongs to exactly one of
* four layers, distinguished by who decides the value and what
* happens when a brand omits the token.
*
* A1-identity Required. The token *is* the brand. No fallback can
* substitute. (--bg, --fg, --accent, font stacks.)
*
* A1-structure Required. The token is a structural decision (type
* scale, layout grid, section rhythm) that has no
* cross-brand sensible default β every brand authors
* its own.
*
* A2 Required *in the final tokens.css*, but a sensible
* fallback exists in `_schema/defaults.css` that the
* derive script (PR-B) will inline if a brand's
* DESIGN.md does not specify the value. Authors of
* hand-written brands (default, kami) must include
* every A2 token directly until the derive script
* ships.
*
* B-slot Optional schema slot. The token exists for cross-
* brand consistency but a brand without the richer
* tier may alias it to the named sibling via `var()`.
* Components that reference B-slot tokens always
* resolve, even on brands that do not differentiate
* the tier.
*
* C-extension Brand-specific token, declared explicitly per
* brand. Generic cross-brand components must NOT
* reference these. Promote to a B-slot when β₯2 brands
* need the same name; promote to A2 when there is a
* meaningful global default.
*
* Why A2 is "required-with-fallback" rather than "optional":
* Artifacts are generated by agents pasting one brand's :root block
* into a single <style>. There is no runtime cascade from a global
* defaults stylesheet. Agents that paste a tokens.css missing a
* var() target will produce broken artifacts (`var(--motion-fast)`
* resolves to nothing, `transition: var(--motion-fast)` becomes
* `transition: ` and the rule is dropped). The fallback lives in
* `_schema/defaults.css` so the *derive script* can inline it; the
* *runtime* contract remains "every tokens.css must declare every
* A1 + A2 + B-slot token".
*
* Why C-extension is allowlisted, not free:
* Without an allowlist, brand authors can ship arbitrary token
* names that other brands' components silently miss. The allowlist
* forces a deliberate review when a new brand-only name appears,
* and makes the CβBβA promotion path explicit (move the name from
* the brand-specific list into TOKEN_SCHEMA when β₯2 brands need it).
*
* Sources of truth:
* - This file: every shared schema token with its layer + metadata.
* - design-systems/_schema/tokens.schema.ts: compatibility re-export for
* guard scripts and repo-local schema imports.
* - `_schema/defaults.css`: A2 fallback values, mirrored from this
* file so humans can sanity-check the contract in real CSS form.
* - `_schema/AGENTS.md`: prose narrative + promotion path rules.
*
* Drift between this file and defaults.css is enforced by the
* `design-system: A2 defaults parity` guard check.
* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
export type TokenLayer = "A1-identity" | "A1-structure" | "A2" | "B-slot";
export type TokenSpec = {
/** CSS custom property name including the `--` prefix. */
readonly name: string;
readonly layer: TokenLayer;
/** One-line description for documentation generators. */
readonly description: string;
/**
* A2 only. The default value the derive script inlines when a
* brand's DESIGN.md does not specify one. Must stay byte-equivalent
* to the matching declaration in `_schema/defaults.css`.
*/
readonly fallback?: string;
/**
* B-slot only. Sibling token to alias to when the brand has no
* richer tier (e.g. `--fg-2` aliases to `--fg`). The aliasTo string
* is a CSS expression, typically `var(--name)`.
*/
readonly aliasTo?: string;
};
/* eslint-disable @typescript-eslint/no-inferrable-types */
/**
* Every brand's tokens.css must declare every entry in this list.
*
* Order is meaningful for human review β tokens are grouped by intent
* rather than by layer so reviewers can scan the visual stack from
* surface β text β border β accent β semantic β typography β spacing
* β radius β elevation β focus β motion β layout.
*/
export const TOKEN_SCHEMA: readonly TokenSpec[] = [
// βββ Surface ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--bg", layer: "A1-identity", description: "Page background β defines the brand canvas." },
{ name: "--surface", layer: "A1-identity", description: "Card / lifted container background." },
{ name: "--surface-warm", layer: "B-slot", description: "Tertiary surface tier (kami warm-sand).",
aliasTo: "var(--surface)" },
// βββ Foreground βββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--fg", layer: "A1-identity", description: "Primary text color." },
{ name: "--fg-2", layer: "B-slot", description: "Secondary text tier (kami dark-warm).",
aliasTo: "var(--fg)" },
{ name: "--muted", layer: "A1-identity", description: "Subtext / captions." },
{ name: "--meta", layer: "B-slot", description: "Tertiary FG / metadata tier (kami stone).",
aliasTo: "var(--muted)" },
// βββ Border βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--border", layer: "A1-identity", description: "Default border / card edge." },
{ name: "--border-soft", layer: "B-slot", description: "Inner row separator that should not visually compete.",
aliasTo: "var(--border)" },
// βββ Accent βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--accent", layer: "A1-identity", description: "Brand accent. β€2 visible uses per screen (lint enforced)." },
{ name: "--accent-on", layer: "A2", description: "FG when --accent is the bg.",
fallback: "#ffffff" },
{ name: "--accent-hover", layer: "A2", description: "Hover state for elements using --accent as bg.",
fallback: "color-mix(in oklab, var(--accent), black 8%)" },
{ name: "--accent-active", layer: "A2", description: "Active state for elements using --accent as bg.",
fallback: "color-mix(in oklab, var(--accent), black 14%)" },
// βββ Semantic βββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--success", layer: "A2", description: "Success state.", fallback: "#16a34a" },
{ name: "--warn", layer: "A2", description: "Warning state.", fallback: "#eab308" },
{ name: "--danger", layer: "A2", description: "Danger state.", fallback: "#dc2626" },
// βββ Typography β fonts βββββββββββββββββββββββββββββββββββββββββββ
{ name: "--font-display", layer: "A1-identity", description: "Display / heading font stack." },
{ name: "--font-body", layer: "A1-identity", description: "Body font stack." },
{ name: "--font-mono", layer: "A2", description: "Monospace font stack β used by kbd, code, tabular metrics.",
fallback: 'ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, Consolas, monospace' },
// βββ Typography β type scale ββββββββββββββββββββββββββββββββββββββ
{ name: "--text-xs", layer: "A1-structure", description: "Type scale step β extra small (β11β12px)." },
{ name: "--text-sm", layer: "A1-structure", description: "Type scale step β small (β12β14px)." },
{ name: "--text-base", layer: "A1-structure", description: "Type scale step β body baseline." },
{ name: "--text-lg", layer: "A1-structure", description: "Type scale step β H3 / featured body." },
{ name: "--text-xl", layer: "A1-structure", description: "Type scale step β H2." },
{ name: "--text-2xl", layer: "A1-structure", description: "Type scale step β section title." },
{ name: "--text-3xl", layer: "A1-structure", description: "Type scale step β H1." },
{ name: "--text-4xl", layer: "A1-structure", description: "Type scale step β display / hero." },
// βββ Typography β leading & tracking ββββββββββββββββββββββββββββββ
{ name: "--leading-body", layer: "A1-structure", description: "Line-height for reading body." },
{ name: "--leading-tight", layer: "A1-structure", description: "Line-height for headings." },
{ name: "--tracking-display", layer: "A1-structure", description: "Letter-spacing applied to display sizes." },
// βββ Spacing β base scale βββββββββββββββββββββββββββββββββββββββββ
{ name: "--space-1", layer: "A2", description: "Base spacing β 4px tier.", fallback: "4px" },
{ name: "--space-2", layer: "A2", description: "Base spacing β 8px tier.", fallback: "8px" },
{ name: "--space-3", layer: "A2", description: "Base spacing β 12px tier.", fallback: "12px" },
{ name: "--space-4", layer: "A2", description: "Base spacing β 16px tier.", fallback: "16px" },
{ name: "--space-5", layer: "A2", description: "Base spacing β 20px tier.", fallback: "20px" },
{ name: "--space-6", layer: "A2", description: "Base spacing β 24px tier.", fallback: "24px" },
{ name: "--space-8", layer: "A2", description: "Base spacing β 32px tier.", fallback: "32px" },
{ name: "--space-12", layer: "A2", description: "Base spacing β 48px tier.", fallback: "48px" },
// βββ Section rhythm βββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--section-y-desktop", layer: "A1-structure", description: "Vertical padding between sections β desktop." },
{ name: "--section-y-tablet", layer: "A1-structure", description: "Vertical padding between sections β tablet." },
{ name: "--section-y-phone", layer: "A1-structure", description: "Vertical padding between sections β phone." },
// βββ Radius βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--radius-sm", layer: "A2", description: "Small radius β buttons, inputs, chips.", fallback: "8px" },
{ name: "--radius-md", layer: "A2", description: "Medium radius β cards, modals.", fallback: "12px" },
{ name: "--radius-lg", layer: "A2", description: "Large radius β featured containers.", fallback: "16px" },
{ name: "--radius-pill", layer: "A2", description: "Pill radius β avatars, badges.", fallback: "9999px" },
// βββ Elevation ββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--elev-flat", layer: "A2", description: "No elevation.", fallback: "none" },
{ name: "--elev-ring", layer: "A2", description: "Hairline ring (1px box-shadow border).", fallback: "0 0 0 1px var(--border)" },
{ name: "--elev-raised", layer: "A2", description: "Raised surface (blur or whisper).",
fallback: "0 2px 8px color-mix(in oklab, var(--fg), transparent 92%)" },
// βββ Focus ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--focus-ring", layer: "A2", description: "Keyboard focus indicator.",
fallback: "0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%)" },
// βββ Motion βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--motion-fast", layer: "A2", description: "Hover / micro-state duration.", fallback: "150ms" },
{ name: "--motion-base", layer: "A2", description: "General state-change duration.", fallback: "200ms" },
{ name: "--ease-standard", layer: "A2", description: "Standard easing curve.",
fallback: "cubic-bezier(0.2, 0, 0, 1)" },
// βββ Layout βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{ name: "--container-max", layer: "A1-structure", description: "Max content container width." },
{ name: "--container-gutter-desktop", layer: "A1-structure", description: "Container side gutter β desktop." },
{ name: "--container-gutter-tablet", layer: "A1-structure", description: "Container side gutter β tablet." },
{ name: "--container-gutter-phone", layer: "A1-structure", description: "Container side gutter β phone." },
];
/* eslint-enable @typescript-eslint/no-inferrable-types */
/**
* Brand-specific tokens (Layer C) that are not part of the shared
* schema but are explicitly allowed for the named brand.
*
* Adding a name here means: "this token exists only in this brand's
* tokens.css; cross-brand components must not reference it." When a
* second brand adopts the same name, promote the entry into
* TOKEN_SCHEMA (typically as a B-slot or A2) and remove it here.
*/
export const BRAND_EXTENSIONS: Readonly<Record<string, readonly string[]>> = {
default: [
"--space-20", // 80px β used as section-y-desktop's twin; only default needs it
],
openai: [
"--space-16", // 64px β major section gap in OpenAI's DESIGN.md Β§5 spacing scale
],
kami: [
"--accent-light", // brighter ink-blue for links on dark surfaces
"--text-md", // 15px lede tier between --text-base and --text-lg
"--leading-display", // 1.10 β only kami needs this tier
"--leading-dense", // 1.40 β resume / one-pager rhythm
"--tracking-eyebrow", // uppercase eyebrow tracking
"--tracking-label", // small uppercase label tracking
"--space-7", // 28px β kami's card interior
"--space-18", // 72px β section gap (web)
"--space-22", // 88px β page top padding (web)
"--radius-xs", // 2px β kami tags
"--radius-xl", // 16px β kami hero containers
"--elev-ring-accent", // 1px brand ring used as primary-button edge
],
"tom-modern": [
"--tm-accent-soft", // rgba accent tint for subtle backgrounds
"--tm-shadow-hard", // 8px 8px hard offset shadow (hover emphasis)
"--tm-shadow-soft", // 4px 4px soft offset shadow (default card)
"--tm-shadow-code", // 0 28px 90px deep shadow (code window)
"--tm-header-height", // 82px sticky header height
"--tm-code-bg", // #111517 code window background
"--tm-code-panel", // #151a1d code window header
"--tm-code-text", // #d8dee9 code syntax text
],
};
/**
* Prefixes that match any token starting with the given string. Use
* for whole families of brand-specific tokens (e.g. kami's pre-blended
* tag tints `--tag-bg-faint / --tag-bg-soft / ...`) where the
* individual member tokens shouldn't have to be enumerated.
*
* A prefix in this list applies to *any* brand. To restrict a prefix
* to one brand, list each member name in BRAND_EXTENSIONS instead.
*/
export const BRAND_EXTENSION_PREFIXES: readonly string[] = [
"--tag-bg-",
];
/**
* Names that are intentionally absent from the shared schema and not
* tracked per-brand: `--leading-display`, `--leading-dense`, etc.
* begin life in BRAND_EXTENSIONS[brand] and graduate to the schema
* once a second brand adopts them.
*/
// βββ Helpers (consumed by the guard checks) βββββββββββββββββββββββββ
export function getRequiredA1Names(): readonly string[] {
return TOKEN_SCHEMA.filter((t) => t.layer === "A1-identity" || t.layer === "A1-structure").map((t) => t.name);
}
export function getRequiredA2Names(): readonly string[] {
return TOKEN_SCHEMA.filter((t) => t.layer === "A2").map((t) => t.name);
}
export function getBSlotNames(): readonly string[] {
return TOKEN_SCHEMA.filter((t) => t.layer === "B-slot").map((t) => t.name);
}
export function getAllSchemaNames(): readonly string[] {
return TOKEN_SCHEMA.map((t) => t.name);
}
export function isAllowedExtension(brand: string, name: string): boolean {
if (BRAND_EXTENSION_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
const brandList = BRAND_EXTENSIONS[brand];
if (brandList != null && brandList.includes(name)) return true;
return false;
}