@hotelfriendag/design-tokens 0.3.0
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 +558 -0
- package/README.uk.md +377 -0
- package/RFC-0001-cross-project-design-system.md +273 -0
- package/RFC-0002-semantic-tier-naming.md +296 -0
- package/UI_DESIGN.md +608 -0
- package/ai-rules/CLAUDE.md +80 -0
- package/ai-rules/cursorrules.template +39 -0
- package/ai-rules/github-copilot-instructions.md +45 -0
- package/ai-rules/system-prompt-compact.md +43 -0
- package/components.html +3018 -0
- package/generate-tokens.cjs +665 -0
- package/package.json +98 -0
- package/portal-audit.html +2306 -0
- package/pre-built/_tokens.scss +138 -0
- package/pre-built/components.css +515 -0
- package/pre-built/shadcn-tokens.css +67 -0
- package/pre-built/status.css +51 -0
- package/pre-built/stylelint-design-system.cjs +69 -0
- package/pre-built/tailwind.additive.css +158 -0
- package/pre-built/tailwind.css +158 -0
- package/pre-built/tailwind.preset.js +207 -0
- package/pre-built/tokens.css +185 -0
- package/pre-built/tokens.d.ts +240 -0
- package/pre-built/tokens.js +243 -0
- package/pre-built/tokens.ts +240 -0
- package/scripts/integration-smoke.sh +91 -0
- package/scripts/pre-commit.sh +55 -0
- package/scripts/validate-tokens.cjs +113 -0
- package/states-canonical.json +240 -0
- package/states.json +2950 -0
- package/status-map.json +47 -0
- package/tokens.figma.json +230 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* generate-tokens.cjs
|
|
4
|
+
*
|
|
5
|
+
* Reads `tokens.figma.json` (Tokens Studio format, sibling file) and emits
|
|
6
|
+
* a framework-specific token file you can drop into a project.
|
|
7
|
+
*
|
|
8
|
+
* Targets:
|
|
9
|
+
* --target=css → :root { --token: value; ... } (vanilla CSS)
|
|
10
|
+
* --target=tailwind → module.exports = { theme: { extend: {...} } } (Tailwind v3 preset, legacy)
|
|
11
|
+
* --target=tailwind-v4 → @theme { --color-hf-*: ...; --text-hf-*: ... }(Tailwind v4 CSS theme, collision-safe by prefix)
|
|
12
|
+
* --target=tailwind-v4-additive → alias of `tailwind-v4` (kept for API discoverability — see Dispatch comment)
|
|
13
|
+
* --target=scss → $token: value; (SCSS variables)
|
|
14
|
+
* --target=ts → export const tokens = {...} (TypeScript source)
|
|
15
|
+
* --target=js → module.exports = { tokens: {...} } (CommonJS module — Node consumers)
|
|
16
|
+
* --target=dts → export declare const tokens: {...} (TypeScript declarations)
|
|
17
|
+
* --target=shadcn → :root { --primary: ...; --background: ... } (shadcn/ui CSS contract — unprefixed by contract)
|
|
18
|
+
*
|
|
19
|
+
* Usage (from this folder, or anywhere — paths are relative to the script):
|
|
20
|
+
* node generate-tokens.cjs --target=css > pre-built/tokens.css
|
|
21
|
+
* node generate-tokens.cjs --target=tailwind > pre-built/tailwind.preset.js
|
|
22
|
+
* node generate-tokens.cjs --target=tailwind-v4 > pre-built/tailwind.css
|
|
23
|
+
* node generate-tokens.cjs --target=tailwind-v4-additive > pre-built/tailwind.additive.css (= same content)
|
|
24
|
+
* node generate-tokens.cjs --target=scss > pre-built/_tokens.scss
|
|
25
|
+
* node generate-tokens.cjs --target=ts > pre-built/tokens.ts
|
|
26
|
+
* node generate-tokens.cjs --target=js > pre-built/tokens.js
|
|
27
|
+
* node generate-tokens.cjs --target=dts > pre-built/tokens.d.ts
|
|
28
|
+
* node generate-tokens.cjs --target=shadcn > pre-built/shadcn-tokens.css
|
|
29
|
+
*
|
|
30
|
+
* Pass --in=path/to/tokens.figma.json to override the input file.
|
|
31
|
+
*
|
|
32
|
+
* ESM compatibility: the `.cjs` extension guarantees this script runs under Node even when
|
|
33
|
+
* the host project has `"type": "module"` in package.json (Vite / Vue 3 / most modern repos).
|
|
34
|
+
* Do not rename to `.js` without rewriting to ESM `import`/`export`.
|
|
35
|
+
*/
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
const arg = (name) => {
|
|
40
|
+
const a = process.argv.find(s => s.startsWith('--' + name + '='));
|
|
41
|
+
return a ? a.slice(name.length + 3) : null;
|
|
42
|
+
};
|
|
43
|
+
const target = arg('target') || 'css';
|
|
44
|
+
const inputPath = arg('in') || path.resolve(__dirname, 'tokens.figma.json');
|
|
45
|
+
const raw = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
|
46
|
+
const G = raw.global || raw;
|
|
47
|
+
|
|
48
|
+
// ── RFC-0001 §4.2: collision-safe `hf-` prefix ──
|
|
49
|
+
// All emitted CSS variable names insert `hf-` AFTER the category segment so they
|
|
50
|
+
// 1) cannot shadow Tailwind v4 defaults (--text-*, --spacing-*, --radius-*, --font-*)
|
|
51
|
+
// 2) still match Tailwind v4's utility derivation (--color-* → bg-*, etc.)
|
|
52
|
+
//
|
|
53
|
+
// Example: tokens.figma.json `color.primary` → CSS `--color-hf-primary` → Tailwind utility `bg-hf-primary`.
|
|
54
|
+
// (Not `--hf-color-primary` because that wouldn't generate any utility — Tailwind v4 only
|
|
55
|
+
// scans tokens that start with its known utility prefixes: --color-, --text-, --spacing-, etc.)
|
|
56
|
+
//
|
|
57
|
+
// Exception: --target=shadcn emits unprefixed names (shadcn's contract requires fixed names).
|
|
58
|
+
//
|
|
59
|
+
// Override via --prefix=foo- (e.g. --prefix= for unprefixed legacy output).
|
|
60
|
+
const PREFIX = arg('prefix') ?? 'hf-';
|
|
61
|
+
|
|
62
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
63
|
+
const isToken = (v) => v && typeof v === 'object' && 'value' in v && 'type' in v;
|
|
64
|
+
function flatten(obj, prefix = []) {
|
|
65
|
+
// Walk Tokens Studio tree → flat list of { path: ['color','primary','default'], token }
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
68
|
+
if (k.startsWith('$')) continue; // skip $themes / $metadata
|
|
69
|
+
const next = [...prefix, k];
|
|
70
|
+
if (isToken(v)) out.push({ path: next, token: v });
|
|
71
|
+
else if (v && typeof v === 'object') out.push(...flatten(v, next));
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── RFC-0002: Tier normalisation + alias resolution ──
|
|
77
|
+
// Tokens Studio supports two notations we need to resolve:
|
|
78
|
+
// 1. Tier prefixes — `primitive.color.blue.500` and `semantic.color.accent.default`
|
|
79
|
+
// both emit as if their path started with `color` (the actual category).
|
|
80
|
+
// We STRIP the leading `primitive`/`semantic` segment so kebab() works.
|
|
81
|
+
// 2. Reference values — a token's `value` can be a string like `"{primitive.color.blue.500}"`
|
|
82
|
+
// which means "use the resolved value of that token". For CSS targets we
|
|
83
|
+
// emit `var(--color-hf-blue-500)` (preserving the alias chain at runtime).
|
|
84
|
+
// For non-CSS targets (TS/SCSS/shadcn/v3-preset) we inline the resolved value.
|
|
85
|
+
const TIER_NAMESPACES = new Set(['primitive', 'semantic']);
|
|
86
|
+
|
|
87
|
+
// Strip leading tier namespace so downstream emitters see a clean category-first path.
|
|
88
|
+
const normalisePath = (parts) => {
|
|
89
|
+
if (parts.length > 1 && TIER_NAMESPACES.has(parts[0])) return parts.slice(1);
|
|
90
|
+
return parts;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Resolve a token-studio-style reference (e.g. "{primitive.color.blue.500}") to its raw value.
|
|
94
|
+
// `aliasMode='css'`: returns var(--color-hf-blue-500) so the chain stays runtime-resolvable.
|
|
95
|
+
// `aliasMode='inline'`: returns the resolved raw value, walking the chain if needed.
|
|
96
|
+
// `tokens`: lookup index { 'primitive.color.blue.500': token, ... }
|
|
97
|
+
const isRef = (v) => typeof v === 'string' && /^\{[\w.-]+\}$/.test(v);
|
|
98
|
+
const refTarget = (v) => v.slice(1, -1); // strip braces
|
|
99
|
+
|
|
100
|
+
function resolveRef(ref, tokenIndex, mode = 'inline', seen = new Set()) {
|
|
101
|
+
if (seen.has(ref)) throw new Error(`Token alias cycle detected: ${[...seen, ref].join(' → ')}`);
|
|
102
|
+
seen.add(ref);
|
|
103
|
+
const target = tokenIndex[ref];
|
|
104
|
+
if (!target) {
|
|
105
|
+
// Reference not found — return the raw ref so the consumer can spot it.
|
|
106
|
+
console.error(`Warning: unresolved token reference {${ref}}`);
|
|
107
|
+
return `{${ref}}`;
|
|
108
|
+
}
|
|
109
|
+
if (mode === 'css') {
|
|
110
|
+
// Emit var() pointing to the target CSS variable. Path → kebab name.
|
|
111
|
+
const targetPath = normalisePath(ref.split('.'));
|
|
112
|
+
return `var(--${kebab(targetPath)})`;
|
|
113
|
+
}
|
|
114
|
+
// mode === 'inline' — walk the chain
|
|
115
|
+
const tv = target.value;
|
|
116
|
+
if (isRef(tv)) return resolveRef(refTarget(tv), tokenIndex, mode, seen);
|
|
117
|
+
return tv;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolve a token's value for emission. Handles refs + composite (typography/boxShadow) values.
|
|
121
|
+
function resolveValue(token, tokenIndex, aliasMode = 'inline') {
|
|
122
|
+
const v = token.value;
|
|
123
|
+
if (isRef(v)) return resolveRef(refTarget(v), tokenIndex, aliasMode);
|
|
124
|
+
return v;
|
|
125
|
+
}
|
|
126
|
+
// Drop trailing 'default' / 'DEFAULT' so a `.default` sub-key becomes the base token name.
|
|
127
|
+
// Example: color.primary.default → color-primary (not color-primary-default).
|
|
128
|
+
// This matches Tokens Studio + Tailwind v3 `DEFAULT` convention.
|
|
129
|
+
const stripDefault = (parts) => {
|
|
130
|
+
const last = parts[parts.length - 1];
|
|
131
|
+
return (last === 'default' || last === 'DEFAULT') ? parts.slice(0, -1) : parts;
|
|
132
|
+
};
|
|
133
|
+
const kebabSeg = (s) => s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()).replace(/-+/g, '-');
|
|
134
|
+
const prefixNoDash = () => PREFIX.replace(/-$/, '');
|
|
135
|
+
|
|
136
|
+
// kebab(parts) — for CSS target (full path INCLUDING leading category segment).
|
|
137
|
+
// PREFIX is inserted AFTER the first (category) segment.
|
|
138
|
+
// Examples (PREFIX='hf-'):
|
|
139
|
+
// ['color', 'primary'] → 'color-hf-primary' → CSS: --color-hf-primary
|
|
140
|
+
// ['shadow', 'modal'] → 'shadow-hf-modal' → CSS: --shadow-hf-modal
|
|
141
|
+
// ['spacing', '5'] → 'spacing-hf-5' → CSS: --spacing-hf-5
|
|
142
|
+
// ['shadow'] (from .default collapse) → 'shadow-hf' → CSS: --shadow-hf
|
|
143
|
+
const kebab = (parts) => {
|
|
144
|
+
const clean = stripDefault(parts);
|
|
145
|
+
if (PREFIX === '') return clean.map(kebabSeg).join('-');
|
|
146
|
+
if (clean.length === 0) return prefixNoDash();
|
|
147
|
+
if (clean.length === 1) return kebabSeg(clean[0]) + '-' + prefixNoDash();
|
|
148
|
+
const first = kebabSeg(clean[0]);
|
|
149
|
+
const rest = clean.slice(1).map(kebabSeg).join('-');
|
|
150
|
+
return first + '-' + PREFIX + rest;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// suffix(parts) — for Tailwind v4 / shadcn emitters that already prepend a category
|
|
154
|
+
// (e.g. `--color-`, `--shadow-`, `--text-`). The caller has sliced off the category
|
|
155
|
+
// segment and passes the remainder; suffix() returns "{PREFIX}{remainder}" or just
|
|
156
|
+
// PREFIX (no dash) when the remainder is empty.
|
|
157
|
+
// Examples (PREFIX='hf-'):
|
|
158
|
+
// suffix(['primary']) → 'hf-primary' → used with --color-: `--color-hf-primary`
|
|
159
|
+
// suffix(['text','primary'])→ 'hf-text-primary' → `--color-hf-text-primary`
|
|
160
|
+
// suffix([]) → 'hf' → used with --shadow-: `--shadow-hf`
|
|
161
|
+
const suffix = (parts) => {
|
|
162
|
+
const clean = stripDefault(parts);
|
|
163
|
+
if (PREFIX === '') return clean.map(kebabSeg).join('-');
|
|
164
|
+
if (clean.length === 0) return prefixNoDash();
|
|
165
|
+
return PREFIX + clean.map(kebabSeg).join('-');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const camelRaw = (parts) => {
|
|
169
|
+
const clean = stripDefault(parts);
|
|
170
|
+
return clean.map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1))).join('');
|
|
171
|
+
};
|
|
172
|
+
const camel = (parts) => camelRaw(parts);
|
|
173
|
+
|
|
174
|
+
const allRaw = flatten(G);
|
|
175
|
+
// Build token lookup index by FULL path (including tier prefix) so refs like
|
|
176
|
+
// "{primitive.color.blue.500}" can be resolved.
|
|
177
|
+
const tokenIndex = Object.fromEntries(allRaw.map(({ path, token }) => [path.join('.'), token]));
|
|
178
|
+
// `all` is the flattened list with TIER-NORMALISED paths (primitive/semantic stripped).
|
|
179
|
+
// All emitters use this so the path passed to kebab()/suffix() starts with the actual category.
|
|
180
|
+
const all = allRaw.map(({ path, token }) => ({ path: normalisePath(path), token }));
|
|
181
|
+
|
|
182
|
+
// ── CSS target ───────────────────────────────────────────────────────────────
|
|
183
|
+
function emitCss() {
|
|
184
|
+
const lines = [];
|
|
185
|
+
lines.push('/*');
|
|
186
|
+
lines.push(' * HotelFriend design tokens — CSS custom properties');
|
|
187
|
+
lines.push(' * Generated from docs/tokens.figma.json — do NOT edit by hand.');
|
|
188
|
+
lines.push(' *');
|
|
189
|
+
lines.push(' * Three-tier model per RFC-0002:');
|
|
190
|
+
lines.push(' * primitive.* → raw palette values (--color-hf-blue-500, --color-hf-gray-300, …)');
|
|
191
|
+
lines.push(' * semantic.* → role tokens via var() chain (--color-hf-accent → var(--color-hf-blue-500))');
|
|
192
|
+
lines.push(' *');
|
|
193
|
+
lines.push(' * App code should reference SEMANTIC tokens. Primitives are theming-layer detail.');
|
|
194
|
+
lines.push(' */');
|
|
195
|
+
lines.push(':root {');
|
|
196
|
+
for (const { path: p, token } of all) {
|
|
197
|
+
if (token.type === 'typography' || token.type === 'boxShadow') continue;
|
|
198
|
+
const v = resolveValue(token, tokenIndex, 'css'); // string OR `var(--…)` for refs
|
|
199
|
+
if (typeof v === 'object') continue;
|
|
200
|
+
lines.push(` --${kebab(p)}: ${v};` + (token.description ? ` /* ${token.description} */` : ''));
|
|
201
|
+
}
|
|
202
|
+
// Box shadow composites → flat string
|
|
203
|
+
for (const { path: p, token } of all) {
|
|
204
|
+
if (token.type !== 'boxShadow') continue;
|
|
205
|
+
const v = token.value;
|
|
206
|
+
if (Array.isArray(v)) continue;
|
|
207
|
+
const str = `${v.x}px ${v.y}px ${v.blur}px ${v.spread}px ${v.color}`;
|
|
208
|
+
lines.push(` --${kebab(p)}: ${str};` + (token.description ? ` /* ${token.description} */` : ''));
|
|
209
|
+
}
|
|
210
|
+
lines.push('}');
|
|
211
|
+
lines.push('');
|
|
212
|
+
lines.push('/* Composite typography tokens (use as utility classes) */');
|
|
213
|
+
for (const { path: p, token } of all) {
|
|
214
|
+
if (token.type !== 'typography') continue;
|
|
215
|
+
const v = token.value;
|
|
216
|
+
const cls = '.' + kebab(p);
|
|
217
|
+
lines.push(`${cls} {`);
|
|
218
|
+
if (v.fontFamily) lines.push(` font-family: ${v.fontFamily.replace(/[{}]/g, '')};`);
|
|
219
|
+
if (v.fontWeight) lines.push(` font-weight: var(--font-weight-${PREFIX}${v.fontWeight.replace(/[{}]/g, '').replace(/^fontWeight\./, '')});`);
|
|
220
|
+
if (v.fontSize) lines.push(` font-size: var(--font-size-${PREFIX}${v.fontSize.replace(/[{}]/g, '').replace(/^fontSize\./, '')});`);
|
|
221
|
+
if (v.lineHeight) lines.push(` line-height: var(--line-height-${PREFIX}${v.lineHeight.replace(/[{}]/g, '').replace(/^lineHeight\./, '')});`);
|
|
222
|
+
lines.push('}');
|
|
223
|
+
}
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── SCSS target ──────────────────────────────────────────────────────────────
|
|
228
|
+
function emitScss() {
|
|
229
|
+
const lines = [];
|
|
230
|
+
lines.push('// HotelFriend design tokens — SCSS variables');
|
|
231
|
+
lines.push('// Generated from docs/tokens.figma.json — do NOT edit by hand.');
|
|
232
|
+
lines.push('');
|
|
233
|
+
for (const { path: p, token } of all) {
|
|
234
|
+
if (typeof token.value !== 'string') continue;
|
|
235
|
+
lines.push(`$${camel(p)}: ${token.value};${token.description ? ' // ' + token.description : ''}`);
|
|
236
|
+
}
|
|
237
|
+
for (const { path: p, token } of all) {
|
|
238
|
+
if (token.type !== 'boxShadow') continue;
|
|
239
|
+
const v = token.value;
|
|
240
|
+
if (Array.isArray(v)) continue;
|
|
241
|
+
const str = `${v.x}px ${v.y}px ${v.blur}px ${v.spread}px ${v.color}`;
|
|
242
|
+
lines.push(`$${camel(p)}: ${str};`);
|
|
243
|
+
}
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── TypeScript / JS / DTS targets ────────────────────────────────────────────
|
|
248
|
+
// All three share the same nested-object shape (path → leaf string value).
|
|
249
|
+
// `nestTokens()` builds it once; emit functions wrap it in language-specific syntax.
|
|
250
|
+
function nestTokens() {
|
|
251
|
+
const root = {};
|
|
252
|
+
for (const { path: p, token } of all) {
|
|
253
|
+
let cur = root;
|
|
254
|
+
for (let i = 0; i < p.length - 1; i++) {
|
|
255
|
+
cur[p[i]] = cur[p[i]] || {};
|
|
256
|
+
cur = cur[p[i]];
|
|
257
|
+
}
|
|
258
|
+
let v = token.value;
|
|
259
|
+
if (token.type === 'boxShadow' && typeof v === 'object' && !Array.isArray(v)) {
|
|
260
|
+
v = `${v.x}px ${v.y}px ${v.blur}px ${v.spread}px ${v.color}`;
|
|
261
|
+
}
|
|
262
|
+
cur[p[p.length - 1]] = v;
|
|
263
|
+
}
|
|
264
|
+
return root;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function emitTs() {
|
|
268
|
+
const obj = nestTokens();
|
|
269
|
+
const banner = [
|
|
270
|
+
'// HotelFriend design tokens — TypeScript const',
|
|
271
|
+
'// Generated from docs/tokens.figma.json — do NOT edit by hand.',
|
|
272
|
+
'',
|
|
273
|
+
'export const tokens = ' + JSON.stringify(obj, null, 2) + ' as const;',
|
|
274
|
+
'',
|
|
275
|
+
'export type Tokens = typeof tokens;',
|
|
276
|
+
];
|
|
277
|
+
return banner.join('\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function emitJs() {
|
|
281
|
+
const obj = nestTokens();
|
|
282
|
+
const banner = [
|
|
283
|
+
'// HotelFriend design tokens — CommonJS module',
|
|
284
|
+
'// Generated from docs/tokens.figma.json — do NOT edit by hand.',
|
|
285
|
+
'//',
|
|
286
|
+
'// Works with both `require("@hotelfriendag/design-tokens").tokens` and',
|
|
287
|
+
'// ESM `import { tokens } from "@hotelfriendag/design-tokens"` via Node CJS-ESM interop.',
|
|
288
|
+
'',
|
|
289
|
+
'const tokens = ' + JSON.stringify(obj, null, 2) + ';',
|
|
290
|
+
'',
|
|
291
|
+
'module.exports = { tokens };',
|
|
292
|
+
];
|
|
293
|
+
return banner.join('\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Emit a `.d.ts` declaration matching the same tree shape, with `string` at the leaves.
|
|
297
|
+
// Choice: concrete key paths (so `tokens.color.blue["500"]` autocompletes) but `string`
|
|
298
|
+
// leaf type (avoids 6KB of literal types and lets values mutate without breaking dts).
|
|
299
|
+
function emitDts() {
|
|
300
|
+
const obj = nestTokens();
|
|
301
|
+
const isSafeIdent = (k) => /^[A-Za-z_$][\w$]*$/.test(k);
|
|
302
|
+
function typeOf(node, indent) {
|
|
303
|
+
if (typeof node === 'string') return 'string';
|
|
304
|
+
const pad = ' '.repeat(indent);
|
|
305
|
+
const padInner = ' '.repeat(indent + 1);
|
|
306
|
+
const entries = Object.entries(node).map(([k, v]) => {
|
|
307
|
+
const key = isSafeIdent(k) ? k : JSON.stringify(k);
|
|
308
|
+
return `${padInner}readonly ${key}: ${typeOf(v, indent + 1)};`;
|
|
309
|
+
});
|
|
310
|
+
return '{\n' + entries.join('\n') + '\n' + pad + '}';
|
|
311
|
+
}
|
|
312
|
+
const banner = [
|
|
313
|
+
'// HotelFriend design tokens — TypeScript declarations',
|
|
314
|
+
'// Generated from docs/tokens.figma.json — do NOT edit by hand.',
|
|
315
|
+
'',
|
|
316
|
+
'export declare const tokens: ' + typeOf(obj, 0) + ';',
|
|
317
|
+
'',
|
|
318
|
+
'export type Tokens = typeof tokens;',
|
|
319
|
+
];
|
|
320
|
+
return banner.join('\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Tailwind preset ──────────────────────────────────────────────────────────
|
|
324
|
+
function emitTailwind() {
|
|
325
|
+
// Reshape colors: { primary: { default, hover, ... }, neutral: {...}, status: {...}, badge: {...} }
|
|
326
|
+
const colors = {};
|
|
327
|
+
const fontSize = {};
|
|
328
|
+
const fontWeight = {};
|
|
329
|
+
const lineHeight = {};
|
|
330
|
+
const fontFamily = {};
|
|
331
|
+
const borderRadius = {};
|
|
332
|
+
const spacing = {};
|
|
333
|
+
const boxShadow = {};
|
|
334
|
+
const zIndex = {};
|
|
335
|
+
const transitionTimingFunction = {};
|
|
336
|
+
|
|
337
|
+
for (const { path: p, token } of all) {
|
|
338
|
+
const value = token.value;
|
|
339
|
+
if (token.type === 'color') {
|
|
340
|
+
// Build nested color object: color.primary.default → colors.primary.DEFAULT
|
|
341
|
+
// Path starts with 'color', skip first segment
|
|
342
|
+
const cp = p.slice(1);
|
|
343
|
+
let cur = colors;
|
|
344
|
+
for (let i = 0; i < cp.length - 1; i++) {
|
|
345
|
+
cur[cp[i]] = cur[cp[i]] || {};
|
|
346
|
+
cur = cur[cp[i]];
|
|
347
|
+
}
|
|
348
|
+
const last = cp[cp.length - 1];
|
|
349
|
+
cur[last === 'default' ? 'DEFAULT' : last] = value;
|
|
350
|
+
} else if (token.type === 'fontSizes') {
|
|
351
|
+
fontSize[p[1]] = value;
|
|
352
|
+
} else if (token.type === 'fontWeights') {
|
|
353
|
+
fontWeight[p[1]] = value;
|
|
354
|
+
} else if (token.type === 'lineHeights') {
|
|
355
|
+
lineHeight[p[1]] = value;
|
|
356
|
+
} else if (token.type === 'fontFamilies') {
|
|
357
|
+
fontFamily[p[1]] = value.split(',').map(s => s.trim());
|
|
358
|
+
} else if (token.type === 'borderRadius') {
|
|
359
|
+
borderRadius[p[1]] = value;
|
|
360
|
+
} else if (token.type === 'spacing') {
|
|
361
|
+
spacing[p[1]] = value;
|
|
362
|
+
} else if (token.type === 'sizing') {
|
|
363
|
+
spacing[p[1]] = value;
|
|
364
|
+
} else if (token.type === 'boxShadow') {
|
|
365
|
+
const v = value;
|
|
366
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
367
|
+
boxShadow[p[1]] = `${v.x}px ${v.y}px ${v.blur}px ${v.spread}px ${v.color}`;
|
|
368
|
+
} else {
|
|
369
|
+
boxShadow[p[1]] = String(v);
|
|
370
|
+
}
|
|
371
|
+
} else if (token.type === 'other') {
|
|
372
|
+
if (p[0] === 'zIndex') zIndex[p[1]] = String(value);
|
|
373
|
+
else if (p[0] === 'motion') transitionTimingFunction[p[1]] = String(value);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const lines = [];
|
|
378
|
+
lines.push('/**');
|
|
379
|
+
lines.push(' * HotelFriend design tokens — Tailwind v3 preset (legacy)');
|
|
380
|
+
lines.push(' * Generated from docs/tokens.figma.json — do NOT edit by hand.');
|
|
381
|
+
lines.push(' *');
|
|
382
|
+
lines.push(' * For new projects use --target=tailwind-v4 (pre-built/tailwind.css).');
|
|
383
|
+
lines.push(' *');
|
|
384
|
+
lines.push(' * Usage in tailwind.config.js:');
|
|
385
|
+
lines.push(' * const hfPreset = require("./tailwind.preset.js");');
|
|
386
|
+
lines.push(' * module.exports = { presets: [hfPreset], content: [...] };');
|
|
387
|
+
lines.push(' */');
|
|
388
|
+
lines.push('module.exports = {');
|
|
389
|
+
lines.push(' theme: {');
|
|
390
|
+
lines.push(' extend: {');
|
|
391
|
+
lines.push(' colors: ' + JSON.stringify(colors, null, 6).split('\n').join('\n ') + ',');
|
|
392
|
+
lines.push(' fontFamily: ' + JSON.stringify(fontFamily, null, 6).split('\n').join('\n ') + ',');
|
|
393
|
+
lines.push(' fontSize: ' + JSON.stringify(fontSize, null, 6).split('\n').join('\n ') + ',');
|
|
394
|
+
lines.push(' fontWeight: ' + JSON.stringify(fontWeight, null, 6).split('\n').join('\n ') + ',');
|
|
395
|
+
lines.push(' lineHeight: ' + JSON.stringify(lineHeight, null, 6).split('\n').join('\n ') + ',');
|
|
396
|
+
lines.push(' borderRadius: ' + JSON.stringify(borderRadius, null, 6).split('\n').join('\n ') + ',');
|
|
397
|
+
lines.push(' spacing: ' + JSON.stringify(spacing, null, 6).split('\n').join('\n ') + ',');
|
|
398
|
+
lines.push(' boxShadow: ' + JSON.stringify(boxShadow, null, 6).split('\n').join('\n ') + ',');
|
|
399
|
+
lines.push(' zIndex: ' + JSON.stringify(zIndex, null, 6).split('\n').join('\n ') + ',');
|
|
400
|
+
lines.push(' transitionTimingFunction: ' + JSON.stringify(transitionTimingFunction, null, 6).split('\n').join('\n ') + ',');
|
|
401
|
+
lines.push(' },');
|
|
402
|
+
lines.push(' },');
|
|
403
|
+
lines.push('};');
|
|
404
|
+
return lines.join('\n');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Tailwind v4 CSS theme ────────────────────────────────────────────────────
|
|
408
|
+
function emitTailwind4() {
|
|
409
|
+
const lines = [];
|
|
410
|
+
lines.push('/*');
|
|
411
|
+
lines.push(' * HotelFriend design tokens — Tailwind v4 CSS theme');
|
|
412
|
+
lines.push(' * Generated from docs/tokens.figma.json — do NOT edit by hand.');
|
|
413
|
+
lines.push(' *');
|
|
414
|
+
lines.push(' * Usage in your main CSS entry file:');
|
|
415
|
+
lines.push(' * @import "tailwindcss";');
|
|
416
|
+
lines.push(' * @import "./pre-built/tailwind.css";');
|
|
417
|
+
lines.push(' *');
|
|
418
|
+
lines.push(' * Tailwind v4 auto-generates utilities from @theme variables:');
|
|
419
|
+
lines.push(' * --color-* → bg-*, text-*, border-*, ring-*, …');
|
|
420
|
+
lines.push(' * --text-* → text-*');
|
|
421
|
+
lines.push(' * --font-* → font-* (family)');
|
|
422
|
+
lines.push(' * --font-weight-*→ font-* (weight)');
|
|
423
|
+
lines.push(' * --leading-* → leading-*');
|
|
424
|
+
lines.push(' * --radius-* → rounded-*');
|
|
425
|
+
lines.push(' * --shadow-* → shadow-*');
|
|
426
|
+
lines.push(' * --z-* → z-*');
|
|
427
|
+
lines.push(' */');
|
|
428
|
+
lines.push('@theme {');
|
|
429
|
+
|
|
430
|
+
// Colors — --color-{path} (drop leading "color" segment; collapse "default" leaf)
|
|
431
|
+
// Refs (e.g. semantic.color.accent → "{primitive.color.blue.500}") emit as var() chain.
|
|
432
|
+
lines.push(' /* Colors */');
|
|
433
|
+
for (const { path: p, token } of all) {
|
|
434
|
+
if (token.type !== 'color') continue;
|
|
435
|
+
lines.push(` --color-${suffix(p.slice(1))}: ${resolveValue(token, tokenIndex, 'css')};`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Font families — map primary→sans per Tailwind v4 convention
|
|
439
|
+
lines.push(' /* Typography — font families */');
|
|
440
|
+
for (const { path: p, token } of all) {
|
|
441
|
+
if (token.type !== 'fontFamilies') continue;
|
|
442
|
+
// Special case: tokens.figma.json `fontFamily.primary` → Tailwind v4 `--font-sans` (default family)
|
|
443
|
+
// PREFIX still applied: `--font-hf-sans`
|
|
444
|
+
const isSansAlias = p[p.length - 1] === 'primary';
|
|
445
|
+
const name = isSansAlias ? prefixNoDash() + '-sans' : suffix(p.slice(1));
|
|
446
|
+
lines.push(` --font-${name}: ${token.value};`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Font weights — --font-weight-{name}
|
|
450
|
+
lines.push(' /* Typography — font weights */');
|
|
451
|
+
for (const { path: p, token } of all) {
|
|
452
|
+
if (token.type !== 'fontWeights') continue;
|
|
453
|
+
lines.push(` --font-weight-${suffix(p.slice(1))}: ${token.value};`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Font sizes — --text-{name}
|
|
457
|
+
lines.push(' /* Typography — font sizes */');
|
|
458
|
+
for (const { path: p, token } of all) {
|
|
459
|
+
if (token.type !== 'fontSizes') continue;
|
|
460
|
+
lines.push(` --text-${suffix(p.slice(1))}: ${token.value};`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Line heights — --leading-{name}
|
|
464
|
+
lines.push(' /* Typography — line heights */');
|
|
465
|
+
for (const { path: p, token } of all) {
|
|
466
|
+
if (token.type !== 'lineHeights') continue;
|
|
467
|
+
lines.push(` --leading-${suffix(p.slice(1))}: ${token.value};`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Border radius — --radius-{name}
|
|
471
|
+
lines.push(' /* Border radius */');
|
|
472
|
+
for (const { path: p, token } of all) {
|
|
473
|
+
if (token.type !== 'borderRadius') continue;
|
|
474
|
+
lines.push(` --radius-${suffix(p.slice(1))}: ${token.value};`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Spacing / sizing — --spacing-{name}
|
|
478
|
+
lines.push(' /* Spacing */');
|
|
479
|
+
for (const { path: p, token } of all) {
|
|
480
|
+
if (token.type !== 'spacing' && token.type !== 'sizing') continue;
|
|
481
|
+
lines.push(` --spacing-${suffix(p.slice(1))}: ${token.value};`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Box shadows — --shadow-{name}
|
|
485
|
+
lines.push(' /* Shadows */');
|
|
486
|
+
for (const { path: p, token } of all) {
|
|
487
|
+
if (token.type !== 'boxShadow') continue;
|
|
488
|
+
const v = token.value;
|
|
489
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
490
|
+
lines.push(` --shadow-${suffix(p.slice(1))}: ${v.x}px ${v.y}px ${v.blur}px ${v.spread}px ${v.color};`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Z-index — --z-{name}
|
|
495
|
+
lines.push(' /* Z-index */');
|
|
496
|
+
for (const { path: p, token } of all) {
|
|
497
|
+
if (token.type === 'other' && p[0] === 'zIndex') {
|
|
498
|
+
lines.push(` --z-${suffix(p.slice(1))}: ${token.value};`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
lines.push('}');
|
|
503
|
+
return lines.join('\n');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── shadcn/ui target ─────────────────────────────────────────────────────────
|
|
507
|
+
function emitShadcn() {
|
|
508
|
+
// shadcn v4 uses @theme inline with --color-* prefixed variables.
|
|
509
|
+
// Full contract: ~25 semantic slots + sidebar tokens + font + radius.
|
|
510
|
+
const lines = [];
|
|
511
|
+
lines.push('/* HotelFriend design tokens — shadcn/ui v4 contract');
|
|
512
|
+
lines.push(' * Generated from docs/tokens.figma.json — do NOT edit by hand.');
|
|
513
|
+
lines.push(' *');
|
|
514
|
+
lines.push(' * Usage: append to app/globals.css (after @import "tailwindcss")');
|
|
515
|
+
lines.push(' * @import "./pre-built/shadcn-tokens.css";');
|
|
516
|
+
lines.push(' */');
|
|
517
|
+
lines.push('@theme inline {');
|
|
518
|
+
lines.push(' /* ── Base ── */');
|
|
519
|
+
lines.push(' --color-background: #FFFFFF;');
|
|
520
|
+
lines.push(' --color-foreground: #2B2B2B; /* text-primary */');
|
|
521
|
+
lines.push('');
|
|
522
|
+
lines.push(' /* ── Card ── */');
|
|
523
|
+
lines.push(' --color-card: #FFFFFF;');
|
|
524
|
+
lines.push(' --color-card-foreground: #2B2B2B;');
|
|
525
|
+
lines.push('');
|
|
526
|
+
lines.push(' /* ── Popover ── */');
|
|
527
|
+
lines.push(' --color-popover: #FFFFFF;');
|
|
528
|
+
lines.push(' --color-popover-foreground: #2B2B2B;');
|
|
529
|
+
lines.push('');
|
|
530
|
+
lines.push(' /* ── Primary ── */');
|
|
531
|
+
lines.push(' --color-primary: #24AFE8; /* canonical brand */');
|
|
532
|
+
lines.push(' --color-primary-foreground: #FFFFFF;');
|
|
533
|
+
lines.push('');
|
|
534
|
+
lines.push(' /* ── Secondary ── */');
|
|
535
|
+
lines.push(' --color-secondary: #E4E8EF; /* neutral border */');
|
|
536
|
+
lines.push(' --color-secondary-foreground: #4B5675; /* text-secondary */');
|
|
537
|
+
lines.push('');
|
|
538
|
+
lines.push(' /* ── Muted ── */');
|
|
539
|
+
lines.push(' --color-muted: #F1F3F6; /* very-light bg */');
|
|
540
|
+
lines.push(' --color-muted-foreground: #99A1B7; /* placeholder */');
|
|
541
|
+
lines.push('');
|
|
542
|
+
lines.push(' /* ── Accent ── */');
|
|
543
|
+
lines.push(' --color-accent: #E9F6FC; /* primary light tint */');
|
|
544
|
+
lines.push(' --color-accent-foreground: #149AD1; /* primary-hover */');
|
|
545
|
+
lines.push('');
|
|
546
|
+
lines.push(' /* ── Destructive ── */');
|
|
547
|
+
lines.push(' --color-destructive: #EA6565; /* status error */');
|
|
548
|
+
lines.push(' --color-destructive-foreground: #FFFFFF;');
|
|
549
|
+
lines.push('');
|
|
550
|
+
lines.push(' /* ── Border / Input / Ring ── */');
|
|
551
|
+
lines.push(' --color-border: #D1D6DD;');
|
|
552
|
+
lines.push(' --color-input: #D1D6DD;');
|
|
553
|
+
lines.push(' --color-ring: #24AFE8; /* focus ring = primary */');
|
|
554
|
+
lines.push('');
|
|
555
|
+
lines.push(' /* ── Chart palette (maps to HF status colors) ── */');
|
|
556
|
+
lines.push(' --color-chart-1: #59B59D; /* success */');
|
|
557
|
+
lines.push(' --color-chart-2: #FFBD5A; /* warning */');
|
|
558
|
+
lines.push(' --color-chart-3: #24AFE8; /* primary */');
|
|
559
|
+
lines.push(' --color-chart-4: #F87921; /* coral */');
|
|
560
|
+
lines.push(' --color-chart-5: #EA6565; /* error */');
|
|
561
|
+
lines.push('');
|
|
562
|
+
lines.push(' /* ── Sidebar ── */');
|
|
563
|
+
lines.push(' --color-sidebar: #F6F7FB; /* bg-accent */');
|
|
564
|
+
lines.push(' --color-sidebar-foreground: #4B5675;');
|
|
565
|
+
lines.push(' --color-sidebar-primary: #24AFE8;');
|
|
566
|
+
lines.push(' --color-sidebar-primary-foreground: #FFFFFF;');
|
|
567
|
+
lines.push(' --color-sidebar-accent: #E9F6FC;');
|
|
568
|
+
lines.push(' --color-sidebar-accent-foreground: #2B2B2B;');
|
|
569
|
+
lines.push(' --color-sidebar-border: #E4E8EF;');
|
|
570
|
+
lines.push(' --color-sidebar-ring: #24AFE8;');
|
|
571
|
+
lines.push('');
|
|
572
|
+
lines.push(' /* ── Radius ── */');
|
|
573
|
+
lines.push(' --radius: 6px; /* $br-sm */');
|
|
574
|
+
lines.push('');
|
|
575
|
+
lines.push(' /* ── Font ── */');
|
|
576
|
+
lines.push(' --font-sans: Roboto, sans-serif;');
|
|
577
|
+
lines.push('}');
|
|
578
|
+
return lines.join('\n');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── status-css target ─────────────────────────────────────────────────────────
|
|
582
|
+
// Reads `status-map.json` and emits CSS rules:
|
|
583
|
+
// .status-{domain}-{state} { color: var(--color-hf-status-{role}); background: var(...-bg); }
|
|
584
|
+
// One canonical source for the domain→semantic-role mapping, replacing the
|
|
585
|
+
// hand-maintained block previously in `pre-built/components.css` (RFC-0002 §Q7 refinement).
|
|
586
|
+
function emitStatusCss() {
|
|
587
|
+
const mapPath = path.resolve(__dirname, 'status-map.json');
|
|
588
|
+
if (!fs.existsSync(mapPath)) {
|
|
589
|
+
throw new Error(`status-map.json not found at ${mapPath}. Required for --target=status-css.`);
|
|
590
|
+
}
|
|
591
|
+
const map = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
|
|
592
|
+
|
|
593
|
+
const lines = [];
|
|
594
|
+
lines.push('/*');
|
|
595
|
+
lines.push(' * HotelFriend status pills — domain → semantic mapping');
|
|
596
|
+
lines.push(' * Generated from docs/status-map.json — do NOT edit by hand.');
|
|
597
|
+
lines.push(' *');
|
|
598
|
+
lines.push(' * Each .status-{domain}-{state} class binds to a semantic status slot');
|
|
599
|
+
lines.push(' * (--color-hf-status-{role}) so adding a new domain status is JSON-only —');
|
|
600
|
+
lines.push(' * no CSS edit needed.');
|
|
601
|
+
lines.push(' *');
|
|
602
|
+
lines.push(' * Pair with .hf-pill from components.css for the visual chrome.');
|
|
603
|
+
lines.push(' */');
|
|
604
|
+
lines.push('');
|
|
605
|
+
|
|
606
|
+
// Collect domain entries (everything except keys starting with $ or _)
|
|
607
|
+
for (const [domain, states] of Object.entries(map)) {
|
|
608
|
+
if (domain.startsWith('$')) continue;
|
|
609
|
+
if (domain === '_orphan') {
|
|
610
|
+
// Standalone classes (.status-{key} — no domain prefix)
|
|
611
|
+
lines.push('/* Standalone (no domain prefix) */');
|
|
612
|
+
for (const [state, role] of Object.entries(states)) {
|
|
613
|
+
if (state.startsWith('$')) continue;
|
|
614
|
+
const colorVar = `--color-${PREFIX}status-${role}`;
|
|
615
|
+
const bgVar = `--color-${PREFIX}status-${role}-bg`;
|
|
616
|
+
lines.push(`.status-${state} { color: var(${colorVar}); background: var(${bgVar}); }`);
|
|
617
|
+
}
|
|
618
|
+
lines.push('');
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// Per-domain block
|
|
622
|
+
const domainTitle = domain.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
623
|
+
lines.push(`/* ${domainTitle} */`);
|
|
624
|
+
// Group by role so identical mappings (all cancellations → cancel) collapse to one selector list
|
|
625
|
+
const byRole = {};
|
|
626
|
+
for (const [state, role] of Object.entries(states)) {
|
|
627
|
+
if (state.startsWith('$')) continue;
|
|
628
|
+
(byRole[role] = byRole[role] || []).push(state);
|
|
629
|
+
}
|
|
630
|
+
for (const [role, statesArr] of Object.entries(byRole)) {
|
|
631
|
+
const selectors = statesArr.map(s => `.status-${domain}-${s}`).join(',\n');
|
|
632
|
+
const colorVar = `--color-${PREFIX}status-${role}`;
|
|
633
|
+
const bgVar = `--color-${PREFIX}status-${role}-bg`;
|
|
634
|
+
lines.push(`${selectors} { color: var(${colorVar}); background: var(${bgVar}); }`);
|
|
635
|
+
}
|
|
636
|
+
lines.push('');
|
|
637
|
+
}
|
|
638
|
+
return lines.join('\n');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── Dispatch ─────────────────────────────────────────────────────────────────
|
|
642
|
+
//
|
|
643
|
+
// `tailwind-v4-additive` is an explicit alias for `tailwind-v4`:
|
|
644
|
+
// - After Phase 1A's `hf-` prefix, every emitted token is namespace-isolated:
|
|
645
|
+
// --color-hf-*, --text-hf-*, --radius-hf-*, --spacing-hf-*, --font-hf-* etc.
|
|
646
|
+
// none of these can collide with Tailwind v4 defaults (--color-*, --text-*, --radius-*, etc.).
|
|
647
|
+
// - So "additive" is a property of the prefix model, not a separate filter.
|
|
648
|
+
// - The alias exists for API discoverability (RFC-0001 §4.2) and for projects whose
|
|
649
|
+
// integration scripts explicitly request it. Identical bytes-output.
|
|
650
|
+
// - For TRULY additive-only emit (skip any non-prefixed default-overrides), no current
|
|
651
|
+
// output emits unprefixed keys — so this alias is a 1:1.
|
|
652
|
+
const out =
|
|
653
|
+
target === 'css' ? emitCss() :
|
|
654
|
+
target === 'scss' ? emitScss() :
|
|
655
|
+
target === 'ts' ? emitTs() :
|
|
656
|
+
target === 'js' ? emitJs() :
|
|
657
|
+
target === 'dts' ? emitDts() :
|
|
658
|
+
target === 'tailwind' ? emitTailwind() :
|
|
659
|
+
target === 'tailwind-v4' ? emitTailwind4() :
|
|
660
|
+
target === 'tailwind-v4-additive' ? emitTailwind4() : // alias — see comment above
|
|
661
|
+
target === 'shadcn' ? emitShadcn() :
|
|
662
|
+
target === 'status-css' ? emitStatusCss() :
|
|
663
|
+
(() => { throw new Error(`Unknown target: ${target}. Use css | scss | ts | js | dts | tailwind | tailwind-v4 | tailwind-v4-additive | status-css | shadcn`); })();
|
|
664
|
+
|
|
665
|
+
process.stdout.write(out + '\n');
|