@milesroxas/design-hub 0.0.0-stage → 0.1.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 (217) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/client.d.ts +6 -0
  4. package/configs/biome/biome.json +63 -0
  5. package/configs/tsconfig.project.json +17 -0
  6. package/core/bytes.test.ts +14 -0
  7. package/core/bytes.ts +14 -0
  8. package/core/catalog.test.ts +44 -0
  9. package/core/catalog.ts +43 -0
  10. package/core/checks.test.ts +23 -0
  11. package/core/checks.ts +72 -0
  12. package/core/contacts.test.ts +31 -0
  13. package/core/contacts.ts +38 -0
  14. package/core/doc.ts +4 -0
  15. package/core/feature.ts +6 -0
  16. package/core/fixtures.test.ts +33 -0
  17. package/core/fixtures.ts +69 -0
  18. package/core/frontmatter.ts +15 -0
  19. package/core/html.ts +3 -0
  20. package/core/image.test.ts +22 -0
  21. package/core/image.ts +37 -0
  22. package/core/jev.test.ts +66 -0
  23. package/core/jev.ts +77 -0
  24. package/core/review/model.ts +189 -0
  25. package/core/review/policy.ts +26 -0
  26. package/core/review/service.test.ts +163 -0
  27. package/core/review/service.ts +309 -0
  28. package/core/review/store.ts +51 -0
  29. package/core/review/triage.test.ts +41 -0
  30. package/core/review/triage.ts +58 -0
  31. package/core/states.test.ts +36 -0
  32. package/core/states.ts +34 -0
  33. package/dist/core/bytes.d.ts +5 -0
  34. package/dist/core/bytes.js +9 -0
  35. package/dist/core/catalog.d.ts +17 -0
  36. package/dist/core/catalog.js +24 -0
  37. package/dist/core/checks.d.ts +41 -0
  38. package/dist/core/checks.js +53 -0
  39. package/dist/core/contacts.d.ts +9 -0
  40. package/dist/core/contacts.js +34 -0
  41. package/dist/core/doc.d.ts +11 -0
  42. package/dist/core/doc.js +1 -0
  43. package/dist/core/feature.d.ts +3 -0
  44. package/dist/core/feature.js +4 -0
  45. package/dist/core/fixtures.d.ts +19 -0
  46. package/dist/core/fixtures.js +52 -0
  47. package/dist/core/frontmatter.d.ts +4 -0
  48. package/dist/core/frontmatter.js +15 -0
  49. package/dist/core/html.d.ts +2 -0
  50. package/dist/core/html.js +2 -0
  51. package/dist/core/image.d.ts +6 -0
  52. package/dist/core/image.js +34 -0
  53. package/dist/core/jev.d.ts +54 -0
  54. package/dist/core/jev.js +52 -0
  55. package/dist/core/review/model.d.ts +85 -0
  56. package/dist/core/review/model.js +112 -0
  57. package/dist/core/review/policy.d.ts +3 -0
  58. package/dist/core/review/policy.js +19 -0
  59. package/dist/core/review/service.d.ts +79 -0
  60. package/dist/core/review/service.js +245 -0
  61. package/dist/core/review/store.d.ts +20 -0
  62. package/dist/core/review/store.js +37 -0
  63. package/dist/core/review/triage.d.ts +6 -0
  64. package/dist/core/review/triage.js +45 -0
  65. package/dist/core/states.d.ts +6 -0
  66. package/dist/core/states.js +29 -0
  67. package/dist/scripts/bootstrap.d.ts +51 -0
  68. package/dist/scripts/bootstrap.js +278 -0
  69. package/dist/scripts/calibrate.d.ts +127 -0
  70. package/dist/scripts/calibrate.js +164 -0
  71. package/dist/scripts/check.d.ts +25 -0
  72. package/dist/scripts/check.js +30 -0
  73. package/dist/scripts/cli.d.ts +2 -0
  74. package/dist/scripts/cli.js +73 -0
  75. package/dist/scripts/commands.d.ts +25 -0
  76. package/dist/scripts/commands.js +49 -0
  77. package/dist/scripts/comments.d.ts +63 -0
  78. package/dist/scripts/comments.js +152 -0
  79. package/dist/scripts/dev.d.ts +46 -0
  80. package/dist/scripts/dev.js +84 -0
  81. package/dist/scripts/elyx.d.ts +61 -0
  82. package/dist/scripts/elyx.js +195 -0
  83. package/dist/scripts/fixtures-pull.d.ts +60 -0
  84. package/dist/scripts/fixtures-pull.js +121 -0
  85. package/dist/scripts/guard.d.ts +58 -0
  86. package/dist/scripts/guard.js +201 -0
  87. package/dist/scripts/hook.d.ts +18 -0
  88. package/dist/scripts/hook.js +46 -0
  89. package/dist/scripts/lib/bin.d.ts +4 -0
  90. package/dist/scripts/lib/bin.js +20 -0
  91. package/dist/scripts/lib/catalog.d.ts +7 -0
  92. package/dist/scripts/lib/catalog.js +18 -0
  93. package/dist/scripts/lib/cli.d.ts +75 -0
  94. package/dist/scripts/lib/cli.js +88 -0
  95. package/dist/scripts/lib/css-tokens.d.ts +13 -0
  96. package/dist/scripts/lib/css-tokens.js +67 -0
  97. package/dist/scripts/lib/judge.d.ts +24 -0
  98. package/dist/scripts/lib/judge.js +60 -0
  99. package/dist/scripts/lib/ports.d.ts +35 -0
  100. package/dist/scripts/lib/ports.js +113 -0
  101. package/dist/scripts/lib/review.d.ts +7 -0
  102. package/dist/scripts/lib/review.js +22 -0
  103. package/dist/scripts/lib/rules.d.ts +12 -0
  104. package/dist/scripts/lib/rules.js +20 -0
  105. package/dist/scripts/lib/vercel.d.ts +17 -0
  106. package/dist/scripts/lib/vercel.js +53 -0
  107. package/dist/scripts/new.d.ts +47 -0
  108. package/dist/scripts/new.js +113 -0
  109. package/dist/scripts/people.d.ts +59 -0
  110. package/dist/scripts/people.js +73 -0
  111. package/dist/scripts/preflight.d.ts +35 -0
  112. package/dist/scripts/preflight.js +251 -0
  113. package/dist/scripts/release-check.d.ts +19 -0
  114. package/dist/scripts/release-check.js +39 -0
  115. package/dist/scripts/skill-hint.d.ts +32 -0
  116. package/dist/scripts/skill-hint.js +103 -0
  117. package/dist/scripts/skills.d.ts +13 -0
  118. package/dist/scripts/skills.js +34 -0
  119. package/dist/scripts/visual-match.d.ts +46 -0
  120. package/dist/scripts/visual-match.js +93 -0
  121. package/dist/server/api.d.ts +22 -0
  122. package/dist/server/api.js +143 -0
  123. package/dist/server/checks.d.ts +4 -0
  124. package/dist/server/checks.js +22 -0
  125. package/dist/server/dev.d.ts +2 -0
  126. package/dist/server/dev.js +54 -0
  127. package/dist/server/env.d.ts +8 -0
  128. package/dist/server/env.js +57 -0
  129. package/dist/server/fake-upstash.d.ts +1 -0
  130. package/dist/server/fake-upstash.js +37 -0
  131. package/dist/server/file-kv.d.ts +2 -0
  132. package/dist/server/file-kv.js +24 -0
  133. package/dist/server/local.d.ts +4 -0
  134. package/dist/server/local.js +25 -0
  135. package/dist/server/paths.d.ts +22 -0
  136. package/dist/server/paths.js +81 -0
  137. package/dist/server/session.d.ts +22 -0
  138. package/dist/server/session.js +110 -0
  139. package/dist/server/upstash.d.ts +7 -0
  140. package/dist/server/upstash.js +41 -0
  141. package/dist/vercel/index.d.ts +5 -0
  142. package/dist/vercel/index.js +12 -0
  143. package/dist/vercel/proxy.d.ts +11 -0
  144. package/dist/vercel/proxy.js +238 -0
  145. package/dist/vite/kit.d.ts +4 -0
  146. package/dist/vite/kit.js +35 -0
  147. package/dist/vite/markdown.d.ts +2 -0
  148. package/dist/vite/markdown.js +212 -0
  149. package/dist/vite/plugin.d.ts +15 -0
  150. package/dist/vite/plugin.js +84 -0
  151. package/dist/vite/registry.d.ts +7 -0
  152. package/dist/vite/registry.js +70 -0
  153. package/kit/Icon.tsx +39 -0
  154. package/kit/Space.tsx +25 -0
  155. package/kit/Variants.tsx +134 -0
  156. package/kit/ViewTools.tsx +34 -0
  157. package/kit/base.css +27 -0
  158. package/kit/dials.css +98 -0
  159. package/kit/dials.tsx +322 -0
  160. package/kit/fixtures.ts +3 -0
  161. package/kit/format.ts +13 -0
  162. package/kit/harness.css +99 -0
  163. package/kit/hooks.ts +35 -0
  164. package/kit/icons.ts +329 -0
  165. package/kit/motion.ts +18 -0
  166. package/kit/prefs.ts +111 -0
  167. package/kit/space.css +12 -0
  168. package/kit/ui/Button.tsx +135 -0
  169. package/kit/ui/Menu.tsx +386 -0
  170. package/kit/ui/Overlay.tsx +300 -0
  171. package/kit/ui/Toast.tsx +187 -0
  172. package/kit/ui/index.ts +13 -0
  173. package/kit/ui/layer.ts +51 -0
  174. package/kit/ui/ui.css +238 -0
  175. package/package.json +87 -3
  176. package/shell/Guide.tsx +160 -0
  177. package/shell/Index.tsx +301 -0
  178. package/shell/Notice.tsx +27 -0
  179. package/shell/Prototype.tsx +31 -0
  180. package/shell/a11y.ts +50 -0
  181. package/shell/doc.d.ts +7 -0
  182. package/shell/doc.ts +3 -0
  183. package/shell/guide.css +133 -0
  184. package/shell/index.html +17 -0
  185. package/shell/main.tsx +53 -0
  186. package/shell/public/favicon.svg +1 -0
  187. package/shell/registry.ts +21 -0
  188. package/shell/registry.virtual.d.ts +10 -0
  189. package/shell/review/Review.tsx +989 -0
  190. package/shell/review/anchor.ts +132 -0
  191. package/shell/review/client.ts +165 -0
  192. package/shell/review/mount.tsx +12 -0
  193. package/shell/review/review.css +296 -0
  194. package/shell/shell.css +160 -0
  195. package/shell/system.css +238 -0
  196. package/skills/miles-conductor/SKILL.md +37 -0
  197. package/skills/miles-deploy/SKILL.md +67 -0
  198. package/skills/miles-design-hub/SKILL.md +62 -0
  199. package/skills/miles-get-hostile/SKILL.md +61 -0
  200. package/skills/miles-new-feature/SKILL.md +40 -0
  201. package/skills/miles-paper-to-prototype/SKILL.md +46 -0
  202. package/skills/miles-port/SKILL.md +59 -0
  203. package/skills/miles-port/references/astro.md +35 -0
  204. package/skills/miles-port/references/next.md +36 -0
  205. package/skills/miles-prototype/SKILL.md +83 -0
  206. package/skills/miles-prototype/references/accessibility.md +48 -0
  207. package/skills/miles-prototype/references/motion.md +59 -0
  208. package/skills/miles-prototype/references/performance.md +45 -0
  209. package/skills/miles-review/SKILL.md +39 -0
  210. package/templates/app/README.md +41 -0
  211. package/templates/app/index.tsx +32 -0
  212. package/templates/app/styles.css +1 -0
  213. package/templates/conductor/settings.toml +29 -0
  214. package/templates/guide.md +38 -0
  215. package/templates/space/README.md +37 -0
  216. package/templates/space/index.tsx +43 -0
  217. package/templates/space/styles.css +13 -0
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // The Elyx add-on, one way only: tokens.css stays the single source and
4
+ // design-system/ is generated from it, so the two never disagree. A designer
5
+ // consumes it as a read-only Elyx dependency. Two-way sync is refused on
6
+ // purpose: Elyx rounds colours to 8 bits, keeps one font of a stack, cannot
7
+ // hold shadows, rings, easings or durations, and rewrites files on save, so
8
+ // anything read back would quietly change or delete tokens.
9
+ // Needs the Elyx CLI: npm install --global @elyx-design/cli
10
+ import { spawnSync } from 'node:child_process';
11
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join, relative } from 'node:path';
14
+ import { KIT, ROOT } from '../server/paths.js';
15
+ import { CliError } from './lib/cli.js';
16
+ import { block, classify, toHex } from './lib/css-tokens.js';
17
+ export const spec = {
18
+ name: 'elyx',
19
+ summary: 'Generate the Elyx design-system project from kit/tokens.css, or check it has not drifted.',
20
+ flags: {
21
+ check: { type: 'boolean', description: 'Fail when design-system/ differs from a fresh generation.' },
22
+ },
23
+ output: '{ ok, out, tokens, skipped, diagnostics } or { ok, drift }',
24
+ };
25
+ const TOKENS = join(KIT, 'tokens.css');
26
+ const OUT = join(ROOT, 'design-system');
27
+ const SOURCE = 'kit/tokens.css';
28
+ function elyx(cwd, args) {
29
+ const run = spawnSync('elyx', args, { cwd, encoding: 'utf8' });
30
+ if (run.error)
31
+ throw new CliError('The Elyx CLI is not installed.', { fix: 'npm install --global @elyx-design/cli' });
32
+ if (run.status !== 0)
33
+ throw new CliError(`elyx ${args[0]} failed.`, { stderr: (run.stderr || run.stdout).trim().slice(-800) });
34
+ return run.stdout;
35
+ }
36
+ const header = (what) => `// GENERATED from ${SOURCE}${what} by \`design-hub elyx\`.\n// Do not edit: change tokens.css and run it again.\n`;
37
+ const members = (rows) => rows.map(([n, v]) => ` ${n}: ${v}`).join(',\n');
38
+ export function plan(css) {
39
+ const light = block(css, ':root');
40
+ const dark = block(css, '.dark-mode');
41
+ const groups = { color: [], radius: [], length: [], font: [] };
42
+ const skipped = [];
43
+ for (const [name, value] of light) {
44
+ const c = classify(name, value);
45
+ if (c.kind === 'skip')
46
+ skipped.push(`--${name}: ${c.why}`);
47
+ else
48
+ groups[c.kind].push([name, c.value]);
49
+ }
50
+ const colors = new Set(groups.color.map(([n]) => n));
51
+ const darkColors = [];
52
+ for (const [name, value] of dark) {
53
+ if (!light.has(name))
54
+ throw new CliError(`--${name} is set in .dark-mode but not on :root.`, {
55
+ fix: 'Give it a light value.',
56
+ });
57
+ if (!colors.has(name)) {
58
+ skipped.push(`--${name} (.dark-mode): not a colour token`);
59
+ continue;
60
+ }
61
+ const hex = toHex(value);
62
+ if (!hex)
63
+ throw new CliError(`--${name}: the dark value ${value} is not a colour.`);
64
+ darkColors.push([name, hex]);
65
+ }
66
+ const family = groups.font.find(([n]) => n === 'font')?.[1];
67
+ const files = {
68
+ // Hex encoding stops Elyx rewriting every colour as rgb() percentages on save.
69
+ 'elyx.json': `${JSON.stringify({
70
+ color: { encoding: 'hex' },
71
+ ...(family ? { defaults: { text: { font: { family: JSON.parse(family), style: 'Regular' } } } } : {}),
72
+ exports: { './tokens/*': './tokens/*' },
73
+ }, null, 2)}\n`,
74
+ 'tokens/colors.elyx': `import "tokens/colors.dark.elyx" as dark\n\n${header('')}file {}\n\n@variants(dark.darkColors)\nexport colors = tokens {\n${members(groups.color)}\n}\n`,
75
+ 'tokens/colors.dark.elyx': `import "tokens/colors.elyx" as base\n\n${header(' (.dark-mode)')}@context(theme="dark")\nfile {}\n\nexport darkColors = tokens : base.colors {\n${members(darkColors)}\n}\n`,
76
+ };
77
+ if (groups.radius.length)
78
+ files['tokens/radius.elyx'] =
79
+ `${header('')}file {}\n\nexport radius = tokens {\n${members(groups.radius)}\n}\n`;
80
+ if (groups.length.length)
81
+ files['tokens/lengths.elyx'] =
82
+ `${header('')}file {}\n\nexport lengths = tokens {\n${members(groups.length)}\n}\n`;
83
+ if (groups.font.length)
84
+ files['tokens/typography.elyx'] =
85
+ `${header('')}file {}\n\nexport fonts = tokens {\n${members(groups.font)}\n}\n`;
86
+ return {
87
+ files,
88
+ light,
89
+ dark,
90
+ counts: {
91
+ colors: groups.color.length,
92
+ dark: darkColors.length,
93
+ radius: groups.radius.length,
94
+ lengths: groups.length.length,
95
+ fonts: groups.font.length,
96
+ },
97
+ skipped,
98
+ };
99
+ }
100
+ const README = `# Design system (generated)
101
+
102
+ An Elyx token project generated from \`${SOURCE}\` by \`design-hub elyx\`. Never edit it here: change \`tokens.css\` and run \`design-hub elyx\` again. \`design-hub elyx --check\` fails when this folder has drifted.
103
+
104
+ Use it from a designer's Elyx project as a read-only dependency, so a save in Elyx can never write back:
105
+
106
+ \`\`\`json
107
+ { "dependencies": { "hub": "path:../design-system" } }
108
+ \`\`\`
109
+
110
+ Then \`elyx deps fetch\`, and \`import "@hub/tokens/colors.elyx"\`. Commit \`elyx.lock\`, ignore \`elyx_deps/\`, and fetch again after each regeneration.
111
+
112
+ Shadows, focus rings, easings and durations have no Elyx token type and are left out; \`tokens.css\` keeps them.
113
+ `;
114
+ /** Writes the project into `out`, normalizes it the way the Elyx app saves, and proves it matches tokens.css. */
115
+ function generate(out) {
116
+ const { files, light, dark, counts, skipped } = plan(readFileSync(TOKENS, 'utf8'));
117
+ rmSync(out, { recursive: true, force: true });
118
+ mkdirSync(join(out, 'tokens'), { recursive: true });
119
+ for (const [path, text] of Object.entries(files))
120
+ writeFileSync(join(out, path), text);
121
+ writeFileSync(join(out, 'README.md'), README);
122
+ for (const path of Object.keys(files).filter((p) => p.endsWith('.elyx')))
123
+ elyx(out, ['normalize', '-w', path]);
124
+ const diagnostics = JSON.parse(elyx(out, ['diagnostics', '.']));
125
+ if (diagnostics.errors)
126
+ throw new CliError('Elyx reports errors in the generated project.', { diagnostics });
127
+ const problems = roundTrip(out, light, dark);
128
+ if (problems.length)
129
+ throw new CliError('The Elyx export does not match tokens.css.', { problems });
130
+ return { counts, skipped, diagnostics };
131
+ }
132
+ const bytes = (hex) => [0, 2, 4, 6].map((i) => (i < hex.length - 1 ? Number.parseInt(hex.slice(1 + i, 3 + i), 16) : 255));
133
+ function exported(value) {
134
+ const m = value.match(/^rgb\((\d+) (\d+) (\d+)(?: \/ ([\d.]+))?\)$/);
135
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3]), Math.round(Number(m[4] ?? 1) * 255)] : null;
136
+ }
137
+ /** A token whose bytes differ from tokens.css, or null when they match or cannot be compared. */
138
+ export function mismatch(t, source) {
139
+ const hex = source ? toHex(source) : null;
140
+ const got = exported(t.value);
141
+ if (!hex || !got || bytes(hex).join() === got.join())
142
+ return null;
143
+ return `${t.context.theme === 'dark' ? 'dark' : 'light'} ${t.name}: ${source} became ${t.value}`;
144
+ }
145
+ /** Colours in tokens.css that the export dropped; Elyx drops what it cannot hold without an error. */
146
+ function dropped(theme, declared, seen) {
147
+ return [...declared]
148
+ .filter(([name, value]) => classify(name, value).kind === 'color' && !seen.has(name))
149
+ .map(([name]) => `${theme} ${name}: missing`);
150
+ }
151
+ /** Every colour in tokens.css is in the export with the same bytes, in both themes. */
152
+ function roundTrip(out, light, dark) {
153
+ const tokens = JSON.parse(elyx(out, ['tokens', 'tokens/colors.elyx', '--format', 'json'])).tokens;
154
+ const darkTokens = tokens.filter((t) => t.context.theme === 'dark');
155
+ const lightTokens = tokens.filter((t) => t.context.theme !== 'dark');
156
+ return [
157
+ ...lightTokens.map((t) => mismatch(t, light.get(t.name))),
158
+ // A dark token without an override inherits the light value.
159
+ ...darkTokens.map((t) => mismatch(t, dark.get(t.name) ?? light.get(t.name))),
160
+ ...dropped('light', light, new Set(lightTokens.map((t) => t.name))),
161
+ ...dropped('dark', dark, new Set(darkTokens.map((t) => t.name))),
162
+ ].filter((p) => p !== null);
163
+ }
164
+ const listFiles = (dir, base = dir) => existsSync(dir)
165
+ ? readdirSync(dir, { withFileTypes: true }).flatMap((d) => d.isDirectory()
166
+ ? d.name === 'elyx_deps'
167
+ ? []
168
+ : listFiles(join(dir, d.name), base)
169
+ : [relative(base, join(dir, d.name))])
170
+ : [];
171
+ export function main({ flags }) {
172
+ if (!flags.check) {
173
+ const result = generate(OUT);
174
+ return {
175
+ out: relative(ROOT, OUT),
176
+ tokens: result.counts,
177
+ skipped: result.skipped,
178
+ diagnostics: result.diagnostics,
179
+ };
180
+ }
181
+ const tmp = mkdtempSync(join(tmpdir(), 'hub-elyx-'));
182
+ try {
183
+ generate(tmp);
184
+ const files = new Set([...listFiles(tmp), ...listFiles(OUT)]);
185
+ const drift = [...files].filter((f) => {
186
+ const a = join(tmp, f);
187
+ const b = join(OUT, f);
188
+ return !existsSync(a) || !existsSync(b) || readFileSync(a, 'utf8') !== readFileSync(b, 'utf8');
189
+ });
190
+ return { ok: drift.length === 0, drift, fix: drift.length ? 'design-hub elyx' : undefined };
191
+ }
192
+ finally {
193
+ rmSync(tmp, { recursive: true, force: true });
194
+ }
195
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { type Args } from './lib/cli.ts';
3
+ export declare const spec: {
4
+ readonly name: 'fixtures-pull';
5
+ readonly summary: "Write a prototype's real fixtures from a Webflow CMS collection.";
6
+ readonly positionals: [{
7
+ readonly name: 'prototype';
8
+ readonly description: 'The prototype folder, e.g. sample-list';
9
+ readonly required: true;
10
+ }];
11
+ readonly flags: {
12
+ readonly collection: {
13
+ readonly type: 'string';
14
+ readonly required: true;
15
+ readonly description: 'The Webflow collection id.';
16
+ };
17
+ readonly map: {
18
+ readonly type: 'list';
19
+ readonly description: 'key=field.path: the fixture key and the fieldData path it comes from (thumbnail.url). Omit to list fields.';
20
+ };
21
+ readonly limit: {
22
+ readonly type: 'number';
23
+ readonly default: 20;
24
+ readonly description: 'How many items to keep.';
25
+ };
26
+ readonly staged: {
27
+ readonly type: 'boolean';
28
+ readonly description: 'Read staged items (drafts included) instead of published ones.';
29
+ };
30
+ };
31
+ readonly output: '{ ok, file, items, contacts } or, without --map, { ok: false, fields }';
32
+ };
33
+ type Item = {
34
+ id: string;
35
+ isDraft?: boolean;
36
+ isArchived?: boolean;
37
+ fieldData: Record<string, unknown>;
38
+ };
39
+ export type FieldMap = {
40
+ key: string;
41
+ path: string[];
42
+ };
43
+ export declare function parseMap(raw: string): FieldMap;
44
+ /** One fixture item: the Webflow id, then each mapped field; a path that is not there is null. */
45
+ export declare function pick(item: Item, maps: FieldMap[]): Record<string, unknown>;
46
+ export declare function main({ positionals: [slug], flags }: Args<typeof spec>): Promise<{
47
+ ok: boolean;
48
+ error: string;
49
+ fields: {
50
+ field: string;
51
+ type: string;
52
+ displayName: string;
53
+ }[];
54
+ } | {
55
+ file: string;
56
+ items: number;
57
+ contacts: number;
58
+ next: string;
59
+ }>;
60
+ export {};
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // Writes a prototype's `real` fixtures from a Webflow CMS collection, with
4
+ // only the fields the component renders, so a pull spends nothing on fields
5
+ // nobody reads. Pages through the Data API (100 items a request) for any
6
+ // --limit. Every other key in fixtures.json is kept. Run with no --map to
7
+ // list the collection's fields. Needs WEBFLOW_TOKEN (cms:read) in .env.
8
+ // API: https://developers.webflow.com/data/reference/cms/collection-items
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join, relative } from 'node:path';
11
+ import { contactsInJson } from '../core/contacts.js';
12
+ import { readEnv } from '../server/env.js';
13
+ import { PROTOTYPES, ROOT } from '../server/paths.js';
14
+ import { CliError } from './lib/cli.js';
15
+ export const spec = {
16
+ name: 'fixtures-pull',
17
+ summary: "Write a prototype's real fixtures from a Webflow CMS collection.",
18
+ positionals: [{ name: 'prototype', description: 'The prototype folder, e.g. sample-list', required: true }],
19
+ flags: {
20
+ collection: { type: 'string', required: true, description: 'The Webflow collection id.' },
21
+ map: {
22
+ type: 'list',
23
+ description: 'key=field.path: the fixture key and the fieldData path it comes from (thumbnail.url). Omit to list fields.',
24
+ },
25
+ limit: { type: 'number', default: 20, description: 'How many items to keep.' },
26
+ staged: {
27
+ type: 'boolean',
28
+ description: 'Read staged items (drafts included) instead of published ones.',
29
+ },
30
+ },
31
+ output: '{ ok, file, items, contacts } or, without --map, { ok: false, fields }',
32
+ };
33
+ const API = 'https://api.webflow.com/v2';
34
+ const PAGE = 100;
35
+ const TIMEOUT_MS = 15_000;
36
+ export function parseMap(raw) {
37
+ const at = raw.indexOf('=');
38
+ const key = at > 0 ? raw.slice(0, at).trim() : '';
39
+ const path = at > 0 ? raw.slice(at + 1).trim() : '';
40
+ if (!/^[A-Za-z_][\w]*$/.test(key) || !path)
41
+ throw new CliError(`--map ${raw}: write it as key=field.path.`, { example: '--map cover=thumbnail.url' });
42
+ return { key, path: path.split('.') };
43
+ }
44
+ /** One fixture item: the Webflow id, then each mapped field; a path that is not there is null. */
45
+ export function pick(item, maps) {
46
+ const out = { id: item.id };
47
+ for (const { key, path } of maps) {
48
+ let value = item.fieldData;
49
+ for (const part of path)
50
+ value = value && typeof value === 'object' ? value[part] : undefined;
51
+ out[key] = value ?? null;
52
+ }
53
+ return out;
54
+ }
55
+ async function get(token, path) {
56
+ const response = await fetch(`${API}${path}`, {
57
+ headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
58
+ signal: AbortSignal.timeout(TIMEOUT_MS),
59
+ });
60
+ if (!response.ok)
61
+ throw new CliError(`Webflow answered ${response.status} for ${path}.`, {
62
+ body: (await response.text()).slice(0, 300),
63
+ fix: response.status === 401 || response.status === 403
64
+ ? 'WEBFLOW_TOKEN needs the cms:read scope.'
65
+ : undefined,
66
+ });
67
+ return (await response.json());
68
+ }
69
+ async function listFields(token, collection) {
70
+ const schema = await get(token, `/collections/${collection}`);
71
+ return {
72
+ ok: false,
73
+ error: `Name the fields the component renders with --map, from ${schema.displayName}'s fields.`,
74
+ fields: schema.fields.map(({ slug: field, type, displayName }) => ({ field, type, displayName })),
75
+ };
76
+ }
77
+ /** Pages through the collection until `limit` unarchived items are in, or it runs out. */
78
+ async function pullItems(token, collection, limit, staged) {
79
+ const items = [];
80
+ const listing = staged ? 'items' : 'items/live';
81
+ for (let offset = 0; items.length < limit; offset += PAGE) {
82
+ const page = await get(token, `/collections/${collection}/${listing}?offset=${offset}&limit=${PAGE}`);
83
+ items.push(...page.items.filter((i) => !i.isArchived));
84
+ if (offset + PAGE >= page.pagination.total || !page.items.length)
85
+ break;
86
+ }
87
+ return items.slice(0, limit);
88
+ }
89
+ /** Replaces `real` and keeps every other set, adding an empty `missing` for the hand-written cases. */
90
+ function writeReal(file, real) {
91
+ const current = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
92
+ writeFileSync(file, `${JSON.stringify({ ...current, real, missing: current.missing ?? [] }, null, 2)}\n`);
93
+ }
94
+ export async function main({ positionals: [slug], flags }) {
95
+ const token = readEnv(ROOT).WEBFLOW_TOKEN;
96
+ if (!token)
97
+ throw new CliError('WEBFLOW_TOKEN is not in .env.', {
98
+ fix: 'Create a site token with cms:read in Webflow (Site settings, Apps & integrations) and add WEBFLOW_TOKEN=<token> to .env.',
99
+ });
100
+ const dir = join(PROTOTYPES, slug);
101
+ if (!existsSync(join(dir, 'README.md')))
102
+ throw new CliError(`No prototype called ${slug}.`);
103
+ const collection = encodeURIComponent(flags.collection);
104
+ if (!flags.map.length)
105
+ return listFields(token, collection);
106
+ const maps = flags.map.map(parseMap);
107
+ if (!Number.isInteger(flags.limit) || flags.limit < 1)
108
+ throw new CliError('--limit takes a whole number above 0.');
109
+ const real = (await pullItems(token, collection, flags.limit, flags.staged)).map((i) => pick(i, maps));
110
+ const file = join(dir, 'fixtures.json');
111
+ writeReal(file, real);
112
+ const contacts = contactsInJson(real).length;
113
+ return {
114
+ file: relative(ROOT, file),
115
+ items: real.length,
116
+ contacts,
117
+ next: contacts
118
+ ? `${contacts} email or phone values came in; design-hub preflight stops on them until each is confirmed or replaced.`
119
+ : 'Write the missing set by hand (miles-get-hostile).',
120
+ };
121
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import type { Args } from './lib/cli.ts';
3
+ import { judge } from './lib/judge.ts';
4
+ export declare const spec: {
5
+ readonly name: 'guard';
6
+ readonly summary: 'Run the fidelity guard over whole files, as if each had just been written.';
7
+ readonly positionals: [{
8
+ readonly name: 'file';
9
+ readonly description: 'A file under prototypes/ or kit/';
10
+ readonly required: true;
11
+ readonly variadic: true;
12
+ }];
13
+ readonly flags: {};
14
+ readonly output: '{ ok, findings: [{ file, rule, level, note, p?, id? }] }';
15
+ };
16
+ export type Change = {
17
+ file: string;
18
+ added: string;
19
+ removed: string;
20
+ whole: boolean;
21
+ };
22
+ export type Level = 'warn' | 'block';
23
+ export type Finding = {
24
+ file: string;
25
+ rule: string;
26
+ level: Level;
27
+ note: string;
28
+ p?: number;
29
+ id?: string;
30
+ };
31
+ /** The feature folder a file belongs to, or null for anything outside prototypes/<slug>/. */
32
+ export declare function featureOf(file: string): string | null;
33
+ export declare function codeFindings(change: Change): Finding[];
34
+ export declare function readmeStates(feature: string): string[];
35
+ export declare function jevFindings(change: Change, feature: string, ask?: typeof judge): Promise<Finding[]>;
36
+ export declare function findings(change: Change): Promise<Finding[]>;
37
+ type HookInput = {
38
+ tool_input?: {
39
+ file_path?: string;
40
+ content?: string;
41
+ old_string?: string;
42
+ new_string?: string;
43
+ edits?: {
44
+ old_string: string;
45
+ new_string: string;
46
+ }[];
47
+ };
48
+ };
49
+ /** The file's real path, through any symlink (/tmp on macOS, a linked checkout), so it compares with ROOT. */
50
+ export declare function realFile(path: string): string;
51
+ export declare function changeFromHook(input: HookInput): Change | null;
52
+ /** The hook's stdout: nothing, a warning as context, or a block Claude must answer. */
53
+ export declare function hookOutput(found: Finding[]): Record<string, unknown> | null;
54
+ export declare function guardHook(): Promise<void>;
55
+ export declare function main({ positionals }: Args<typeof spec>): Promise<{
56
+ findings: Finding[];
57
+ }>;
58
+ export {};
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // The fidelity guard. A Claude Code PostToolUse hook (`design-hub hook
4
+ // fidelity-guard`, the hook JSON on stdin) over each file written under
5
+ // prototypes/ or kit/, and `design-hub guard <file>` by hand over a file's
6
+ // whole content.
7
+ //
8
+ // Code checks always run: a write outside a feature folder, a new
9
+ // dependency, an import that crosses prototypes. With TYPESAFE_API_KEY in
10
+ // .env, four Nouls follow on the change (rules.ts), in one request. A
11
+ // finding at or over the project's `fidelity.warnAt` goes back to Claude as
12
+ // context; at or over `blockAt`, or a crossing import, it goes back as a
13
+ // block, which makes Claude answer it before going on. Anything countable
14
+ // stays in code; Jev answers only the semantic part.
15
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
16
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
17
+ import { reaches } from '../core/checks.js';
18
+ import { SET_NAMES } from '../core/fixtures.js';
19
+ import { noulOf } from '../core/jev.js';
20
+ import { parseStates } from '../core/states.js';
21
+ import { checks } from '../server/checks.js';
22
+ import { KIT, PROTOTYPES, ROOT } from '../server/paths.js';
23
+ import { judge } from './lib/judge.js';
24
+ import { NOULS, RULE } from './lib/rules.js';
25
+ export const spec = {
26
+ name: 'guard',
27
+ summary: 'Run the fidelity guard over whole files, as if each had just been written.',
28
+ positionals: [
29
+ { name: 'file', description: 'A file under prototypes/ or kit/', required: true, variadic: true },
30
+ ],
31
+ flags: {},
32
+ output: '{ ok, findings: [{ file, rule, level, note, p?, id? }] }',
33
+ };
34
+ const MAX_CHANGE_CHARS = 12_000;
35
+ const inside = (file, dir) => file === dir || file.startsWith(dir + sep);
36
+ /** The feature folder a file belongs to, or null for anything outside prototypes/<slug>/. */
37
+ export function featureOf(file) {
38
+ if (!inside(file, PROTOTYPES))
39
+ return null;
40
+ const rel = relative(PROTOTYPES, file);
41
+ const top = rel.split(sep)[0];
42
+ if (!top || top.startsWith('_') || top.startsWith('.') || !rel.includes(sep))
43
+ return null;
44
+ return top;
45
+ }
46
+ const DEPENDENCY_LINE = /^\s*"[^"]+":\s*"[\^~>=<\d]/gm;
47
+ export function codeFindings(change) {
48
+ const file = relative(ROOT, change.file);
49
+ if (basename(change.file) === 'package.json' && dirname(change.file) === ROOT) {
50
+ const removed = new Set(change.removed.match(DEPENDENCY_LINE) ?? []);
51
+ const fresh = (change.added.match(DEPENDENCY_LINE) ?? [])
52
+ .filter((l) => !removed.has(l))
53
+ .map((l) => l.trim().split('"')[1]);
54
+ return fresh.length
55
+ ? [
56
+ {
57
+ file,
58
+ rule: RULE.dependency,
59
+ level: 'warn',
60
+ note: `adds ${fresh.join(', ')}; confirm the user agreed`,
61
+ },
62
+ ]
63
+ : [];
64
+ }
65
+ const feature = featureOf(change.file);
66
+ if (!feature) {
67
+ if (inside(change.file, KIT))
68
+ return [
69
+ {
70
+ file,
71
+ rule: RULE.fork,
72
+ level: 'warn',
73
+ note: 'a kit change serves every prototype; copy into the feature folder unless this is a second use',
74
+ },
75
+ ];
76
+ return [];
77
+ }
78
+ const out = [];
79
+ const imports = [...change.added.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1]);
80
+ const crossing = imports.filter((s) => {
81
+ if (!s.startsWith('.'))
82
+ return false;
83
+ const target = resolve(dirname(change.file), s);
84
+ return inside(target, PROTOTYPES) && !inside(target, join(PROTOTYPES, feature));
85
+ });
86
+ if (crossing.length)
87
+ out.push({
88
+ file,
89
+ rule: RULE.gravity,
90
+ level: 'block',
91
+ note: `imports ${crossing.join(', ')}; a prototype imports only @kit/* and its own folder`,
92
+ });
93
+ return out;
94
+ }
95
+ const clip = (text) => text.length > MAX_CHANGE_CHARS ? `${text.slice(0, MAX_CHANGE_CHARS)}\n…[clipped]` : text;
96
+ export function readmeStates(feature) {
97
+ const file = join(PROTOTYPES, feature, 'README.md');
98
+ return existsSync(file) ? (parseStates(readFileSync(file, 'utf8')) ?? []).map((s) => s.name) : [];
99
+ }
100
+ export async function jevFindings(change, feature, ask = judge) {
101
+ if (!checks || !/\.(tsx?|jsx?)$/.test(change.file) || /\.test\.tsx?$/.test(change.file))
102
+ return [];
103
+ const file = relative(ROOT, change.file);
104
+ const state = {
105
+ file,
106
+ readmeStates: readmeStates(feature),
107
+ fixtureSets: SET_NAMES,
108
+ change: change.whole
109
+ ? { content: clip(change.added) }
110
+ : { removed: clip(change.removed), added: clip(change.added) },
111
+ };
112
+ const rules = Object.keys(NOULS);
113
+ const judged = await ask({
114
+ check: 'fidelity',
115
+ subject: file,
116
+ state,
117
+ questions: Object.fromEntries(rules.map((r) => [r, { type: 'noul', instructions: NOULS[r] }])),
118
+ excerpt: clip(change.added).slice(0, 1500),
119
+ });
120
+ if (!judged)
121
+ return [];
122
+ const { warnAt, blockAt } = checks.fidelity;
123
+ return rules.flatMap((r) => {
124
+ const p = noulOf(judged.answers, r);
125
+ if (p === null || (!reaches(p, warnAt) && !reaches(p, blockAt)))
126
+ return [];
127
+ return [
128
+ {
129
+ file,
130
+ rule: RULE[r],
131
+ level: reaches(p, blockAt) ? 'block' : 'warn',
132
+ note: NOULS[r],
133
+ p,
134
+ id: judged.ids[r],
135
+ },
136
+ ];
137
+ });
138
+ }
139
+ export async function findings(change) {
140
+ if (!inside(change.file, ROOT) || /\/(node_modules|dist)\//.test(change.file))
141
+ return [];
142
+ const feature = featureOf(change.file);
143
+ return [...codeFindings(change), ...(feature ? await jevFindings(change, feature) : [])];
144
+ }
145
+ /** The file's real path, through any symlink (/tmp on macOS, a linked checkout), so it compares with ROOT. */
146
+ export function realFile(path) {
147
+ const abs = resolve(path);
148
+ try {
149
+ return realpathSync(abs);
150
+ }
151
+ catch {
152
+ return join(realFile(dirname(abs)), basename(abs));
153
+ }
154
+ }
155
+ export function changeFromHook(input) {
156
+ const t = input.tool_input;
157
+ if (!t?.file_path)
158
+ return null;
159
+ const file = realFile(t.file_path);
160
+ if (t.edits)
161
+ return {
162
+ file,
163
+ added: t.edits.map((e) => e.new_string).join('\n'),
164
+ removed: t.edits.map((e) => e.old_string).join('\n'),
165
+ whole: false,
166
+ };
167
+ if (t.old_string !== undefined || t.new_string !== undefined)
168
+ return { file, added: t.new_string ?? '', removed: t.old_string ?? '', whole: false };
169
+ return { file, added: t.content ?? '', removed: '', whole: true };
170
+ }
171
+ const line = (f) => `${f.file}: ${f.rule}${f.p !== undefined ? ` (p=${f.p.toFixed(2)}, log ${f.id})` : ''}. ${f.note}`;
172
+ /** The hook's stdout: nothing, a warning as context, or a block Claude must answer. */
173
+ export function hookOutput(found) {
174
+ if (!found.length)
175
+ return null;
176
+ const blocking = found.some((f) => f.level === 'block');
177
+ const text = `Fidelity guard: ${found.map(line).join(' | ')}. Hold the fidelity ceiling in miles-prototype, or say why this one earns it.`;
178
+ const context = { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } };
179
+ return blocking ? { decision: 'block', reason: text, ...context } : context;
180
+ }
181
+ export async function guardHook() {
182
+ let input;
183
+ try {
184
+ input = JSON.parse(readFileSync(0, 'utf8'));
185
+ }
186
+ catch {
187
+ return;
188
+ }
189
+ const change = changeFromHook(input);
190
+ const output = change ? hookOutput(await findings(change)) : null;
191
+ if (output)
192
+ console.log(JSON.stringify(output));
193
+ }
194
+ export async function main({ positionals }) {
195
+ const all = [];
196
+ for (const path of positionals) {
197
+ const file = realFile(path);
198
+ all.push(...(await findings({ file, added: readFileSync(file, 'utf8'), removed: '', whole: true })));
199
+ }
200
+ return { findings: all };
201
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /** PostToolUse on a Markdown file: ask for the writing pass before the change is reported done. */
3
+ export declare function mdWriting(input: {
4
+ tool_input?: {
5
+ file_path?: string;
6
+ };
7
+ tool_response?: {
8
+ filePath?: string;
9
+ };
10
+ }): {
11
+ systemMessage: string;
12
+ hookSpecificOutput: {
13
+ hookEventName: string;
14
+ additionalContext: string;
15
+ };
16
+ } | null;
17
+ export declare const HOOKS: readonly ['fidelity-guard', 'md-writing', 'skill-hint'];
18
+ export declare function runHook(name: string): Promise<void>;