@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 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
  &lt;html data-theme="dark"&gt;…&lt;/html&gt;</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">&lt;html data-theme&gt;</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
+ &lt;!-- import once, after tokens.css so the override wins --&gt;
1170
+ @import "@hotelfriendag/design-tokens/web.css";
1171
+ &lt;!-- then flip the marketing surface (whole app or any region) --&gt;
1172
+ &lt;html data-theme="web"&gt;…&lt;/html&gt;</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 -->
@@ -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="dark"] { ... }` from the `dark` theme in $theme-overrides.
714
- // Semantic vars are re-pointed to primitives via var() chains (theming stays
715
- // alias-based no inlined hex), exactly mirroring the ADOPTION.md template.
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.6.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",
@@ -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 seven checks against pre-built/*.css and related files:
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
  }