@atelier-ui/create-workspace 0.2.41 → 0.2.43

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 (39) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/package.json +1 -1
  3. package/src/generators/preset/files/contracts/README.md +29 -0
  4. package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
  5. package/src/generators/preset/files/contracts/types.ts.template +55 -0
  6. package/src/generators/preset/files/figma/snapshot.json +164 -0
  7. package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
  8. package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
  9. package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
  10. package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
  11. package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
  12. package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
  13. package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
  14. package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
  15. package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
  16. package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
  17. package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
  18. package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
  19. package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
  20. package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
  21. package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
  22. package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
  23. package/src/generators/preset/files/styles/tokens.css +59 -43
  24. package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
  25. package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
  26. package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
  27. package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
  28. package/src/generators/preset/files/tools/scripts/preflight.mjs +220 -30
  29. package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
  30. package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
  31. package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
  32. package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
  33. package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
  34. package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
  35. package/src/generators/preset/preset.d.ts +1 -0
  36. package/src/generators/preset/preset.js +955 -5
  37. package/src/generators/preset/preset.js.map +1 -1
  38. package/src/generators/preset/schema.d.ts +1 -0
  39. package/src/generators/preset/schema.json +5 -0
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { execSync, spawnSync } from 'node:child_process';
15
15
  import { createServer } from 'node:net';
16
- import { readFileSync, existsSync, statSync } from 'node:fs';
16
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
17
17
  import { homedir } from 'node:os';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { dirname, join, resolve } from 'node:path';
@@ -23,7 +23,8 @@ const __dirname = dirname(__filename);
23
23
  const ROOT = resolve(__dirname, '../..');
24
24
 
25
25
  // ── Styling ──────────────────────────────────────────────────────────
26
- const supportsColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
26
+ const supportsColor =
27
+ process.stdout.isTTY && process.env.NO_COLOR === undefined;
27
28
  const c = (code, s) => (supportsColor ? `\x1b[${code}m${s}\x1b[0m` : s);
28
29
  const red = (s) => c('31', s);
29
30
  const green = (s) => c('32', s);
@@ -54,7 +55,8 @@ const warn = (label, detail, fix) => record('warn', label, detail, fix);
54
55
  function which(cmd) {
55
56
  const finder = process.platform === 'win32' ? 'where' : 'which';
56
57
  const res = spawnSync(finder, [cmd], { encoding: 'utf8' });
57
- if (res.status === 0 && res.stdout.trim()) return res.stdout.trim().split('\n')[0];
58
+ if (res.status === 0 && res.stdout.trim())
59
+ return res.stdout.trim().split('\n')[0];
58
60
  return null;
59
61
  }
60
62
 
@@ -101,7 +103,10 @@ async function mcpPost(url, body, sessionId, timeoutMs = 10000) {
101
103
  sessionId: res.headers.get('mcp-session-id') ?? undefined,
102
104
  };
103
105
  } catch (err) {
104
- return { error: err?.name === 'AbortError' ? 'timeout' : String(err?.message ?? err) };
106
+ return {
107
+ error:
108
+ err?.name === 'AbortError' ? 'timeout' : String(err?.message ?? err),
109
+ };
105
110
  } finally {
106
111
  clearTimeout(timer);
107
112
  }
