@cparkerwebm/webmonterey 1.0.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +104 -0
  4. package/agents/.gitkeep +0 -0
  5. package/dist/webm.mjs +2381 -0
  6. package/hooks/.gitkeep +0 -0
  7. package/package.json +101 -0
  8. package/schema/design.json +118 -0
  9. package/skills/launch/SKILL.md +183 -0
  10. package/skills/new-component/SKILL.md +85 -0
  11. package/skills/start/SKILL.md +117 -0
  12. package/skills/traps/SKILL.md +333 -0
  13. package/skills/upgrade/SKILL.md +42 -0
  14. package/src/actions/index.ts +348 -0
  15. package/src/cli/checks.test.ts +711 -0
  16. package/src/cli/checks.ts +822 -0
  17. package/src/cli/codemods.test.ts +42 -0
  18. package/src/cli/codemods.ts +51 -0
  19. package/src/cli/compare.test.ts +144 -0
  20. package/src/cli/compare.ts +222 -0
  21. package/src/cli/design-extract.test.ts +96 -0
  22. package/src/cli/design-extract.ts +229 -0
  23. package/src/cli/doctor.ts +187 -0
  24. package/src/cli/mcp.test.ts +57 -0
  25. package/src/cli/mcp.ts +110 -0
  26. package/src/cli/new.ts +148 -0
  27. package/src/cli/package-root.ts +58 -0
  28. package/src/cli/scaffold.test.ts +230 -0
  29. package/src/cli/scaffold.ts +424 -0
  30. package/src/cli/seed.ts +133 -0
  31. package/src/cli/slug.test.ts +70 -0
  32. package/src/cli/slug.ts +109 -0
  33. package/src/cli/sync.test.ts +137 -0
  34. package/src/cli/sync.ts +266 -0
  35. package/src/cli/upgrade.ts +93 -0
  36. package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
  37. package/src/design/brand.ts +49 -0
  38. package/src/design/compile.test.ts +98 -0
  39. package/src/design/compile.ts +155 -0
  40. package/src/design/defaults.ts +315 -0
  41. package/src/design/index.ts +18 -0
  42. package/src/design/resolve.test.ts +80 -0
  43. package/src/design/resolve.ts +108 -0
  44. package/src/design/types.ts +96 -0
  45. package/src/emails/autoresponse.test.ts +82 -0
  46. package/src/emails/autoresponse.ts +83 -0
  47. package/src/emails/footer.test.ts +102 -0
  48. package/src/emails/footer.ts +91 -0
  49. package/src/emails/index.ts +24 -0
  50. package/src/emails/subject.test.ts +66 -0
  51. package/src/emails/subject.ts +80 -0
  52. package/src/emails/submission-notification.ts +76 -0
  53. package/src/env.d.ts +12 -0
  54. package/src/includes/cloudflare/d1/client.ts +86 -0
  55. package/src/includes/cloudflare/r2/README.md +68 -0
  56. package/src/includes/cloudflare/r2/media.ts +41 -0
  57. package/src/includes/cloudflare/r2/url.test.ts +44 -0
  58. package/src/includes/cloudflare/r2/url.ts +34 -0
  59. package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
  60. package/src/includes/cloudflare/turnstile/verify.ts +157 -0
  61. package/src/includes/cloudflare/workers/env.ts +56 -0
  62. package/src/includes/google/tag-manager/TagManager.astro +73 -0
  63. package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
  64. package/src/includes/sinch/mailgun/redirect.ts +96 -0
  65. package/src/includes/sinch/mailgun/send.ts +186 -0
  66. package/src/includes/webmonterey/animations/animations.css +347 -0
  67. package/src/includes/webmonterey/animations/observe.test.ts +88 -0
  68. package/src/includes/webmonterey/animations/observe.ts +209 -0
  69. package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
  70. package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
  71. package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
  72. package/src/includes/webmonterey/compliance/consent.ts +142 -0
  73. package/src/includes/webmonterey/config.test.ts +94 -0
  74. package/src/includes/webmonterey/config.ts +346 -0
  75. package/src/includes/webmonterey/copy-defaults.ts +148 -0
  76. package/src/includes/webmonterey/copy.ts +13 -0
  77. package/src/includes/webmonterey/credits/Credit.astro +80 -0
  78. package/src/includes/webmonterey/credits/credit.test.ts +111 -0
  79. package/src/includes/webmonterey/credits/credit.ts +59 -0
  80. package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
  81. package/src/includes/webmonterey/forms/honeypot.ts +66 -0
  82. package/src/includes/webmonterey/prose/inline.test.ts +94 -0
  83. package/src/includes/webmonterey/prose/inline.ts +71 -0
  84. package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
  85. package/src/includes/webmonterey/site.ts +136 -0
  86. package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
  87. package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
  88. package/src/integration/adapter.ts +53 -0
  89. package/src/integration/app-middleware.ts +43 -0
  90. package/src/integration/config.ts +96 -0
  91. package/src/integration/content.ts +66 -0
  92. package/src/integration/image-size.test.ts +83 -0
  93. package/src/integration/image-size.ts +100 -0
  94. package/src/integration/index.ts +386 -0
  95. package/src/integration/virtual.d.ts +101 -0
  96. package/src/layouts/base.astro +402 -0
  97. package/src/package.test.ts +121 -0
  98. package/src/pages/404.astro +33 -0
  99. package/src/pages/[...slug].astro +126 -0
  100. package/src/pages/robots.txt.ts +41 -0
  101. package/src/pages/webm.astro +23 -0
  102. package/src/scripts/.gitkeep +0 -0
  103. package/src/styles/base.css +185 -0
  104. package/src/styles/global.css +47 -0
  105. package/src/styles/layers.test.ts +32 -0
  106. package/src/styles/layers.ts +21 -0
  107. package/src/styles/layout.css +235 -0
  108. package/src/styles/reset.css +135 -0
  109. package/src/styles/utilities.css +90 -0
  110. package/src/worker.ts +76 -0
  111. package/template/assets/logo.svg +14 -0
  112. package/template/assets/open-graph.png +0 -0
  113. package/template/migrations/0001_create_submissions.sql +40 -0
  114. package/template/migrations/README.md +41 -0
  115. package/template/public/android-chrome-192x192.png +0 -0
  116. package/template/public/android-chrome-512x512.png +0 -0
  117. package/template/public/apple-touch-icon.png +0 -0
  118. package/template/public/favicon-16x16.png +0 -0
  119. package/template/public/favicon-32x32.png +0 -0
  120. package/template/public/favicon.ico +0 -0
  121. package/template/public/favicon.svg +4 -0
  122. package/template/public/open-graph.png +0 -0
  123. package/template/scripts/check-node.mjs +72 -0
  124. package/template/scripts/test-hooks.mjs +96 -0
  125. package/template/site/.editorconfig +22 -0
  126. package/template/site/.prettierignore +15 -0
  127. package/template/site/.prettierrc.json +17 -0
  128. package/template/site/CLAUDE.md +245 -0
  129. package/template/site/CONTENT.md +135 -0
  130. package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
  131. package/template/site/public/_headers +70 -0
  132. package/template/site/src/forms/contact.json +45 -0
  133. package/template/workflows/ci.yml +52 -0
