@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,315 @@
1
+ /*
2
+ * THE DESIGN SYSTEM'S DEFAULT TOKEN SET.
3
+ *
4
+ * Ported verbatim from webm-astro v1.4.1 src/styles/tokens.css, which is the reference the
5
+ * compiler is checked against: compiling an empty design.json must produce the same values that
6
+ * file declared. That equivalence is what makes the move from a hand-edited stylesheet to
7
+ * generated CSS a refactor rather than a redesign.
8
+ *
9
+ * Groups exist so compiled output stays readable. Order is preserved on emit.
10
+ *
11
+ * WHAT BELONGS HERE vs in design.json: anything a client would sensibly change is exposed in
12
+ * design.json and merged over these. Everything else - spacing, widths, z-index, durations,
13
+ * easings, shadows - is the system. A client wanting a different spacing scale is nearly always
14
+ * a client who wants a different value in one component.
15
+ */
16
+ import type { TokenGroup } from './types.ts';
17
+
18
+ export const DEFAULTS: TokenGroup[] = [
19
+ {
20
+ title: 'base palette',
21
+ note: 'Raw neutrals. Everything semantic below points at these, not the other way round.',
22
+ tokens: [
23
+ { name: '--webm-base-100', value: '#ffffff' },
24
+ { name: '--webm-base-300', value: '#f1eae8' },
25
+ { name: '--webm-base-500', value: '#3f3f3f' },
26
+ { name: '--webm-base-700', value: '#222222' },
27
+ { name: '--webm-base-900', value: '#000000' },
28
+ ],
29
+ },
30
+ {
31
+ title: 'action',
32
+ note: 'The brand color. Retheming a client usually starts and ends here.',
33
+ tokens: [
34
+ { name: '--webm-action', value: '#006abe' },
35
+ { name: '--webm-action-dark', value: '#003e80' },
36
+ { name: '--webm-action-light', value: '#6bc7ff' },
37
+ ],
38
+ },
39
+ {
40
+ title: 'surface',
41
+ tokens: [
42
+ { name: '--webm-surface', value: 'var(--webm-base-100)' },
43
+ { name: '--webm-surface-alt', value: 'var(--webm-base-300)' },
44
+ { name: '--webm-surface-accent', value: 'var(--webm-action-light)' },
45
+ { name: '--webm-surface-inverse', value: 'var(--webm-base-900)' },
46
+ { name: '--webm-surface-raised', value: 'var(--webm-base-100)' },
47
+ ],
48
+ },
49
+ {
50
+ title: 'text',
51
+ tokens: [
52
+ { name: '--webm-text', value: 'var(--webm-base-700)' },
53
+ { name: '--webm-text-muted', value: 'var(--webm-base-500)' },
54
+ { name: '--webm-text-strong', value: 'var(--webm-base-900)' },
55
+ { name: '--webm-text-inverse', value: 'var(--webm-base-100)' },
56
+ { name: '--webm-text-on-action', value: 'var(--webm-base-100)' },
57
+ ],
58
+ },
59
+ {
60
+ title: 'link',
61
+ tokens: [
62
+ { name: '--webm-link', value: 'var(--webm-action)' },
63
+ { name: '--webm-link-hover', value: 'var(--webm-action-dark)' },
64
+ { name: '--webm-link-visited', value: 'var(--webm-action-dark)' },
65
+ ],
66
+ },
67
+ {
68
+ title: 'border',
69
+ tokens: [
70
+ { name: '--webm-border-subtle', value: '#dcd3d0' },
71
+ { name: '--webm-border-interactive', value: 'var(--webm-base-500)' },
72
+ { name: '--webm-border-strong', value: 'var(--webm-base-700)' },
73
+ ],
74
+ },
75
+ {
76
+ title: 'state',
77
+ tokens: [
78
+ { name: '--webm-state-success', value: '#0f7b3f' },
79
+ { name: '--webm-state-warning', value: '#9a6700' },
80
+ { name: '--webm-state-danger', value: '#b3261e' },
81
+ { name: '--webm-state-info', value: 'var(--webm-action)' },
82
+ ],
83
+ },
84
+ {
85
+ title: 'font',
86
+ note: [
87
+ 'System stack by default - zero network cost, no layout shift, no dependency.',
88
+ 'To use a real typeface, put the woff2 in public/fonts/, declare @font-face in',
89
+ 'src/styles/custom/, and set font.sans in design.json.',
90
+ ].join('\n'),
91
+ tokens: [
92
+ {
93
+ name: '--webm-font-sans',
94
+ value: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
95
+ },
96
+ {
97
+ name: '--webm-font-mono',
98
+ value: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
99
+ },
100
+ ],
101
+ },
102
+ {
103
+ title: 'font-size',
104
+ note: 'Fluid scale. Each step interpolates between a mobile and a desktop size.',
105
+ tokens: [
106
+ { name: '--webm-font-size-2xs', value: 'clamp(0.6944rem, 0.6859rem + 0.0426vw, 0.72rem)' },
107
+ { name: '--webm-font-size-xs', value: 'clamp(0.8333rem, 0.8111rem + 0.1111vw, 0.9rem)' },
108
+ { name: '--webm-font-size-sm', value: 'clamp(1rem, 0.9583rem + 0.2083vw, 1.125rem)' },
109
+ { name: '--webm-font-size-md', value: 'clamp(1.2rem, 1.1312rem + 0.3438vw, 1.406rem)' },
110
+ { name: '--webm-font-size-lg', value: 'clamp(1.44rem, 1.3341rem + 0.5297vw, 1.758rem)' },
111
+ { name: '--webm-font-size-xl', value: 'clamp(1.728rem, 1.5716rem + 0.7821vw, 2.197rem)' },
112
+ { name: '--webm-font-size-2xl', value: 'clamp(2.074rem, 1.8493rem + 1.1216vw, 2.747rem)' },
113
+ { name: '--webm-font-size-3xl', value: 'clamp(2.488rem, 2.1734rem + 1.5748vw, 3.433rem)' },
114
+ { name: '--webm-font-size-4xl', value: 'clamp(2.986rem, 2.5508rem + 2.1759vw, 4.292rem)' },
115
+ ],
116
+ },
117
+ {
118
+ title: 'leading',
119
+ tokens: [
120
+ { name: '--webm-leading-tight', value: '1.1' },
121
+ { name: '--webm-leading-snug', value: '1.25' },
122
+ { name: '--webm-leading-normal', value: '1.5' },
123
+ { name: '--webm-leading-loose', value: '1.7' },
124
+ ],
125
+ },
126
+ {
127
+ title: 'tracking',
128
+ tokens: [
129
+ { name: '--webm-tracking-tight', value: '-0.02em' },
130
+ { name: '--webm-tracking-normal', value: '0em' },
131
+ { name: '--webm-tracking-wide', value: '0.06em' },
132
+ ],
133
+ },
134
+ {
135
+ title: 'weight',
136
+ tokens: [
137
+ { name: '--webm-weight-regular', value: '400' },
138
+ { name: '--webm-weight-medium', value: '500' },
139
+ { name: '--webm-weight-semibold', value: '600' },
140
+ { name: '--webm-weight-bold', value: '700' },
141
+ ],
142
+ },
143
+ {
144
+ title: 'space',
145
+ tokens: [
146
+ { name: '--webm-space-3xs', value: '0.25rem' },
147
+ { name: '--webm-space-2xs', value: '0.5rem' },
148
+ { name: '--webm-space-xs', value: '0.75rem' },
149
+ { name: '--webm-space-sm', value: '1rem' },
150
+ { name: '--webm-space-md', value: '1.5rem' },
151
+ { name: '--webm-space-lg', value: '2rem' },
152
+ { name: '--webm-space-xl', value: '3rem' },
153
+ { name: '--webm-space-2xl', value: '4rem' },
154
+ { name: '--webm-space-3xl', value: '6rem' },
155
+ { name: '--webm-space-4xl', value: '8rem' },
156
+ ],
157
+ },
158
+ {
159
+ title: 'space, fluid',
160
+ tokens: [
161
+ { name: '--webm-space-fluid-sm', value: 'clamp(1rem, 0.8333rem + 0.8333vw, 1.5rem)' },
162
+ { name: '--webm-space-fluid-md', value: 'clamp(1.5rem, 1.25rem + 1.25vw, 2.25rem)' },
163
+ { name: '--webm-space-fluid-lg', value: 'clamp(2rem, 1.6667rem + 1.6667vw, 3rem)' },
164
+ { name: '--webm-space-fluid-xl', value: 'clamp(3rem, 2.5rem + 2.5vw, 4.5rem)' },
165
+ { name: '--webm-space-fluid-2xl', value: 'clamp(4rem, 3.3333rem + 3.3333vw, 6rem)' },
166
+ { name: '--webm-space-fluid-3xl', value: 'clamp(6rem, 5rem + 5vw, 9rem)' },
167
+ ],
168
+ },
169
+ {
170
+ title: 'section rhythm',
171
+ tokens: [
172
+ { name: '--webm-section-sm', value: 'clamp(2rem, 1.6667rem + 1.6667vw, 3rem)' },
173
+ { name: '--webm-section-md', value: 'clamp(3rem, 2rem + 5vw, 6rem)' },
174
+ { name: '--webm-section-lg', value: 'clamp(4rem, 2.6667rem + 6.6667vw, 8rem)' },
175
+ { name: '--webm-section-xl', value: 'clamp(6rem, 4rem + 10vw, 12rem)' },
176
+ ],
177
+ },
178
+ {
179
+ title: 'gutter',
180
+ tokens: [{ name: '--webm-gutter', value: 'clamp(1rem, 0.3333rem + 3.3333vw, 3rem)' }],
181
+ },
182
+ {
183
+ title: 'width',
184
+ tokens: [
185
+ { name: '--webm-width-max', value: '1920px' },
186
+ { name: '--webm-width-wide', value: '1600px' },
187
+ { name: '--webm-width-content', value: '1280px' },
188
+ { name: '--webm-width-narrow', value: '960px' },
189
+ { name: '--webm-width-text', value: '68ch' },
190
+ ],
191
+ },
192
+ {
193
+ title: 'radius',
194
+ tokens: [
195
+ { name: '--webm-radius-none', value: '0' },
196
+ { name: '--webm-radius-xs', value: '0.125rem' },
197
+ { name: '--webm-radius-sm', value: '0.25rem' },
198
+ { name: '--webm-radius-md', value: '0.5rem' },
199
+ { name: '--webm-radius-lg', value: '1rem' },
200
+ { name: '--webm-radius-xl', value: '1.5rem' },
201
+ { name: '--webm-radius-pill', value: '62.4375rem' },
202
+ { name: '--webm-radius-circle', value: '50%' },
203
+ ],
204
+ },
205
+ {
206
+ title: 'border-width',
207
+ tokens: [
208
+ { name: '--webm-border-width-0', value: '0' },
209
+ { name: '--webm-border-width-1', value: '1px' },
210
+ { name: '--webm-border-width-2', value: '2px' },
211
+ { name: '--webm-border-width-4', value: '4px' },
212
+ ],
213
+ },
214
+ {
215
+ title: 'shadow',
216
+ tokens: [
217
+ { name: '--webm-shadow-xs', value: '0 1px 2px 0 rgb(34 34 34 / 0.06)' },
218
+ {
219
+ name: '--webm-shadow-sm',
220
+ value: '0 1px 3px 0 rgb(34 34 34 / 0.1), 0 1px 2px -1px rgb(34 34 34 / 0.1)',
221
+ },
222
+ {
223
+ name: '--webm-shadow-md',
224
+ value: '0 4px 6px -1px rgb(34 34 34 / 0.1), 0 2px 4px -2px rgb(34 34 34 / 0.1)',
225
+ },
226
+ {
227
+ name: '--webm-shadow-lg',
228
+ value: '0 10px 15px -3px rgb(34 34 34 / 0.1), 0 4px 6px -4px rgb(34 34 34 / 0.1)',
229
+ },
230
+ {
231
+ name: '--webm-shadow-xl',
232
+ value: '0 20px 25px -5px rgb(34 34 34 / 0.12), 0 8px 10px -6px rgb(34 34 34 / 0.1)',
233
+ },
234
+ ],
235
+ },
236
+ {
237
+ title: 'z-index',
238
+ note: 'Named layers only. Never write a raw z-index in component CSS.',
239
+ tokens: [
240
+ { name: '--webm-z-below', value: '-1' },
241
+ { name: '--webm-z-base', value: '0' },
242
+ { name: '--webm-z-raised', value: '10' },
243
+ { name: '--webm-z-sticky', value: '100' },
244
+ { name: '--webm-z-header', value: '200' },
245
+ { name: '--webm-z-drawer', value: '300' },
246
+ { name: '--webm-z-overlay', value: '400' },
247
+ { name: '--webm-z-modal', value: '500' },
248
+ { name: '--webm-z-toast', value: '600' },
249
+ { name: '--webm-z-tooltip', value: '700' },
250
+ { name: '--webm-z-skiplink', value: '900' },
251
+ ],
252
+ },
253
+ {
254
+ title: 'duration',
255
+ tokens: [
256
+ { name: '--webm-duration-instant', value: '75ms' },
257
+ { name: '--webm-duration-fast', value: '150ms' },
258
+ { name: '--webm-duration-normal', value: '250ms' },
259
+ { name: '--webm-duration-slow', value: '400ms' },
260
+ { name: '--webm-duration-slower', value: '600ms' },
261
+ ],
262
+ },
263
+ {
264
+ title: 'easing',
265
+ tokens: [
266
+ { name: '--webm-ease-linear', value: 'linear' },
267
+ { name: '--webm-ease-out', value: 'cubic-bezier(0.16, 1, 0.3, 1)' },
268
+ { name: '--webm-ease-in', value: 'cubic-bezier(0.7, 0, 0.84, 0)' },
269
+ { name: '--webm-ease-in-out', value: 'cubic-bezier(0.65, 0, 0.35, 1)' },
270
+ { name: '--webm-ease-spring', value: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
271
+ ],
272
+ },
273
+ {
274
+ title: 'scroll reveal',
275
+ note: [
276
+ 'Defaults for .webm-reveal. Overridable per element with data-reveal-delay and',
277
+ 'data-reveal-duration, which observe.ts writes as inline properties.',
278
+ '',
279
+ 'NOT var(--webm-duration-slow), and not a card-sized distance. The motion scale tops out',
280
+ 'at 400ms, tuned for a button or a dropdown - something small moving a short way. A reveal',
281
+ 'is a whole content group arriving, and at 400ms over 1.5rem it reads as a twitch.',
282
+ '',
283
+ '7.5rem over 1500ms was arrived at on a real client homepage, not picked. Raising duration',
284
+ 'alone did nothing: --webm-ease-out front-loads the curve, so extra time only stretches the',
285
+ 'settle. Raising distance alone lurched. Both had to grow together.',
286
+ ].join('\n'),
287
+ tokens: [
288
+ { name: '--webm-reveal-duration', value: '1500ms' },
289
+ { name: '--webm-reveal-delay', value: '0ms' },
290
+ { name: '--webm-reveal-distance', value: '7.5rem' },
291
+ { name: '--webm-reveal-ease', value: 'var(--webm-ease-out)' },
292
+ { name: '--webm-reveal-blur', value: '8px' },
293
+ { name: '--webm-reveal-stagger-step', value: '80ms' },
294
+ ],
295
+ },
296
+ {
297
+ title: 'focus',
298
+ tokens: [
299
+ { name: '--webm-focus-width', value: '3px' },
300
+ { name: '--webm-focus-offset', value: '2px' },
301
+ { name: '--webm-focus-color', value: 'var(--webm-action)' },
302
+ ],
303
+ },
304
+ {
305
+ title: 'composites',
306
+ tokens: [
307
+ { name: '--webm-focus-ring', value: 'var(--webm-focus-width) solid var(--webm-focus-color)' },
308
+ ],
309
+ },
310
+ ];
311
+
312
+ /** Every default token name, for validation and for `webm doctor`. */
313
+ export const TOKEN_NAMES: ReadonlySet<string> = new Set(
314
+ DEFAULTS.flatMap((g) => g.tokens.map((t) => t.name)),
315
+ );
@@ -0,0 +1,18 @@
1
+ /*
2
+ * @cparkerwebm/webmonterey/design
3
+ *
4
+ * One input, four outputs. design.json compiles to:
5
+ *
6
+ * CSS custom properties -> the website and web app, via a Vite virtual module
7
+ * email-safe literals -> transactional mail, where var() does not exist
8
+ * brand context -> Cowork and the platform MCP server
9
+ * resolved palette -> social graphics and client reports
10
+ *
11
+ * No Astro imports anywhere under this subpath. That is what keeps it testable with
12
+ * `node --test` and usable from the platform without pulling a framework in to read a color.
13
+ */
14
+ export { DEFAULTS, TOKEN_NAMES } from './defaults.ts';
15
+ export { compile, toCss, compileToCss, DesignError } from './compile.ts';
16
+ export { resolve, emailPalette, ResolveError } from './resolve.ts';
17
+ export { brandContext } from './brand.ts';
18
+ export type { DesignSystem, Token, TokenGroup, Color } from './types.ts';
@@ -0,0 +1,80 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { compile } from './compile.ts';
4
+ import { resolve, emailPalette, ResolveError } from './resolve.ts';
5
+ import { brandContext } from './brand.ts';
6
+ import type { TokenGroup } from './types.ts';
7
+
8
+ const group = (tokens: Record<string, string>): TokenGroup[] => [
9
+ { title: 'test', tokens: Object.entries(tokens).map(([name, value]) => ({ name, value })) },
10
+ ];
11
+
12
+ test('a var() chain resolves to a literal', () => {
13
+ const out = resolve(
14
+ group({
15
+ '--webm-a': '#123456',
16
+ '--webm-b': 'var(--webm-a)',
17
+ '--webm-c': 'var(--webm-b)',
18
+ }),
19
+ );
20
+ assert.equal(out.get('--webm-c'), '#123456');
21
+ });
22
+
23
+ test('the default set resolves with no var() left anywhere', () => {
24
+ for (const [name, value] of resolve(compile())) {
25
+ assert.ok(!value.includes('var('), `${name} still contains var(): ${value}`);
26
+ }
27
+ });
28
+
29
+ test('a composite with several references resolves all of them', () => {
30
+ const out = resolve(compile());
31
+ assert.equal(out.get('--webm-focus-ring'), '3px solid #006abe');
32
+ });
33
+
34
+ test('a circular reference throws rather than looping', () => {
35
+ assert.throws(
36
+ () => resolve(group({ '--webm-a': 'var(--webm-b)', '--webm-b': 'var(--webm-a)' })),
37
+ (e: unknown) => e instanceof ResolveError && /Circular/.test((e as Error).message),
38
+ );
39
+ });
40
+
41
+ test('an undefined reference with a fallback uses the fallback', () => {
42
+ const out = resolve(group({ '--webm-a': 'var(--webm-missing, 1rem)' }));
43
+ assert.equal(out.get('--webm-a'), '1rem');
44
+ });
45
+
46
+ test('an undefined reference without a fallback throws, rather than emitting empty', () => {
47
+ assert.throws(() => resolve(group({ '--webm-a': 'var(--webm-missing)' })), ResolveError);
48
+ });
49
+
50
+ test('the email palette is literals only, keyed without the prefix', () => {
51
+ const palette = emailPalette(compile({ color: { action: { base: '#bfb23b' } } }));
52
+ assert.equal(palette.action, '#bfb23b');
53
+ assert.equal(palette.link, '#bfb23b', 'link points at action and must arrive resolved');
54
+ for (const [k, v] of Object.entries(palette)) {
55
+ assert.ok(!k.startsWith('--'), `${k} kept its prefix`);
56
+ assert.ok(!v.includes('var('), `${k} still contains var()`);
57
+ }
58
+ });
59
+
60
+ test('the email palette omits fluid sizes no mail client understands', () => {
61
+ for (const v of Object.values(emailPalette(compile()))) {
62
+ assert.ok(!v.includes('clamp('), `a clamp() reached the email palette: ${v}`);
63
+ }
64
+ });
65
+
66
+ test('brand context carries voice and rules alongside a resolved palette', () => {
67
+ const ctx = brandContext({
68
+ brand: { name: 'Autire', voice: 'Direct.', rules: ['Gold is a fill only.'] },
69
+ color: { action: { base: '#bfb23b' } },
70
+ overrides: { '--webm-text-on-action': 'var(--webm-base-700)' },
71
+ });
72
+ assert.equal(ctx.name, 'Autire');
73
+ assert.deepEqual(ctx.rules, ['Gold is a fill only.']);
74
+ assert.equal(ctx.palette.action, '#bfb23b');
75
+ assert.ok(!JSON.stringify(ctx).includes('var('), 'brand context must be fully resolved');
76
+ });
77
+
78
+ test('brand context defaults rules to an empty array, never undefined', () => {
79
+ assert.deepEqual(brandContext().rules, []);
80
+ });
@@ -0,0 +1,108 @@
1
+ /*
2
+ * Flatten var() chains to literal values.
3
+ *
4
+ * This is the email output. Email HTML has no cascade, no classes and no custom properties -
5
+ * Outlook has enforced that for fifteen years - so every style must be inlined as a literal.
6
+ * `--webm-link: var(--webm-action)` is useless in a mail client; `#006abe` is not.
7
+ *
8
+ * Also the "resolved palette" used for social graphics and reports, where a renderer wants a
9
+ * color rather than a reference.
10
+ */
11
+ import type { TokenGroup } from './types.ts';
12
+
13
+ export class ResolveError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = 'ResolveError';
17
+ }
18
+ }
19
+
20
+ /** Matches one var() call, capturing the name and an optional fallback. */
21
+ const VAR = /var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/;
22
+
23
+ /**
24
+ * Resolve every token to a literal, following var() references.
25
+ *
26
+ * A reference to an undefined token resolves to its fallback if one is written, and throws
27
+ * otherwise - a silently-empty value in an email is worse than a failed build, because nobody
28
+ * sees it until a client forwards the mail asking why it looks broken.
29
+ */
30
+ export function resolve(groups: TokenGroup[]): Map<string, string> {
31
+ const raw = new Map<string, string>();
32
+ for (const g of groups) for (const t of g.tokens) raw.set(t.name, t.value);
33
+
34
+ const done = new Map<string, string>();
35
+
36
+ const expand = (name: string, seen: string[]): string => {
37
+ const cached = done.get(name);
38
+ if (cached !== undefined) return cached;
39
+
40
+ if (seen.includes(name)) {
41
+ throw new ResolveError(`Circular token reference: ${[...seen, name].join(' -> ')}`);
42
+ }
43
+ const value = raw.get(name);
44
+ if (value === undefined) {
45
+ throw new ResolveError(`Token ${name} is referenced but never defined`);
46
+ }
47
+
48
+ let out = value;
49
+ for (let guard = 0; guard < 50; guard++) {
50
+ const m = VAR.exec(out);
51
+ if (!m) break;
52
+ const [whole, ref, fallback] = m;
53
+ let replacement: string;
54
+ if (raw.has(ref!)) {
55
+ replacement = expand(ref!, [...seen, name]);
56
+ } else if (fallback !== undefined) {
57
+ replacement = fallback.trim();
58
+ } else {
59
+ throw new ResolveError(
60
+ `Token ${name} references ${ref}, which is not defined and has no fallback`,
61
+ );
62
+ }
63
+ out = out.slice(0, m.index) + replacement + out.slice(m.index + whole!.length);
64
+ }
65
+
66
+ done.set(name, out);
67
+ return out;
68
+ };
69
+
70
+ for (const name of raw.keys()) expand(name, []);
71
+ return done;
72
+ }
73
+
74
+ /**
75
+ * The subset an email template actually reaches for, resolved flat.
76
+ *
77
+ * Deliberately narrow. An email primitive needs colors, a font stack and a couple of sizes -
78
+ * handing it 124 tokens invites someone to inline a fluid clamp() into a table cell, which no
79
+ * mail client understands.
80
+ */
81
+ export function emailPalette(groups: TokenGroup[]): Record<string, string> {
82
+ const all = resolve(groups);
83
+ const want = [
84
+ '--webm-surface',
85
+ '--webm-surface-alt',
86
+ '--webm-surface-inverse',
87
+ '--webm-text',
88
+ '--webm-text-muted',
89
+ '--webm-text-strong',
90
+ '--webm-text-inverse',
91
+ '--webm-text-on-action',
92
+ '--webm-action',
93
+ '--webm-action-dark',
94
+ '--webm-link',
95
+ '--webm-border-subtle',
96
+ '--webm-font-sans',
97
+ '--webm-radius-md',
98
+ '--webm-space-sm',
99
+ '--webm-space-md',
100
+ '--webm-space-lg',
101
+ ];
102
+ const out: Record<string, string> = {};
103
+ for (const name of want) {
104
+ const value = all.get(name);
105
+ if (value !== undefined) out[name.replace('--webm-', '')] = value;
106
+ }
107
+ return out;
108
+ }
@@ -0,0 +1,96 @@
1
+ /*
2
+ * The shape of design.json.
3
+ *
4
+ * DELIBERATELY SMALL. tokens.css in generation 2 was ~150 custom properties, but only about
5
+ * fifteen of them ever changed between clients: the neutral ramp, the action color, borders,
6
+ * state colors and the two font stacks. Everything else - spacing, widths, z-index, durations,
7
+ * easings, shadows - is the design SYSTEM, not the brand, and a client who edits them is
8
+ * usually making a mistake.
9
+ *
10
+ * So design.json carries what varies and the compiler emits the rest from defaults. A client
11
+ * file is ~30 lines rather than ~200, and the diff between two clients is legible.
12
+ *
13
+ * `overrides` is the escape hatch for the remainder: any --webm-* property, set raw. Reach for
14
+ * it when the token system genuinely does not express what a client needs, not to avoid
15
+ * learning where a value lives.
16
+ */
17
+
18
+ /** A CSS color, in any notation CSS accepts. Not validated beyond being a string. */
19
+ export type Color = string;
20
+
21
+ export interface DesignSystem {
22
+ /** Ignored by the compiler; present so editors can offer completion. */
23
+ $schema?: string;
24
+
25
+ /** Bumped only for a breaking change to this shape. */
26
+ version?: 1;
27
+
28
+ /**
29
+ * The words half of the design system, and the half that travels furthest: site copy,
30
+ * social posts, and anything written in the client's name.
31
+ *
32
+ * Not consumed by the CSS compiler. Served to the platform and to Cowork as brand context.
33
+ */
34
+ brand?: {
35
+ /** Display name. Falls back to `client` in webmonterey.json. */
36
+ name?: string;
37
+ /** How this client writes. One or two sentences, concrete. */
38
+ voice?: string;
39
+ /**
40
+ * Hard rules a writer or a generator must not break. Phrase each as an instruction.
41
+ * e.g. "Gold is a fill only - never gold text or links on white."
42
+ */
43
+ rules?: string[];
44
+ logo?: {
45
+ /** Full lockup. Repo-relative or absolute. */
46
+ primary?: string;
47
+ /** Icon-only mark, for favicons and tight spaces. */
48
+ mark?: string;
49
+ };
50
+ };
51
+
52
+ color?: {
53
+ /**
54
+ * The neutral ramp. Everything semantic points at these, never the other way round.
55
+ * 100 is lightest, 900 darkest - the direction does not invert for a dark design.
56
+ */
57
+ base?: Partial<Record<'100' | '300' | '500' | '700' | '900', Color>>;
58
+ /** The brand color. Retheming a client usually starts and ends here. */
59
+ action?: { base?: Color; dark?: Color; light?: Color };
60
+ border?: { subtle?: Color };
61
+ state?: Partial<Record<'success' | 'warning' | 'danger' | 'info', Color>>;
62
+ };
63
+
64
+ font?: {
65
+ /**
66
+ * Full CSS font stacks, not family names - the compiler does not append fallbacks.
67
+ * A self-hosted face still needs its @font-face declared in src/styles/custom/.
68
+ */
69
+ sans?: string;
70
+ mono?: string;
71
+ };
72
+
73
+ /** Partial: name only the steps that differ from the defaults. */
74
+ radius?: Partial<Record<'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'pill' | 'circle', string>>;
75
+
76
+ /**
77
+ * Raw token overrides, applied last and unvalidated beyond the name prefix.
78
+ * Keys must start with `--webm-`; anything else is a build error, because a typo here is
79
+ * otherwise silent - the property is set, nothing reads it, and the page looks untouched.
80
+ */
81
+ overrides?: Record<string, string>;
82
+ }
83
+
84
+ /** One emitted custom property. */
85
+ export interface Token {
86
+ name: string;
87
+ value: string;
88
+ }
89
+
90
+ /** A titled run of tokens. Groups exist to keep compiled CSS readable, nothing more. */
91
+ export interface TokenGroup {
92
+ title: string;
93
+ /** Emitted as a comment above the group. Omit for self-evident groups. */
94
+ note?: string;
95
+ tokens: Token[];
96
+ }