@@ -141,16 +146,28 @@ async function probeMcp(url) {
141
146
  if (init.error) return { level: 'unreachable', detail: init.error };
142
147
  const session = init.sessionId;
143
148
  if (session) {
144
- await mcpPost(url, { jsonrpc: '2.0', method: 'notifications/initialized' }, session, 5000);
149
+ await mcpPost(
150
+ url,
151
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
152
+ session,
153
+ 5000,
154
+ );
145
155
  }
146
156
 
147
157
  // 2. tools/list — must be HTTP 200 with a parseable JSON-RPC tools array.
148
- const list = await mcpPost(url, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, session);
149
- if (list.error) return { level: 'broken', detail: `tools/list failed (${list.error})` };
150
- if (list.status !== 200) return { level: 'broken', detail: `tools/list HTTP ${list.status}` };
158
+ const list = await mcpPost(
159
+ url,
160
+ { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
161
+ session,
162
+ );
163
+ if (list.error)
164
+ return { level: 'broken', detail: `tools/list failed (${list.error})` };
165
+ if (list.status !== 200)
166
+ return { level: 'broken', detail: `tools/list → HTTP ${list.status}` };
151
167
  const listMsg = parseJsonRpc(list.contentType, list.text, 2);
152
168
  const tools = listMsg?.result?.tools;
153
- if (!Array.isArray(tools)) return { level: 'broken', detail: 'tools/list → no parseable result' };
169
+ if (!Array.isArray(tools))
170
+ return { level: 'broken', detail: 'tools/list → no parseable result' };
154
171
 
155
172
  // 3. On an Atelier Storybook MCP, exercise one real tool call: manifests
156
173
  // are only fetched inside tool calls, and a broken manifest fetch comes
@@ -166,14 +183,21 @@ async function probeMcp(url) {
166
183
  },
167
184
  session,
168
185
  );
169
- if (call.error) return { level: 'broken', detail: `docs-list failed (${call.error})` };
170
- if (call.status !== 200) return { level: 'broken', detail: `docs-list HTTP ${call.status}` };
186
+ if (call.error)
187
+ return { level: 'broken', detail: `docs-list failed (${call.error})` };
188
+ if (call.status !== 200)
189
+ return { level: 'broken', detail: `docs-list → HTTP ${call.status}` };
171
190
  const callMsg = parseJsonRpc(call.contentType, call.text, 3);
172
191
  if (!callMsg?.result || callMsg.result.isError) {
173
192
  const reason =
174
- callMsg?.result?.content?.[0]?.text ?? callMsg?.error?.message ?? 'no parseable result';
193
+ callMsg?.result?.content?.[0]?.text ??
194
+ callMsg?.error?.message ??
195
+ 'no parseable result';
175
196
  const oneLine = String(reason).replace(/\s+/g, ' ').trim();
176
- return { level: 'broken', detail: `docs-list → ${oneLine.slice(0, 120)}` };
197
+ return {
198
+ level: 'broken',
199
+ detail: `docs-list → ${oneLine.slice(0, 120)}`,
200
+ };
177
201
  }
178
202
  }
179
203
 
@@ -205,6 +229,31 @@ function loadMcpEndpoints() {
205
229
  }
206
230
  }
207
231
 
