@hotelfriendag/design-tokens 0.6.0 → 0.7.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/README.md +13 -2
- package/components.html +64 -0
- package/generate-tokens.cjs +11 -5
- package/package.json +3 -1
- package/pre-built/themes.json +31 -1
- package/pre-built/web.css +30 -0
- package/scripts/validate-tokens.cjs +115 -1
- package/tokens.figma.json +17 -0
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Public package — no `.npmrc`, no auth, no CI secrets. Works from any project,
|
|
|
25
25
|
|
|
26
26
|
## Wire it up (pick your stack)
|
|
27
27
|
|
|
28
|
-
The package exposes each generated file via a subpath export, e.g. `@hotelfriendag/design-tokens/tailwind.css`, `/components.css`, `/status.css`, `/_tokens.scss`, `/tokens.css`, `/tokens.ts`, `/shadcn-tokens.css`, `/dark.css`, `/utilities.css`, `/_ionic.scss`, `/themes.json`.
|
|
28
|
+
The package exposes each generated file via a subpath export, e.g. `@hotelfriendag/design-tokens/tailwind.css`, `/components.css`, `/status.css`, `/_tokens.scss`, `/tokens.css`, `/tokens.ts`, `/shadcn-tokens.css`, `/dark.css`, `/web.css`, `/utilities.css`, `/_ionic.scss`, `/themes.json`.
|
|
29
29
|
|
|
30
30
|
### Tailwind v4 (recommended — e.g. `ui-hf`)
|
|
31
31
|
|
|
@@ -105,7 +105,18 @@ Then use `var(--color-hf-accent)`, `var(--font-size-hf-base)`, or `.hf-modal` /
|
|
|
105
105
|
@import "@hotelfriendag/design-tokens/dark.css"; /* [data-theme="dark"] overrides */
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
Then `<html data-theme="dark">` (or any container) switches the whole subtree. `pre-built/themes.json` is the machine-readable list of which semantic vars a theme overrides (the validation contract for custom themes).
|
|
108
|
+
Then `<html data-theme="dark">` (or any container) switches the whole subtree. `pre-built/themes.json` is the machine-readable list of which semantic vars a theme overrides (the validation contract for custom themes, enforced by validator check #8).
|
|
109
|
+
|
|
110
|
+
### Web / marketing theme
|
|
111
|
+
|
|
112
|
+
`web.css` is a `[data-theme="web"]` theme for the marketing surface (`web-hf`, hotelfriend.com). It is the **same brand** as the portal — **zero color overrides** (accent, neutrals, Roboto unchanged) — and forks only the **rhythm**: larger fluid type (`--font-size-hf-*`, with `clamp()` page/hero), rounder radius (`--radius-hf-*`), and softer layered elevation (`--shadow-hf-*`). It demonstrates the `[data-theme]` axis extended to non-color semantic tiers (founder decision D5). Import it after `tokens.css` and flip `<html data-theme="web">`:
|
|
113
|
+
|
|
114
|
+
```css
|
|
115
|
+
@import "@hotelfriendag/design-tokens/tokens.css";
|
|
116
|
+
@import "@hotelfriendag/design-tokens/web.css"; /* [data-theme="web"] — non-color rhythm */
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Then `<html data-theme="web">` (or any region) re-rhythms the subtree. web-hf's pricing-tier sub-brand (gold/navy) stays local to web-hf (decision D6) and is intentionally not shipped here.
|
|
109
120
|
|
|
110
121
|
### Utilities (non-Tailwind)
|
|
111
122
|
|
package/components.html
CHANGED
|
@@ -877,6 +877,34 @@
|
|
|
877
877
|
--color-hf-bg-inverse: var(--color-hf-gray-25);
|
|
878
878
|
}
|
|
879
879
|
|
|
880
|
+
/* ── Web / marketing theme (v0.7) — mirrors pre-built/web.css per this file's token-duplication design.
|
|
881
|
+
Same brand as the portal (ZERO color overrides — accent/neutrals/Roboto unchanged); only the
|
|
882
|
+
RHYTHM forks: larger fluid type, rounder radius, softer layered elevation. The [data-theme] axis
|
|
883
|
+
extended to NON-COLOR semantic tiers (founder decision D5 / approval-8f6baafb). Lives in the PLAIN
|
|
884
|
+
<style> for the same reason the :root + dark blocks do (Tailwind v4's CDN tree-shakes @theme vars
|
|
885
|
+
and won't trace these var() chains). web-hf's pricing-tier sub-brand (gold/navy) stays LOCAL to
|
|
886
|
+
web-hf per decision D6 (approval-dbf6f1f2) — deliberately NOT here.
|
|
887
|
+
Keep in sync with pre-built/web.css ($theme-overrides.web in tokens.figma.json). ── */
|
|
888
|
+
[data-theme="web"] {
|
|
889
|
+
/* type — larger + fluid (clamp) marketing scale */
|
|
890
|
+
--font-size-hf-base: 16px;
|
|
891
|
+
--font-size-hf-md: 16px;
|
|
892
|
+
--font-size-hf-lg: 18px;
|
|
893
|
+
--font-size-hf-xl: 20px;
|
|
894
|
+
--font-size-hf-2xl: 24px;
|
|
895
|
+
--font-size-hf-h2: 28px;
|
|
896
|
+
--font-size-hf-page: clamp(2.125rem, 1.55rem + 2.4vw, 3rem);
|
|
897
|
+
--font-size-hf-hero: clamp(2.5rem, 1.9rem + 2.6vw, 3.5rem);
|
|
898
|
+
/* radius — rounder */
|
|
899
|
+
--radius-hf-sm: 8px;
|
|
900
|
+
--radius-hf-md: 12px;
|
|
901
|
+
--radius-hf-lg: 16px;
|
|
902
|
+
--radius-hf-xl: 24px;
|
|
903
|
+
/* elevation — softer, layered */
|
|
904
|
+
--shadow-hf-card: 0 20px 40px -16px rgba(30,58,95,0.18);
|
|
905
|
+
--shadow-hf-hover: 0 12px 28px -10px rgba(30,58,95,0.22);
|
|
906
|
+
}
|
|
907
|
+
|
|
880
908
|
/* ── Code snippet ── */
|
|
881
909
|
.snippet { position: relative; background: #1e293b; border-radius: 6px; margin-top: 16px; }
|
|
882
910
|
.snippet pre { padding: 14px 16px; overflow-x: auto; font-size: 12px; line-height: 1.6; color: #cbd5e1; font-family: 'Roboto Mono','Fira Code',monospace; margin: 0; }
|
|
@@ -1108,6 +1136,42 @@
|
|
|
1108
1136
|
<html data-theme="dark">…</html></pre>
|
|
1109
1137
|
</div>
|
|
1110
1138
|
|
|
1139
|
+
<!-- Web / marketing theme (v0.7) -->
|
|
1140
|
+
<div class="sub-title mt-8">Theme / web marketing rhythm <span class="text-meta text-ink-secondary">(v0.7 — set <code class="bg-muted px-1 rounded text-meta">data-theme="web"</code>; the theme axis now spans NON-COLOR tiers)</span></div>
|
|
1141
|
+
<p class="text-body text-ink-secondary mb-4">Same brand as the portal — <strong>zero color overrides</strong> (accent / neutrals / Roboto unchanged). What forks is the <strong>rhythm</strong>: larger fluid type (<code class="bg-muted px-1 rounded text-meta">clamp()</code> page/hero), rounder radius, softer layered shadows — the marketing surface (<code class="bg-muted px-1 rounded text-meta">web-hf</code>, hotelfriend.com). Demonstrates the <code class="bg-muted px-1 rounded text-meta">[data-theme]</code> axis extended to type / radius / elevation (founder decision D5). Shipped as <code class="bg-muted px-1 rounded text-meta">web.css</code>. <span class="text-ink-faint">web-hf's pricing-tier sub-brand (gold/navy) stays local to web-hf — decision D6.</span></p>
|
|
1142
|
+
<div class="demo-row mb-3">
|
|
1143
|
+
<button class="hf-btn hf-btn--outline" onclick="var h=document.documentElement; h.setAttribute('data-theme', h.getAttribute('data-theme')==='web' ? '' : 'web');">Toggle page web rhythm</button>
|
|
1144
|
+
<span class="state-label">live — flips <code class="bg-muted px-1 rounded text-meta"><html data-theme></code></span>
|
|
1145
|
+
</div>
|
|
1146
|
+
<div class="grid grid-cols-2 gap-4 mb-2">
|
|
1147
|
+
<div data-theme="" class="p-4 rounded-lg border" style="border-color:var(--color-hf-border);background:var(--color-hf-bg-section)">
|
|
1148
|
+
<div class="state-label mb-2">default (portal rhythm)</div>
|
|
1149
|
+
<div style="font-size:var(--font-size-hf-page);line-height:1.15;color:var(--color-hf-fg)" class="font-semibold mb-1">Stay effortless</div>
|
|
1150
|
+
<div style="font-size:var(--font-size-hf-base);color:var(--color-hf-fg-muted)" class="mb-3">26px page title · 12px card radius · standard shadow.</div>
|
|
1151
|
+
<div style="background:var(--color-hf-bg-surface);border:1px solid var(--color-hf-border-subtle);border-radius:var(--radius-hf-xl);box-shadow:var(--shadow-hf-card);padding:20px">
|
|
1152
|
+
<div style="font-size:var(--font-size-hf-xl)" class="font-semibold mb-1">Card</div>
|
|
1153
|
+
<button class="hf-btn hf-btn--primary">Book now</button>
|
|
1154
|
+
</div>
|
|
1155
|
+
</div>
|
|
1156
|
+
<div data-theme="web" class="p-4 rounded-lg border" style="border-color:var(--color-hf-border);background:var(--color-hf-bg-section)">
|
|
1157
|
+
<div class="state-label mb-2" style="color:var(--color-hf-fg-faint)">data-theme="web"</div>
|
|
1158
|
+
<div style="font-size:var(--font-size-hf-page);line-height:1.15;color:var(--color-hf-fg)" class="font-semibold mb-1">Stay effortless</div>
|
|
1159
|
+
<div style="font-size:var(--font-size-hf-base);color:var(--color-hf-fg-muted)" class="mb-3">fluid page title · 24px card radius · softer layered shadow.</div>
|
|
1160
|
+
<div style="background:var(--color-hf-bg-surface);border:1px solid var(--color-hf-border-subtle);border-radius:var(--radius-hf-xl);box-shadow:var(--shadow-hf-card);padding:20px">
|
|
1161
|
+
<div style="font-size:var(--font-size-hf-xl)" class="font-semibold mb-1">Card</div>
|
|
1162
|
+
<button class="hf-btn hf-btn--primary">Book now</button>
|
|
1163
|
+
</div>
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
<div class="snippet">
|
|
1167
|
+
<button class="copy-btn" onclick="copySnippet(this)">Copy</button>
|
|
1168
|
+
<pre>
|
|
1169
|
+
<!-- import once, after tokens.css so the override wins -->
|
|
1170
|
+
@import "@hotelfriendag/design-tokens/web.css";
|
|
1171
|
+
<!-- then flip the marketing surface (whole app or any region) -->
|
|
1172
|
+
<html data-theme="web">…</html></pre>
|
|
1173
|
+
</div>
|
|
1174
|
+
|
|
1111
1175
|
<div class="sub-title mt-8">Portal recipes <span class="text-meta text-ink-secondary">(raw utilities — the source <code class="bg-muted px-1 rounded text-meta">.hf-btn</code> was distilled from)</span></div>
|
|
1112
1176
|
|
|
1113
1177
|
<!-- Filled buttons -->
|
package/generate-tokens.cjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* --target=status-css → .status-{domain}-{state} { color: var(...) } (read from status-map.json)
|
|
19
19
|
* --target=components-css → .hf-* component primitives (read from src/components.css)
|
|
20
20
|
* --target=dark-css → [data-theme="dark"] { ... } (semantic overrides from $theme-overrides.dark)
|
|
21
|
+
* --target=web-css → [data-theme="web"] { ... } (non-color semantic overrides from $theme-overrides.web — marketing rhythm)
|
|
21
22
|
* --target=utilities-css → .bg-hf-* / .text-hf-* / .border-hf-* / .shadow-hf-* (semantic-tier utilities, non-Tailwind consumers)
|
|
22
23
|
* --target=ionic-scss → :root { --ion-color-*: ... } (Ionic theme partial — computed -rgb/-shade/-tint/-contrast)
|
|
23
24
|
* --target=themes-manifest → { themeable: [...], themes: {...} } (machine-readable variation contract → themes.json)
|
|
@@ -709,10 +710,14 @@ function emitComponentsCss() {
|
|
|
709
710
|
return fs.readFileSync(srcPath, 'utf8').replace(/\s*$/, '');
|
|
710
711
|
}
|
|
711
712
|
|
|
712
|
-
// ── dark-css target
|
|
713
|
-
// Emits `[data-theme="
|
|
714
|
-
//
|
|
715
|
-
//
|
|
713
|
+
// ── dark-css / web-css target ─────────────────────────────────────────────────
|
|
714
|
+
// Emits `[data-theme="<name>"] { ... }` from the named theme in $theme-overrides.
|
|
715
|
+
// Parameterised by theme name — `dark-css` passes 'dark', `web-css` passes 'web'.
|
|
716
|
+
// For COLOR themes (dark) semantic vars are re-pointed to primitives via var()
|
|
717
|
+
// chains (theming stays alias-based — no inlined hex), mirroring the ADOPTION.md
|
|
718
|
+
// template. For NON-COLOR themes (web — founder decision D5) the override values
|
|
719
|
+
// are raw type/radius/shadow literals (e.g. clamp() fluid type), which pass through
|
|
720
|
+
// themeDeclValue() unchanged — the [data-theme] axis spans NON-COLOR semantic tiers.
|
|
716
721
|
function emitThemeCss(themeName) {
|
|
717
722
|
if (!themeOverrides[themeName]) {
|
|
718
723
|
throw new Error(`Theme "${themeName}" not found in tokens.figma.json $theme-overrides. Available: ${themeNames.join(', ') || '(none)'}`);
|
|
@@ -900,9 +905,10 @@ const out =
|
|
|
900
905
|
target === 'status-css' ? emitStatusCss() :
|
|
901
906
|
target === 'components-css' ? emitComponentsCss() :
|
|
902
907
|
target === 'dark-css' ? emitThemeCss('dark') :
|
|
908
|
+
target === 'web-css' ? emitThemeCss('web') :
|
|
903
909
|
target === 'utilities-css' ? emitUtilitiesCss() :
|
|
904
910
|
target === 'ionic-scss' ? emitIonicScss() :
|
|
905
911
|
target === 'themes-manifest' ? emitThemesManifest() :
|
|
906
|
-
(() => { throw new Error(`Unknown target: ${target}. Use css | scss | ts | js | dts | tailwind | tailwind-v4 | tailwind-v4-additive | status-css | components-css | dark-css | utilities-css | ionic-scss | themes-manifest | shadcn`); })();
|
|
912
|
+
(() => { throw new Error(`Unknown target: ${target}. Use css | scss | ts | js | dts | tailwind | tailwind-v4 | tailwind-v4-additive | status-css | components-css | dark-css | web-css | utilities-css | ionic-scss | themes-manifest | shadcn`); })();
|
|
907
913
|
|
|
908
914
|
process.stdout.write(out + '\n');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotelfriendag/design-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "HotelFriend Design System — portable bundle (tokens, components, AI rules). Three-tier model per RFC-0002.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"./components.css": "./pre-built/components.css",
|
|
28
28
|
"./status.css": "./pre-built/status.css",
|
|
29
29
|
"./dark.css": "./pre-built/dark.css",
|
|
30
|
+
"./web.css": "./pre-built/web.css",
|
|
30
31
|
"./utilities.css": "./pre-built/utilities.css",
|
|
31
32
|
"./shadcn-tokens.css": "./pre-built/shadcn-tokens.css",
|
|
32
33
|
"./_tokens.scss": "./pre-built/_tokens.scss",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"build:tokens:dts": "node generate-tokens.cjs --target=dts > pre-built/tokens.d.ts",
|
|
72
73
|
"build:tokens:shadcn": "node generate-tokens.cjs --target=shadcn > pre-built/shadcn-tokens.css",
|
|
73
74
|
"build:tokens:dark": "node generate-tokens.cjs --target=dark-css > pre-built/dark.css",
|
|
75
|
+
"build:tokens:web": "node generate-tokens.cjs --target=web-css > pre-built/web.css",
|
|
74
76
|
"build:tokens:utilities": "node generate-tokens.cjs --target=utilities-css > pre-built/utilities.css",
|
|
75
77
|
"build:tokens:ionic": "node generate-tokens.cjs --target=ionic-scss > pre-built/_ionic.scss",
|
|
76
78
|
"build:tokens:themes": "node generate-tokens.cjs --target=themes-manifest > pre-built/themes.json",
|
package/pre-built/themes.json
CHANGED
|
@@ -16,7 +16,21 @@
|
|
|
16
16
|
"--color-hf-border-strong",
|
|
17
17
|
"--color-hf-accent-subtle",
|
|
18
18
|
"--color-hf-accent-subtler",
|
|
19
|
-
"--color-hf-bg-inverse"
|
|
19
|
+
"--color-hf-bg-inverse",
|
|
20
|
+
"--font-size-hf-base",
|
|
21
|
+
"--font-size-hf-md",
|
|
22
|
+
"--font-size-hf-lg",
|
|
23
|
+
"--font-size-hf-xl",
|
|
24
|
+
"--font-size-hf-2xl",
|
|
25
|
+
"--font-size-hf-h2",
|
|
26
|
+
"--font-size-hf-page",
|
|
27
|
+
"--font-size-hf-hero",
|
|
28
|
+
"--radius-hf-sm",
|
|
29
|
+
"--radius-hf-md",
|
|
30
|
+
"--radius-hf-lg",
|
|
31
|
+
"--radius-hf-xl",
|
|
32
|
+
"--shadow-hf-card",
|
|
33
|
+
"--shadow-hf-hover"
|
|
20
34
|
],
|
|
21
35
|
"themes": {
|
|
22
36
|
"dark": {
|
|
@@ -70,6 +84,22 @@
|
|
|
70
84
|
"ref": "{primitive.color.gray.25}",
|
|
71
85
|
"value": "#FBFBFC"
|
|
72
86
|
}
|
|
87
|
+
},
|
|
88
|
+
"web": {
|
|
89
|
+
"--font-size-hf-base": "16px",
|
|
90
|
+
"--font-size-hf-md": "16px",
|
|
91
|
+
"--font-size-hf-lg": "18px",
|
|
92
|
+
"--font-size-hf-xl": "20px",
|
|
93
|
+
"--font-size-hf-2xl": "24px",
|
|
94
|
+
"--font-size-hf-h2": "28px",
|
|
95
|
+
"--font-size-hf-page": "clamp(2.125rem, 1.55rem + 2.4vw, 3rem)",
|
|
96
|
+
"--font-size-hf-hero": "clamp(2.5rem, 1.9rem + 2.6vw, 3.5rem)",
|
|
97
|
+
"--radius-hf-sm": "8px",
|
|
98
|
+
"--radius-hf-md": "12px",
|
|
99
|
+
"--radius-hf-lg": "16px",
|
|
100
|
+
"--radius-hf-xl": "24px",
|
|
101
|
+
"--shadow-hf-card": "0 20px 40px -16px rgba(30,58,95,0.18)",
|
|
102
|
+
"--shadow-hf-hover": "0 12px 28px -10px rgba(30,58,95,0.22)"
|
|
73
103
|
}
|
|
74
104
|
}
|
|
75
105
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* HotelFriend design tokens — "web" theme override
|
|
3
|
+
* Generated from tokens.figma.json ($theme-overrides) — do NOT edit by hand.
|
|
4
|
+
*
|
|
5
|
+
* Re-declares the semantic layer under [data-theme] so it overrides the
|
|
6
|
+
* defaults in tokens.css. Primitives stay fixed; only the role→value mapping
|
|
7
|
+
* changes (var() chains, not inlined hex) — no fork, no rebuild for consumers.
|
|
8
|
+
*
|
|
9
|
+
* Usage (import AFTER tokens.css so the override wins):
|
|
10
|
+
* @import "@hotelfriendag/design-tokens/web.css";
|
|
11
|
+
* <html data-theme="web">…</html>
|
|
12
|
+
*
|
|
13
|
+
* Marketing/web surface (web-hf, hotelfriend.com) — founder decision D5 (approval-8f6baafb). SAME brand as the portal (accent/neutrals/Roboto unchanged — zero color overrides on purpose); only the RHYTHM forks: larger fluid type, rounder radius, softer layered elevation. Demonstrates the [data-theme] axis extended to NON-COLOR semantic tokens. web-hf's pricing-tier sub-brand (gold/navy) stays LOCAL to web-hf per decision D6 (approval-dbf6f1f2) — deliberately NOT here.
|
|
14
|
+
*/
|
|
15
|
+
[data-theme="web"] {
|
|
16
|
+
--font-size-hf-base: 16px;
|
|
17
|
+
--font-size-hf-md: 16px;
|
|
18
|
+
--font-size-hf-lg: 18px;
|
|
19
|
+
--font-size-hf-xl: 20px;
|
|
20
|
+
--font-size-hf-2xl: 24px;
|
|
21
|
+
--font-size-hf-h2: 28px;
|
|
22
|
+
--font-size-hf-page: clamp(2.125rem, 1.55rem + 2.4vw, 3rem);
|
|
23
|
+
--font-size-hf-hero: clamp(2.5rem, 1.9rem + 2.6vw, 3.5rem);
|
|
24
|
+
--radius-hf-sm: 8px;
|
|
25
|
+
--radius-hf-md: 12px;
|
|
26
|
+
--radius-hf-lg: 16px;
|
|
27
|
+
--radius-hf-xl: 24px;
|
|
28
|
+
--shadow-hf-card: 0 20px 40px -16px rgba(30,58,95,0.18);
|
|
29
|
+
--shadow-hf-hover: 0 12px 28px -10px rgba(30,58,95,0.22);
|
|
30
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/*
|
|
3
3
|
* validate-tokens.cjs — drift detection for the design-system bundle.
|
|
4
4
|
*
|
|
5
|
-
* Runs
|
|
5
|
+
* Runs eight checks against pre-built/*.css and related files:
|
|
6
6
|
* 1. Every `var(--*)` reference resolves to a defined CSS custom property.
|
|
7
7
|
* 2. No bare hex literals in components.css / status.css (outside comments + var() fallbacks).
|
|
8
8
|
* 3. Every doc-snippet token reference exists in pre-built/*.css (anti-RFC-0001 §2.2 drift).
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* 5. ai-rules/ files must not contain stale pre-extraction patterns (staleness guard).
|
|
11
11
|
* 6. WCAG AA contrast (≥4.5:1) for declared reading-text pairs (light-mode only; see scope note).
|
|
12
12
|
* 7. Gzip size budgets for pre-built CSS files (runaway growth guard).
|
|
13
|
+
* 8. Themeable allowlist gate — every $theme-override key is (a) a real defined --* var, and
|
|
14
|
+
* (b) within an ALLOWED themeable semantic tier (CONTRACT §3). Turns themes.json's
|
|
15
|
+
* `themeable[]` from documentation into an ENFORCED contract boundary: a theme may only
|
|
16
|
+
* touch themeable tiers (color/type/radius/shadow semantics) — never a primitive ramp
|
|
17
|
+
* (--color-hf-blue-500), a --z-/--spacing- tier, or any nonexistent var.
|
|
13
18
|
*
|
|
14
19
|
* Exits 1 on any failure. Use as a pre-commit hook or CI step.
|
|
15
20
|
*
|
|
@@ -257,6 +262,7 @@ const gzipBudgets = [
|
|
|
257
262
|
{ file: 'pre-built/tokens.css', ceiling: 5120 },
|
|
258
263
|
{ file: 'pre-built/utilities.css', ceiling: 4096 },
|
|
259
264
|
{ file: 'pre-built/dark.css', ceiling: 2048 },
|
|
265
|
+
{ file: 'pre-built/web.css', ceiling: 2048 },
|
|
260
266
|
{ file: 'pre-built/status.css', ceiling: 2048 },
|
|
261
267
|
{ file: 'pre-built/tailwind.css', ceiling: 3072 },
|
|
262
268
|
{ file: 'pre-built/shadcn-tokens.css', ceiling: 2048 },
|
|
@@ -280,6 +286,114 @@ for (const { file, ceiling } of gzipBudgets) {
|
|
|
280
286
|
}
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
// Check 8: themeable allowlist gate — the enforced contract boundary for the [data-theme] axis.
|
|
290
|
+
//
|
|
291
|
+
// CONTRACT §3 states "a theme may only touch themeable vars." themes.json's `themeable[]` array
|
|
292
|
+
// has so far been DOCUMENTATION (a manifest). This check turns it into an ENFORCED boundary:
|
|
293
|
+
// for EVERY theme (dark + web + any future theme) it asserts each overridden key is
|
|
294
|
+
// (a) a real --* custom property DEFINED in the generated CSS (union of --*: definitions across
|
|
295
|
+
// pre-built/tokens.css + pre-built/tailwind.css) — catches typos and overriding a var that
|
|
296
|
+
// does not exist; AND
|
|
297
|
+
// (b) within an ALLOWED themeable semantic TIER — one of the prefixes below. This is the D5
|
|
298
|
+
// governance gate: the [data-theme] axis spans the semantic color/type/radius/elevation
|
|
299
|
+
// tiers, and NOTHING else. A primitive palette ramp (--color-hf-blue-500), a layout tier
|
|
300
|
+
// (--spacing-*, --z-*, --size-*), or an out-of-scope var is rejected with a clear message —
|
|
301
|
+
// that is how divergence is prevented from creeping in via a "theme".
|
|
302
|
+
//
|
|
303
|
+
// Source of truth: prefers pre-built/themes.json (the generated manifest); falls back to
|
|
304
|
+
// tokens.figma.json $theme-overrides if the manifest is absent.
|
|
305
|
+
//
|
|
306
|
+
// Allowed themeable tiers (semantic only):
|
|
307
|
+
// --color-hf- semantic colors (accent/bg/fg/border/status/...) — EXCEPT primitive ramps
|
|
308
|
+
// --font-size-hf- type scale
|
|
309
|
+
// --font-weight-hf- weights
|
|
310
|
+
// --line-height-hf- line heights
|
|
311
|
+
// --font-hf- / --font-family font family
|
|
312
|
+
// --radius-hf- corner radius
|
|
313
|
+
// --shadow-hf- elevation
|
|
314
|
+
// The --color-hf- tier is allowed for SEMANTIC roles only; primitive palette ramps
|
|
315
|
+
// (--color-hf-{family}-{step}, where family is a known palette name, plus white/black) are
|
|
316
|
+
// rejected — themes re-map roles, they never re-define the palette.
|
|
317
|
+
const THEMEABLE_TIER_PREFIXES = [
|
|
318
|
+
'--color-hf-',
|
|
319
|
+
'--font-size-hf-',
|
|
320
|
+
'--font-weight-hf-',
|
|
321
|
+
'--line-height-hf-',
|
|
322
|
+
'--font-hf-',
|
|
323
|
+
'--font-family',
|
|
324
|
+
'--radius-hf-',
|
|
325
|
+
'--shadow-hf-',
|
|
326
|
+
];
|
|
327
|
+
const PRIMITIVE_PALETTE_FAMILIES = ['blue', 'gray', 'green', 'amber', 'red', 'orange', 'violet', 'slate', 'olive', 'white', 'black'];
|
|
328
|
+
// A primitive color ramp is --color-hf-{family} or --color-hf-{family}-{step}. Semantic roles
|
|
329
|
+
// (accent/bg/fg/border/status/...) never collide with a palette family name, so this is precise.
|
|
330
|
+
const isPrimitiveColorRamp = (name) => {
|
|
331
|
+
const m = name.match(/^--color-hf-([a-z]+)(?:-\d+)?$/);
|
|
332
|
+
return !!m && PRIMITIVE_PALETTE_FAMILIES.includes(m[1]);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Union of --* definitions across the two files that carry the runtime token layer.
|
|
336
|
+
const themeDefsSet = new Set();
|
|
337
|
+
for (const f of ['pre-built/tokens.css', 'pre-built/tailwind.css']) {
|
|
338
|
+
if (!exists(f)) continue;
|
|
339
|
+
for (const m of read(f).matchAll(/--([a-z0-9-]+):/g)) themeDefsSet.add('--' + m[1]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Collect each theme's overridden keys from themes.json (preferred) or tokens.figma.json.
|
|
343
|
+
const themeKeyMap = {}; // { themeName: [varName, ...] }
|
|
344
|
+
if (exists('pre-built/themes.json')) {
|
|
345
|
+
try {
|
|
346
|
+
const manifest = JSON.parse(read('pre-built/themes.json'));
|
|
347
|
+
for (const [name, map] of Object.entries(manifest.themes || {})) {
|
|
348
|
+
themeKeyMap[name] = Object.keys(map);
|
|
349
|
+
}
|
|
350
|
+
} catch (e) {
|
|
351
|
+
findings.push({ severity: 'error', file: 'pre-built/themes.json', line: 0,
|
|
352
|
+
msg: `themes.json failed to parse for themeable gate: ${e.message}` });
|
|
353
|
+
}
|
|
354
|
+
} else if (exists('tokens.figma.json')) {
|
|
355
|
+
try {
|
|
356
|
+
const fig = JSON.parse(read('tokens.figma.json'));
|
|
357
|
+
const overrides = (fig.global && fig.global['$theme-overrides']) || {};
|
|
358
|
+
for (const [name, map] of Object.entries(overrides)) {
|
|
359
|
+
if (name.startsWith('$')) continue;
|
|
360
|
+
themeKeyMap[name] = Object.keys(map).filter(k => !k.startsWith('$'));
|
|
361
|
+
}
|
|
362
|
+
} catch (e) {
|
|
363
|
+
findings.push({ severity: 'error', file: 'tokens.figma.json', line: 0,
|
|
364
|
+
msg: `tokens.figma.json failed to parse for themeable gate: ${e.message}` });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
console.log('\nCheck 8: themeable allowlist gate (every theme override is a defined --* in an allowed semantic tier):');
|
|
369
|
+
for (const [theme, keys] of Object.entries(themeKeyMap)) {
|
|
370
|
+
let themeErrors = 0;
|
|
371
|
+
for (const key of keys) {
|
|
372
|
+
// (a) must be a real defined --* var
|
|
373
|
+
if (!themeDefsSet.has(key)) {
|
|
374
|
+
themeErrors++;
|
|
375
|
+
findings.push({ severity: 'error', file: 'pre-built/themes.json', line: 0,
|
|
376
|
+
msg: `themeable gate [${theme}]: \`${key}\` is not a defined --* var in tokens.css/tailwind.css (typo, or overriding a nonexistent var)` });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// (b) must be in an allowed themeable tier...
|
|
380
|
+
const inAllowedTier = THEMEABLE_TIER_PREFIXES.some(p => key.startsWith(p));
|
|
381
|
+
if (!inAllowedTier) {
|
|
382
|
+
themeErrors++;
|
|
383
|
+
findings.push({ severity: 'error', file: 'pre-built/themes.json', line: 0,
|
|
384
|
+
msg: `themeable gate [${theme}]: \`${key}\` is outside the themeable tiers (allowed: ${THEMEABLE_TIER_PREFIXES.join(', ')}) — a theme may only touch semantic color/type/radius/shadow tiers (CONTRACT §3)` });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
// ...and within the color tier, must be a SEMANTIC role, not a primitive palette ramp.
|
|
388
|
+
if (isPrimitiveColorRamp(key)) {
|
|
389
|
+
themeErrors++;
|
|
390
|
+
findings.push({ severity: 'error', file: 'pre-built/themes.json', line: 0,
|
|
391
|
+
msg: `themeable gate [${theme}]: \`${key}\` is a PRIMITIVE palette ramp — themes re-map semantic roles, never the palette (CONTRACT §3)` });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
console.log(` ${themeErrors === 0 ? '✓' : '✗'} ${theme}: ${keys.length} override(s)${themeErrors ? `, ${themeErrors} out of contract` : ''}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
283
397
|
// ── Report ──
|
|
284
398
|
const errors = findings.filter(f => f.severity === 'error');
|
|
285
399
|
const warns = findings.filter(f => f.severity === 'warn');
|
package/tokens.figma.json
CHANGED
|
@@ -249,6 +249,23 @@
|
|
|
249
249
|
"--color-hf-accent-subtle": "rgba(36,175,232,0.16)",
|
|
250
250
|
"--color-hf-accent-subtler": "rgba(36,175,232,0.10)",
|
|
251
251
|
"--color-hf-bg-inverse": "{primitive.color.gray.25}"
|
|
252
|
+
},
|
|
253
|
+
"web": {
|
|
254
|
+
"$description": "Marketing/web surface (web-hf, hotelfriend.com) — founder decision D5 (approval-8f6baafb). SAME brand as the portal (accent/neutrals/Roboto unchanged — zero color overrides on purpose); only the RHYTHM forks: larger fluid type, rounder radius, softer layered elevation. Demonstrates the [data-theme] axis extended to NON-COLOR semantic tokens. web-hf's pricing-tier sub-brand (gold/navy) stays LOCAL to web-hf per decision D6 (approval-dbf6f1f2) — deliberately NOT here.",
|
|
255
|
+
"--font-size-hf-base": "16px",
|
|
256
|
+
"--font-size-hf-md": "16px",
|
|
257
|
+
"--font-size-hf-lg": "18px",
|
|
258
|
+
"--font-size-hf-xl": "20px",
|
|
259
|
+
"--font-size-hf-2xl": "24px",
|
|
260
|
+
"--font-size-hf-h2": "28px",
|
|
261
|
+
"--font-size-hf-page": "clamp(2.125rem, 1.55rem + 2.4vw, 3rem)",
|
|
262
|
+
"--font-size-hf-hero": "clamp(2.5rem, 1.9rem + 2.6vw, 3.5rem)",
|
|
263
|
+
"--radius-hf-sm": "8px",
|
|
264
|
+
"--radius-hf-md": "12px",
|
|
265
|
+
"--radius-hf-lg": "16px",
|
|
266
|
+
"--radius-hf-xl": "24px",
|
|
267
|
+
"--shadow-hf-card": "0 20px 40px -16px rgba(30,58,95,0.18)",
|
|
268
|
+
"--shadow-hf-hover": "0 12px 28px -10px rgba(30,58,95,0.22)"
|
|
252
269
|
}
|
|
253
270
|
}
|
|
254
271
|
}
|