@@ -0,0 +1,227 @@
1
+ /*
2
+ * webm.tokens — the design system. THIS FILE IS THE SOURCE OF TRUTH.
3
+ *
4
+ * Edit values here. Every colour, size, space, radius, shadow, z-index, duration and easing
5
+ * used anywhere in the site must come from one of these custom properties — component CSS
6
+ * contains no literal values.
7
+ *
8
+ * To retheme a client, the usual move is to override the token in src/styles/custom/ under
9
+ * @layer webm.overrides — one declaration cascades everywhere at once, and it keeps the
10
+ * client's palette in one place someone can find later.
11
+ *
12
+ * Editing this file directly is allowed too. A client repo is a copy of the starter and never
13
+ * pulls a later version forward, so these tokens are the client's to set outright; where the
14
+ * whole palette differs, changing the values here beats shadowing every one of them from
15
+ * custom/. Neither choice costs anything at build time — pick the one that will read more
16
+ * clearly to whoever opens the repo next.
17
+ */
18
+
19
+ @layer webm.tokens {
20
+ :root {
21
+ /* --- base palette ------------------------------------------------------ */
22
+ /* Raw neutrals. Everything semantic below points at these, not the other way round. */
23
+ --webm-base-100: #ffffff;
24
+ --webm-base-300: #f1eae8;
25
+ --webm-base-500: #3f3f3f;
26
+ --webm-base-700: #222222;
27
+ --webm-base-900: #000000;
28
+
29
+ /* --- action ------------------------------------------------------------ */
30
+ /* The brand colour. Retheming a client usually starts and ends here. */
31
+ --webm-action: #006abe;
32
+ --webm-action-dark: #003e80;
33
+ --webm-action-light: #6bc7ff;
34
+
35
+ /* --- surface ----------------------------------------------------------- */
36
+ --webm-surface: var(--webm-base-100);
37
+ --webm-surface-alt: var(--webm-base-300);
38
+ --webm-surface-accent: var(--webm-action-light);
39
+ --webm-surface-inverse: var(--webm-base-900);
40
+ --webm-surface-raised: var(--webm-base-100);
41
+
42
+ /* --- text -------------------------------------------------------------- */
43
+ --webm-text: var(--webm-base-700);
44
+ --webm-text-muted: var(--webm-base-500);
45
+ --webm-text-strong: var(--webm-base-900);
46
+ --webm-text-inverse: var(--webm-base-100);
47
+ --webm-text-on-action: var(--webm-base-100);
48
+
49
+ /* --- link -------------------------------------------------------------- */
50
+ --webm-link: var(--webm-action);
51
+ --webm-link-hover: var(--webm-action-dark);
52
+ --webm-link-visited: var(--webm-action-dark);
53
+
54
+ /* --- border ------------------------------------------------------------ */
55
+ --webm-border-subtle: #dcd3d0;
56
+ --webm-border-interactive: var(--webm-base-500);
57
+ --webm-border-strong: var(--webm-base-700);
58
+
59
+ /* --- state ------------------------------------------------------------- */
60
+ --webm-state-success: #0f7b3f;
61
+ --webm-state-warning: #9a6700;
62
+ --webm-state-danger: #b3261e;
63
+ --webm-state-info: var(--webm-action);
64
+
65
+ /* --- font -------------------------------------------------------------- */
66
+ /*
67
+ * System stack by default — zero network cost, no layout shift, no dependency.
68
+ * To use a real typeface, put the woff2 in public/fonts/, declare @font-face in
69
+ * src/styles/custom/, and prepend the family here.
70
+ */
71
+ --webm-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
72
+ --webm-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
73
+
74
+ /* --- font-size --------------------------------------------------------- */
75
+ /* Fluid scale. Each step interpolates between a mobile and a desktop size. */
76
+ --webm-font-size-2xs: clamp(0.6944rem, 0.6859rem + 0.0426vw, 0.72rem);
77
+ --webm-font-size-xs: clamp(0.8333rem, 0.8111rem + 0.1111vw, 0.9rem);
78
+ --webm-font-size-sm: clamp(1rem, 0.9583rem + 0.2083vw, 1.125rem);
79
+ --webm-font-size-md: clamp(1.2rem, 1.1312rem + 0.3438vw, 1.406rem);
80
+ --webm-font-size-lg: clamp(1.44rem, 1.3341rem + 0.5297vw, 1.758rem);
81
+ --webm-font-size-xl: clamp(1.728rem, 1.5716rem + 0.7821vw, 2.197rem);
82
+ --webm-font-size-2xl: clamp(2.074rem, 1.8493rem + 1.1216vw, 2.747rem);
83
+ --webm-font-size-3xl: clamp(2.488rem, 2.1734rem + 1.5748vw, 3.433rem);
84
+ --webm-font-size-4xl: clamp(2.986rem, 2.5508rem + 2.1759vw, 4.292rem);
85
+
86
+ /* --- leading ----------------------------------------------------------- */
87
+ --webm-leading-tight: 1.1;
88
+ --webm-leading-snug: 1.25;
89
+ --webm-leading-normal: 1.5;
90
+ --webm-leading-loose: 1.7;
91
+
92
+ /* --- tracking ---------------------------------------------------------- */
93
+ --webm-tracking-tight: -0.02em;
94
+ --webm-tracking-normal: 0em;
95
+ --webm-tracking-wide: 0.06em;
96
+
97
+ /* --- weight ------------------------------------------------------------ */
98
+ --webm-weight-regular: 400;
99
+ --webm-weight-medium: 500;
100
+ --webm-weight-semibold: 600;
101
+ --webm-weight-bold: 700;
102
+
103
+ /* --- space ------------------------------------------------------------- */
104
+ --webm-space-3xs: 0.25rem;
105
+ --webm-space-2xs: 0.5rem;
106
+ --webm-space-xs: 0.75rem;
107
+ --webm-space-sm: 1rem;
108
+ --webm-space-md: 1.5rem;
109
+ --webm-space-lg: 2rem;
110
+ --webm-space-xl: 3rem;
111
+ --webm-space-2xl: 4rem;
112
+ --webm-space-3xl: 6rem;
113
+ --webm-space-4xl: 8rem;
114
+
115
+ /* --- space, fluid ------------------------------------------------------ */
116
+ --webm-space-fluid-sm: clamp(1rem, 0.8333rem + 0.8333vw, 1.5rem);
117
+ --webm-space-fluid-md: clamp(1.5rem, 1.25rem + 1.25vw, 2.25rem);
118
+ --webm-space-fluid-lg: clamp(2rem, 1.6667rem + 1.6667vw, 3rem);
119
+ --webm-space-fluid-xl: clamp(3rem, 2.5rem + 2.5vw, 4.5rem);
120
+ --webm-space-fluid-2xl: clamp(4rem, 3.3333rem + 3.3333vw, 6rem);
121
+ --webm-space-fluid-3xl: clamp(6rem, 5rem + 5vw, 9rem);
122
+
123
+ /* --- section rhythm ---------------------------------------------------- */
124
+ --webm-section-sm: clamp(2rem, 1.6667rem + 1.6667vw, 3rem);
125
+ --webm-section-md: clamp(3rem, 2rem + 5vw, 6rem);
126
+ --webm-section-lg: clamp(4rem, 2.6667rem + 6.6667vw, 8rem);
127
+ --webm-section-xl: clamp(6rem, 4rem + 10vw, 12rem);
128
+
129
+ /* --- gutter ------------------------------------------------------------ */
130
+ --webm-gutter: clamp(1rem, 0.3333rem + 3.3333vw, 3rem);
131
+
132
+ /* --- width ------------------------------------------------------------- */
133
+ --webm-width-max: 1920px;
134
+ --webm-width-wide: 1600px;
135
+ --webm-width-content: 1280px;
136
+ --webm-width-narrow: 960px;
137
+ --webm-width-text: 68ch;
138
+
139
+ /* --- radius ------------------------------------------------------------ */
140
+ --webm-radius-none: 0;
141
+ --webm-radius-xs: 0.125rem;
142
+ --webm-radius-sm: 0.25rem;
143
+ --webm-radius-md: 0.5rem;
144
+ --webm-radius-lg: 1rem;
145
+ --webm-radius-xl: 1.5rem;
146
+ --webm-radius-pill: 62.4375rem;
147
+ --webm-radius-circle: 50%;
148
+
149
+ /* --- border-width ------------------------------------------------------ */
150
+ --webm-border-width-0: 0;
151
+ --webm-border-width-1: 1px;
152
+ --webm-border-width-2: 2px;
153
+ --webm-border-width-4: 4px;
154
+
155
+ /* --- shadow ------------------------------------------------------------ */
156
+ --webm-shadow-xs: 0 1px 2px 0 rgb(34 34 34 / 0.06);
157
+ --webm-shadow-sm: 0 1px 3px 0 rgb(34 34 34 / 0.1), 0 1px 2px -1px rgb(34 34 34 / 0.1);
158
+ --webm-shadow-md: 0 4px 6px -1px rgb(34 34 34 / 0.1), 0 2px 4px -2px rgb(34 34 34 / 0.1);
159
+ --webm-shadow-lg: 0 10px 15px -3px rgb(34 34 34 / 0.1), 0 4px 6px -4px rgb(34 34 34 / 0.1);
160
+ --webm-shadow-xl: 0 20px 25px -5px rgb(34 34 34 / 0.12), 0 8px 10px -6px rgb(34 34 34 / 0.1);
161
+
162
+ /* --- z-index ----------------------------------------------------------- */
163
+ /* Named layers only. Never write a raw z-index in component CSS. */
164
+ --webm-z-below: -1;
165
+ --webm-z-base: 0;
166
+ --webm-z-raised: 10;
167
+ --webm-z-sticky: 100;
168
+ --webm-z-header: 200;
169
+ --webm-z-drawer: 300;
170
+ --webm-z-overlay: 400;
171
+ --webm-z-modal: 500;
172
+ --webm-z-toast: 600;
173
+ --webm-z-tooltip: 700;
174
+ --webm-z-skiplink: 900;
175
+
176
+ /* --- duration ---------------------------------------------------------- */
177
+ --webm-duration-instant: 75ms;
178
+ --webm-duration-fast: 150ms;
179
+ --webm-duration-normal: 250ms;
180
+ --webm-duration-slow: 400ms;
181
+ --webm-duration-slower: 600ms;
182
+
183
+ /* --- easing ------------------------------------------------------------ */
184
+ --webm-ease-linear: linear;
185
+ --webm-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
186
+ --webm-ease-in: cubic-bezier(0.7, 0, 0.84, 0);
187
+ --webm-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
188
+ --webm-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
189
+
190
+ /* --- scroll reveal ------------------------------------------------------
191
+ * Defaults for .webm-reveal. Overridable per element with data-reveal-delay
192
+ * and data-reveal-duration, which observe.ts writes as inline properties. */
193
+ /*
194
+ * NOT var(--webm-duration-slow), and not a card-sized distance.
195
+ *
196
+ * The motion scale tops out at 400ms, which is tuned for a button, a panel, a dropdown —
197
+ * something small moving a short way. A reveal is a whole content group arriving, and at
198
+ * 400ms over 1.5rem it reads as a twitch rather than a movement.
199
+ *
200
+ * 7.5rem over 1500ms was arrived at on a real client homepage, not picked. Raising the
201
+ * duration alone did nothing: --webm-ease-out front-loads the curve, covering most of the
202
+ * distance in the first third, so extra time only stretches the settle and the element
203
+ * still appears to arrive at the same moment. Raising distance alone lurched. Both had to
204
+ * grow together. 5rem at this duration shipped briefly and was pulled — a shorter travel
205
+ * spends proportionally longer on the settle and reads as drifting into place.
206
+ *
207
+ * Reveals therefore get their own duration rather than borrowing from the scale. Reduced
208
+ * motion is still covered — animations.css forces `transition: none !important` under
209
+ * `reduce`, so decoupling from the scale's own reduced-motion override costs nothing.
210
+ */
211
+ --webm-reveal-duration: 1500ms;
212
+ --webm-reveal-delay: 0ms;
213
+ --webm-reveal-distance: 7.5rem;
214
+ --webm-reveal-ease: var(--webm-ease-out);
215
+ --webm-reveal-blur: 8px;
216
+ /* Gap between staggered siblings. The data-delay ladder is multiples of this. */
217
+ --webm-reveal-stagger-step: 80ms;
218
+
219
+ /* --- focus ------------------------------------------------------------- */
220
+ --webm-focus-width: 3px;
221
+ --webm-focus-offset: 2px;
222
+ --webm-focus-color: var(--webm-action);
223
+
224
+ /* --- composites -------------------------------------------------------- */
225
+ --webm-focus-ring: var(--webm-focus-width) solid var(--webm-focus-color);
226
+ }
227
+ }
@@ -0,0 +1,49 @@
1
+ /*
2
+ * The brand-context output: what Claude reads when writing for a client.
3
+ *
4
+ * This is the thing that makes generated copy look like the client instead of like a chatbot,
5
+ * and it is why `brand.voice` lives in design.json rather than in a prompt somewhere. A social
6
+ * post, a report and the site itself all draw from one file.
7
+ *
8
+ * Consumed by the platform MCP server, so it must stay serialisable and free of anything that
9
+ * only makes sense inside a build.
10
+ */
11
+ import { resolve } from './resolve.ts';
12
+ import { compile } from './compile.ts';
13
+ import type { DesignSystem } from './types.ts';
14
+
15
+ export interface BrandContext {
16
+ name?: string;
17
+ voice?: string;
18
+ rules: string[];
19
+ logo?: { primary?: string; mark?: string };
20
+ /** Resolved literals, keyed without the --webm- prefix. */
21
+ palette: Record<string, string>;
22
+ fonts: { sans: string; mono: string };
23
+ }
24
+
25
+ export function brandContext(design: DesignSystem = {}): BrandContext {
26
+ const tokens = resolve(compile(design));
27
+ const get = (n: string) => tokens.get(n) ?? '';
28
+
29
+ return {
30
+ name: design.brand?.name,
31
+ voice: design.brand?.voice,
32
+ rules: design.brand?.rules ?? [],
33
+ logo: design.brand?.logo,
34
+ palette: {
35
+ action: get('--webm-action'),
36
+ actionDark: get('--webm-action-dark'),
37
+ actionLight: get('--webm-action-light'),
38
+ surface: get('--webm-surface'),
39
+ surfaceAlt: get('--webm-surface-alt'),
40
+ surfaceInverse: get('--webm-surface-inverse'),
41
+ text: get('--webm-text'),
42
+ textMuted: get('--webm-text-muted'),
43
+ textStrong: get('--webm-text-strong'),
44
+ textInverse: get('--webm-text-inverse'),
45
+ border: get('--webm-border-subtle'),
46
+ },
47
+ fonts: { sans: get('--webm-font-sans'), mono: get('--webm-font-mono') },
48
+ };
49
+ }
@@ -0,0 +1,98 @@
1
+ /*
2
+ * The load-bearing test is the first one: an empty design.json must reproduce webm-astro
3
+ * v1.4.1's tokens.css exactly, value for value.
4
+ *
5
+ * It reads the real file, checked in as a fixture, rather than a copy of defaults.ts - which
6
+ * would make it tautological. Edit defaults.ts and this fails until the fixture is updated
7
+ * deliberately, which is the point. The fixture is frozen history, not a live file.
8
+ */
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { readFileSync } from 'node:fs';
12
+ import { compile, toCss, compileToCss, DesignError } from './compile.ts';
13
+
14
+ const FIXTURE = new URL('./__fixtures__/tokens-v1.4.1.css', import.meta.url);
15
+
16
+ /** Pull `--webm-x: value;` pairs out of a stylesheet, ignoring comments. */
17
+ function parseTokens(css: string): Map<string, string> {
18
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
19
+ const out = new Map<string, string>();
20
+ for (const m of withoutComments.matchAll(/(--webm-[\w-]+)\s*:\s*([^;]+);/g)) {
21
+ out.set(m[1]!, m[2]!.trim());
22
+ }
23
+ return out;
24
+ }
25
+
26
+ const flat = (design = {}) => {
27
+ const out = new Map<string, string>();
28
+ for (const g of compile(design)) for (const t of g.tokens) out.set(t.name, t.value);
29
+ return out;
30
+ };
31
+
32
+ test('an empty design.json reproduces tokens.css v1.4.1 exactly', () => {
33
+ const expected = parseTokens(readFileSync(FIXTURE, 'utf8'));
34
+ const actual = flat();
35
+
36
+ assert.equal(actual.size, expected.size, 'token count differs from v1.4.1');
37
+ for (const [name, value] of expected) {
38
+ assert.equal(actual.get(name), value, `${name} differs from v1.4.1`);
39
+ }
40
+ });
41
+
42
+ test('the compiled stylesheet round-trips through the same parser', () => {
43
+ const expected = parseTokens(readFileSync(FIXTURE, 'utf8'));
44
+ const actual = parseTokens(compileToCss());
45
+ assert.deepEqual([...actual.entries()].sort(), [...expected.entries()].sort());
46
+ });
47
+
48
+ test('a structured field replaces its token in place', () => {
49
+ const tokens = flat({ color: { action: { base: '#bfb23b' } } });
50
+ assert.equal(tokens.get('--webm-action'), '#bfb23b');
51
+ // Siblings are untouched.
52
+ assert.equal(tokens.get('--webm-action-dark'), '#003e80');
53
+ });
54
+
55
+ test('overrides win over a structured field naming the same token', () => {
56
+ const tokens = flat({
57
+ color: { action: { base: '#111111' } },
58
+ overrides: { '--webm-action': '#222222' },
59
+ });
60
+ assert.equal(tokens.get('--webm-action'), '#222222');
61
+ });
62
+
63
+ test('an override outside the --webm- prefix is a build error', () => {
64
+ assert.throws(
65
+ () => compile({ overrides: { '--brand-color': 'red' } }),
66
+ (e: unknown) =>
67
+ e instanceof DesignError && /must start with --webm-/.test((e as Error).message),
68
+ );
69
+ });
70
+
71
+ test('an override naming an unknown token is kept, in its own group', () => {
72
+ const groups = compile({ overrides: { '--webm-hero-height': '80vh' } });
73
+ const extra = groups.at(-1)!;
74
+ assert.equal(extra.title, 'site tokens');
75
+ assert.deepEqual(extra.tokens, [{ name: '--webm-hero-height', value: '80vh' }]);
76
+ });
77
+
78
+ test('a non-string value is a build error rather than a silent cast', () => {
79
+ assert.throws(
80
+ () => compile({ color: { action: { base: 0x006abe as unknown as string } } }),
81
+ DesignError,
82
+ );
83
+ });
84
+
85
+ test('the layer wrapper is emitted, because membership is what keeps tokens below components', () => {
86
+ const css = compileToCss();
87
+ assert.match(css, /^@layer webm\.tokens \{/);
88
+ assert.match(css, /\n {2}:root \{/);
89
+ assert.ok(css.trimEnd().endsWith('}'));
90
+ });
91
+
92
+ test('group order is preserved on emit', () => {
93
+ const css = toCss(compile());
94
+ const order = [...css.matchAll(/--- ([a-z0-9, -]+?) -+ \*\//g)].map((m) => m[1]);
95
+ assert.equal(order[0], 'base palette');
96
+ assert.equal(order[1], 'action');
97
+ assert.equal(order.at(-1), 'composites');
98
+ });
@@ -0,0 +1,155 @@
1
+ /*
2
+ * design.json -> CSS custom properties.
3
+ *
4
+ * Pure. No I/O, no Astro, no dependencies - which is why it lives behind the `./design` subpath
5
+ * and can be tested with `node --test` alone. Rule 1 applies to the framework as much as to a
6
+ * client site: there is no test framework here either.
7
+ *
8
+ * The contract that matters: compiling an EMPTY design.json must reproduce webm-astro v1.4.1's
9
+ * tokens.css exactly. That equivalence is what makes this a refactor of where values live rather
10
+ * than a redesign of the token system, and it is asserted in compile.test.ts.
11
+ */
12
+ import { DEFAULTS, TOKEN_NAMES } from './defaults.ts';
13
+ import type { DesignSystem, TokenGroup } from './types.ts';
14
+
15
+ /**
16
+ * Where each design.json field lands, as [path, token name] pairs.
17
+ *
18
+ * Declarative on purpose. Adding a field to design.json should be one line here plus one line
19
+ * in types.ts, not a new branch in the merge.
20
+ */
21
+ export const MAPPING: ReadonlyArray<readonly [path: readonly string[], token: string]> = [
22
+ [['color', 'base', '100'], '--webm-base-100'],
23
+ [['color', 'base', '300'], '--webm-base-300'],
24
+ [['color', 'base', '500'], '--webm-base-500'],
25
+ [['color', 'base', '700'], '--webm-base-700'],
26
+ [['color', 'base', '900'], '--webm-base-900'],
27
+ [['color', 'action', 'base'], '--webm-action'],
28
+ [['color', 'action', 'dark'], '--webm-action-dark'],
29
+ [['color', 'action', 'light'], '--webm-action-light'],
30
+ [['color', 'border', 'subtle'], '--webm-border-subtle'],
31
+ [['color', 'state', 'success'], '--webm-state-success'],
32
+ [['color', 'state', 'warning'], '--webm-state-warning'],
33
+ [['color', 'state', 'danger'], '--webm-state-danger'],
34
+ [['color', 'state', 'info'], '--webm-state-info'],
35
+ [['font', 'sans'], '--webm-font-sans'],
36
+ [['font', 'mono'], '--webm-font-mono'],
37
+ [['radius', 'none'], '--webm-radius-none'],
38
+ [['radius', 'xs'], '--webm-radius-xs'],
39
+ [['radius', 'sm'], '--webm-radius-sm'],
40
+ [['radius', 'md'], '--webm-radius-md'],
41
+ [['radius', 'lg'], '--webm-radius-lg'],
42
+ [['radius', 'xl'], '--webm-radius-xl'],
43
+ [['radius', 'pill'], '--webm-radius-pill'],
44
+ [['radius', 'circle'], '--webm-radius-circle'],
45
+ ];
46
+
47
+ function at(source: unknown, path: readonly string[]): unknown {
48
+ let node: unknown = source;
49
+ for (const key of path) {
50
+ if (node === null || typeof node !== 'object') return undefined;
51
+ node = (node as Record<string, unknown>)[key];
52
+ }
53
+ return node;
54
+ }
55
+
56
+ export class DesignError extends Error {
57
+ constructor(message: string) {
58
+ super(message);
59
+ this.name = 'DesignError';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Merge a design.json over the defaults, returning the full grouped token set.
65
+ *
66
+ * Overrides that name a token already in a group REPLACE it in place, so the compiled file keeps
67
+ * its grouping rather than growing an "overrides" tail that separates a value from its siblings.
68
+ * An override naming a token that does not exist is appended to its own group - legitimate, since
69
+ * a client may add a token their own components read.
70
+ */
71
+ export function compile(design: DesignSystem = {}): TokenGroup[] {
72
+ const patch = new Map<string, string>();
73
+
74
+ for (const [path, token] of MAPPING) {
75
+ const value = at(design, path);
76
+ if (value === undefined || value === null) continue;
77
+ if (typeof value !== 'string') {
78
+ throw new DesignError(`design.json: ${path.join('.')} must be a string, got ${typeof value}`);
79
+ }
80
+ patch.set(token, value);
81
+ }
82
+
83
+ /*
84
+ * Overrides are applied after the mapping, so `overrides` wins over a structured field naming
85
+ * the same token. That ordering is deliberate: the escape hatch is the more specific statement.
86
+ */
87
+ const extra: string[] = [];
88
+ for (const [name, value] of Object.entries(design.overrides ?? {})) {
89
+ if (!name.startsWith('--webm-')) {
90
+ throw new DesignError(
91
+ `design.json: override "${name}" must start with --webm-. ` +
92
+ `A property outside the prefix is set but never read, and the page looks untouched.`,
93
+ );
94
+ }
95
+ if (typeof value !== 'string') {
96
+ throw new DesignError(`design.json: override "${name}" must be a string`);
97
+ }
98
+ patch.set(name, value);
99
+ if (!TOKEN_NAMES.has(name)) extra.push(name);
100
+ }
101
+
102
+ const groups: TokenGroup[] = DEFAULTS.map((group) => ({
103
+ ...group,
104
+ tokens: group.tokens.map((t) => (patch.has(t.name) ? { ...t, value: patch.get(t.name)! } : t)),
105
+ }));
106
+
107
+ if (extra.length) {
108
+ groups.push({
109
+ title: 'site tokens',
110
+ note: 'Declared in design.json overrides and not part of the default set.',
111
+ tokens: extra.map((name) => ({ name, value: patch.get(name)! })),
112
+ });
113
+ }
114
+
115
+ return groups;
116
+ }
117
+
118
+ /** Wrap a note as a CSS comment, indented to sit inside :root. */
119
+ function comment(text: string, indent: string): string {
120
+ const lines = text.split('\n');
121
+ if (lines.length === 1) return `${indent}/* ${text} */`;
122
+ return [
123
+ `${indent}/*`,
124
+ ...lines.map((l) => (l ? `${indent} * ${l}` : `${indent} *`)),
125
+ `${indent} */`,
126
+ ].join('\n');
127
+ }
128
+
129
+ /**
130
+ * Emit the compiled token set as a stylesheet.
131
+ *
132
+ * The `@layer webm.tokens` wrapper is REQUIRED, not cosmetic. Layer membership is what keeps
133
+ * these declarations below components in the cascade; a bare :root block would out-rank a
134
+ * component rule at equal specificity. Layer ORDER is a separate problem and is held by the
135
+ * inline statement in base.astro - see the layer-order trap.
136
+ */
137
+ export function toCss(groups: TokenGroup[]): string {
138
+ const out: string[] = ['@layer webm.tokens {', ' :root {'];
139
+
140
+ groups.forEach((group, i) => {
141
+ if (i > 0) out.push('');
142
+ const rule = `/* --- ${group.title} ${'-'.repeat(Math.max(1, 68 - group.title.length))} */`;
143
+ out.push(` ${rule}`);
144
+ if (group.note) out.push(comment(group.note, ' '));
145
+ for (const t of group.tokens) out.push(` ${t.name}: ${t.value};`);
146
+ });
147
+
148
+ out.push(' }', '}', '');
149
+ return out.join('\n');
150
+ }
151
+
152
+ /** design.json in, stylesheet out. The whole compiler, for callers that want one call. */
153
+ export function compileToCss(design: DesignSystem = {}): string {
154
+ return toCss(compile(design));
155
+ }