232
+ /**
233
+ * Reads the exact `figma-console-mcp@<version>` pin off the `figma-console`
234
+ * server's `args` in `.mcp.json` — the same file `loadMcpEndpoints()` reads,
235
+ * just a different server shape (`command: npx`, not `type: http`). Returns
236
+ * null when `.mcp.json` is missing, has no `figma-console` entry, or that
237
+ * entry isn't pinned to an exact version (e.g. `@latest`) — every caller
238
+ * treats null as "nothing to compare against," not as a failure.
239
+ */
240
+ function getPinnedFigmaConsoleVersion() {
241
+ const mcpPath = resolve(ROOT, '.mcp.json');
242
+ if (!existsSync(mcpPath)) return null;
243
+ try {
244
+ const config = JSON.parse(readFileSync(mcpPath, 'utf8'));
245
+ const args = config.mcpServers?.['figma-console']?.args;
246
+ if (!Array.isArray(args)) return null;
247
+ for (const arg of args) {
248
+ const match = /^figma-console-mcp@(\d+\.\d+\.\d+)$/.exec(arg);
249
+ if (match) return match[1];
250
+ }
251
+ return null;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
208
257
  // ── Checks ───────────────────────────────────────────────────────────
209
258
  function checkNode() {
210
259
  const required = { major: 22, minor: 12, patch: 0 };
@@ -224,7 +273,11 @@ function checkNpm() {
224
273
  const required = { major: 10, minor: 0, patch: 0 };
225
274
  const path = which('npm');
226
275
  if (!path) {
227
- fail('npm', 'not found on PATH', 'npm ships with Node.js — reinstall Node if missing');
276
+ fail(
277
+ 'npm',
278
+ 'not found on PATH',
279
+ 'npm ships with Node.js — reinstall Node if missing',
280
+ );
228
281
  return;
229
282
  }
230
283
  try {
@@ -233,7 +286,11 @@ function checkNpm() {
233
286
  if (isAtLeast(actual, required)) {
234
287
  ok('npm', `v${version}`);
235
288
  } else {
236
- warn('npm', `found v${version}, recommend >= 10.0.0`, 'Run `npm install -g npm@latest`');
289
+ warn(
290
+ 'npm',
291
+ `found v${version}, recommend >= 10.0.0`,
292
+ 'Run `npm install -g npm@latest`',
293
+ );
237
294
  }
238
295
  } catch (err) {
239
296
  fail('npm', String(err?.message ?? err), 'Reinstall Node.js');
@@ -243,14 +300,22 @@ function checkNpm() {
243
300
  function checkGit() {
244
301
  const path = which('git');
245
302
  if (!path) {
246
- fail('git', 'not found on PATH', 'Install from https://git-scm.com or `brew install git`');
303
+ fail(
304
+ 'git',
305
+ 'not found on PATH',
306
+ 'Install from https://git-scm.com or `brew install git`',
307
+ );
247
308
  return;
248
309
  }
249
310
  try {
250
311
  const version = execSync('git --version', { encoding: 'utf8' }).trim();
251
312
  ok('git', version);
252
313
  } catch {
253
- warn('git', 'installed but version check failed', 'Verify install with `git --version`');
314
+ warn(
315
+ 'git',
316
+ 'installed but version check failed',
317
+ 'Verify install with `git --version`',
318
+ );
254
319
  }
255
320
  }
256
321
 
@@ -265,36 +330,96 @@ function checkClaudeCli() {
265
330
  return;
266
331
  }
267
332
  try {
268
- const version = execSync('claude --version', { encoding: 'utf8', timeout: 5000 }).trim();
333
+ const version = execSync('claude --version', {
334
+ encoding: 'utf8',
335
+ timeout: 5000,
336
+ }).trim();
269
337
  ok('Claude Code CLI', version);
270
338
  } catch {
271
- warn('Claude Code CLI', 'installed but `claude --version` failed', 'Try `claude doctor`');
339
+ warn(
340
+ 'Claude Code CLI',
341
+ 'installed but `claude --version` failed',
342
+ 'Try `claude doctor`',
343
+ );
272
344
  }
273
345
  }
274
346
 
275
347
  async function checkFigmaSetup() {
276
348
  // 1. Desktop Bridge plugin manifest — the primary channel.
277
- const manifestPath = join(homedir(), '.figma-console-mcp', 'plugin', 'manifest.json');
349
+ const manifestPath = join(
350
+ homedir(),
351
+ '.figma-console-mcp',
352
+ 'plugin',
353
+ 'manifest.json',
354
+ );
355
+ const pinnedVersion = getPinnedFigmaConsoleVersion();
356
+ const pinnedSpec = pinnedVersion
357
+ ? `figma-console-mcp@${pinnedVersion}`
358
+ : 'figma-console-mcp@latest';
278
359
  if (existsSync(manifestPath)) {
279
360
  ok('Figma Desktop Bridge plugin', `manifest at ${manifestPath}`);
280
361
  } else {
281
362
  warn(
282
363
  'Figma Desktop Bridge plugin',
283
364
  'manifest not found',
284
- 'Run `npx -y figma-console-mcp@latest --help` once, then Figma → Plugins → Development → Import plugin from manifest…',
365
+ `Run \`npx -y ${pinnedSpec} --help\` once, then Figma → Plugins → Development → Import plugin from manifest…`,
285
366
  );
286
367
  }
287
368
 
369
+ // 1b. .version marker vs. the exact version .mcp.json pins. The package
370
+ // writes this file (setupStablePluginDir(), dist/local.js) specifically so
371
+ // staleness between "what the npm package installed on disk" and "what
372
+ // this workspace is pinned to" can be detected — nothing read it back
373
+ // until now. This is a warning, not a hard failure: a stale on-disk build
374
+ // does not block the workshop (the Bridge still connects and runs), and
375
+ // the files self-heal on the next `figma-console-mcp` server start anyway
376
+ // — treating it as fatal would fail preflight over something that fixes
377
+ // itself the moment Claude Code (re)starts the MCP.
378
+ if (existsSync(manifestPath)) {
379
+ const versionPath = join(
380
+ homedir(),
381
+ '.figma-console-mcp',
382
+ 'plugin',
383
+ '.version',
384
+ );
385
+ if (!existsSync(versionPath)) {
386
+ warn(
387
+ 'Plugin files .version marker',
388
+ 'manifest exists but no .version file next to it',
389
+ `Run \`npx -y ${pinnedSpec} --help\` once to refresh ~/.figma-console-mcp/plugin/ with a current build`,
390
+ );
391
+ } else if (pinnedVersion) {
392
+ const diskVersion = readFileSync(versionPath, 'utf8').trim();
393
+ if (diskVersion === pinnedVersion) {
394
+ ok('Plugin files vs. .mcp.json pin', `both ${diskVersion}`);
395
+ } else {
396
+ warn(
397
+ 'Plugin files vs. .mcp.json pin',
398
+ `on-disk .version is ${diskVersion}, .mcp.json pins ${pinnedVersion}`,
399
+ `Run \`npx -y ${pinnedSpec} --help\` once to refresh the files, then re-run (or re-import) the plugin in Figma Desktop — see https://atelier.pieper.io/runbook#plugin-update`,
400
+ );
401
+ }
402
+ }
403
+ }
404
+
288
405
  // 2. Bridge WebSocket port range — at least one port must be usable.
289
- const bridgePorts = [9223, 9224, 9225, 9226, 9227, 9228, 9229, 9230, 9231, 9232];
406
+ const bridgePorts = [
407
+ 9223, 9224, 9225, 9226, 9227, 9228, 9229, 9230, 9231, 9232,
408
+ ];
290
409
  let freeCount = 0;
291
410
  for (const p of bridgePorts) {
292
411
  if (await portFree(p)) freeCount += 1;
293
412
  }
294
413
  if (freeCount === bridgePorts.length) {
295
- ok('Bridge port range 9223–9232', 'all free (MCP will bind on first launch)');
414
+ ok(
415
+ 'Bridge port range 9223–9232',
416
+ 'all free (MCP will bind on first launch)',
417
+ );
296
418
  } else if (freeCount > 0) {
297
- ok('Bridge port range 9223–9232', `${freeCount}/10 free (bridge connection OK)`);
419
+ ok(
420
+ 'Bridge port range 9223–9232',
421
+ `${freeCount}/10 free (bridge connection OK)`,
422
+ );
298
423
  } else {
299
424
  warn(
300
425
  'Bridge port range 9223–9232',
@@ -403,7 +528,8 @@ function isDir(path) {
403
528
  }
404
529
 
405
530
  function detectEnvironment() {
406
- const isClone = isDir(resolve(ROOT, 'libs/spec')) && isDir(resolve(ROOT, 'plan/adr'));
531
+ const isClone =
532
+ isDir(resolve(ROOT, 'libs/spec')) && isDir(resolve(ROOT, 'plan/adr'));
407
533
  return isClone ? 'clone' : 'scaffold';
408
534
  }
409
535
 
@@ -427,6 +553,50 @@ const PORTS_BY_ENV = {
427
553
  ],
428
554
  };
429
555
 
556
+ // ── Playwright Chromium (scaffold only) ─────────────────────────────
557
+ // The scaffold's `npm run check:stories` runs every story headless in
558
+ // Chromium via @storybook/addon-vitest + @vitest/browser-playwright — a
559
+ // browser Playwright manages itself, downloaded by `npx playwright install
560
+ // chromium` (never as a postinstall hook: a workshop attendee's first
561
+ // `npm install` should not silently spend minutes downloading a browser).
562
+ // This is a best-effort, dependency-free heuristic (no `require('playwright')`
563
+ // here — this script has zero non-builtin imports and should keep working
564
+ // even before `npm install` has finished) — it looks for a `chromium-*`
565
+ // directory in Playwright's own browser cache, respecting
566
+ // `PLAYWRIGHT_BROWSERS_PATH` the same way Playwright itself does. A warning,
567
+ // not a hard failure: this only blocks `check:stories`, not the workshop.
568
+ function playwrightBrowsersDir() {
569
+ if (process.env.PLAYWRIGHT_BROWSERS_PATH)
570
+ return process.env.PLAYWRIGHT_BROWSERS_PATH;
571
+ const home = homedir();
572
+ if (process.platform === 'darwin')
573
+ return join(home, 'Library', 'Caches', 'ms-playwright');
574
+ if (process.platform === 'win32')
575
+ return join(home, 'AppData', 'Local', 'ms-playwright');
576
+ return join(home, '.cache', 'ms-playwright');
577
+ }
578
+
579
+ function checkPlaywrightChromium() {
580
+ const dir = playwrightBrowsersDir();
581
+ let hasChromium = false;
582
+ try {
583
+ hasChromium =
584
+ existsSync(dir) &&
585
+ readdirSync(dir).some((name) => name.startsWith('chromium'));
586
+ } catch {
587
+ hasChromium = false;
588
+ }
589
+ if (hasChromium) {
590
+ ok('Playwright Chromium', 'installed');
591
+ } else {
592
+ warn(
593
+ 'Playwright Chromium',
594
+ 'not found',
595
+ 'Run `npx playwright install chromium` — needed for `npm run check:stories`',
596
+ );
597
+ }
598
+ }
599
+
430
600
  async function checkPorts(env) {
431
601
  ok(
432
602
  'Environment',
@@ -437,7 +607,12 @@ async function checkPorts(env) {
437
607
  for (const { port, label } of PORTS_BY_ENV[env]) {
438
608
  const free = await portFree(port);
439
609
  if (free) ok(`Port ${port} (${label})`, 'free');
440
- else warn(`Port ${port} (${label})`, 'in use', `Run \`lsof -ti :${port} | xargs kill\` (macOS/Linux)`);
610
+ else
611
+ warn(
612
+ `Port ${port} (${label})`,
613
+ 'in use',
614
+ `Run \`lsof -ti :${port} | xargs kill\` (macOS/Linux)`,
615
+ );
441
616
  }
442
617
  }
443
618
 
@@ -471,6 +646,11 @@ async function main() {
471
646
  header('Local ports');
472
647
  await checkPorts(env);
473
648
 
649
+ if (env === 'scaffold') {
650
+ header('Storybook browser tests');
651
+ checkPlaywrightChromium();
652
+ }
653
+
474
654
  // Summary
475
655
  const failures = results.filter((r) => r.level === 'fail').length;
476
656
  const warnings = results.filter((r) => r.level === 'warn').length;
@@ -479,19 +659,29 @@ async function main() {
479
659
  console.log('');
480
660
  if (failures === 0) {
481
661
  console.log(
482
- green(`All hard checks passed`) + dim(` · ${passes} ok, ${warnings} warning(s)`),
662
+ green(`All hard checks passed`) +
663
+ dim(` · ${passes} ok, ${warnings} warning(s)`),
483
664
  );
484
665
  if (warnings > 0) {
485
- console.log(dim('Warnings are non-blocking — see https://atelier.pieper.io/troubleshooting'));
666
+ console.log(
667
+ dim(
668
+ 'Warnings are non-blocking — see https://atelier.pieper.io/troubleshooting',
669
+ ),
670
+ );
486
671
  }
487
672
  console.log('');
488
673
  process.exit(0);
489
674
  } else {
490
675
  console.log(
491
- red(`${failures} check(s) failed`) + dim(` · ${passes} ok, ${warnings} warning(s)`),
676
+ red(`${failures} check(s) failed`) +
677
+ dim(` · ${passes} ok, ${warnings} warning(s)`),
492
678
  );
493
679
  console.log(dim('Fix the items above, then re-run `npm run preflight`.'));
494
- console.log(dim('Full troubleshooting guide: https://atelier.pieper.io/troubleshooting'));
680
+ console.log(
681
+ dim(
682
+ 'Full troubleshooting guide: https://atelier.pieper.io/troubleshooting',
683
+ ),
684
+ );
495
685
  console.log('');
496
686
  process.exit(1);
497
687
  }
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Local stylelint plugin for this repo's own CSS-discipline invariants —
5
+ * the stylelint counterpart of `tools/eslint-rules/`. A stylelint plugin
6
+ * module may export a single rule definition or an array of them (stylelint
7
+ * flattens either shape when loading `plugins`); this file exports the
8
+ * array so `stylelint.config.mjs` only needs one entry in `plugins` to pick
9
+ * up every rule below.
10
+ *
11
+ * Each rule file calls `stylelint.createPlugin(ruleName, rule)` itself and
12
+ * is `require()`-able on its own — this index just collects them, the same
13
+ * relationship `tools/eslint-rules/index.js` has to its own rule files.
14
+ */
15
+
16
+ module.exports = [
17
+ require('./no-raw-color-literal'),
18
+ require('./no-undeclared-token'),
19
+ require('./no-primitive-token'),
20
+ require('./no-token-bypass'),
21
+ ];