@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
@@ -1,22 +1,450 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installSkills = installSkills;
3
4
  exports.presetGenerator = presetGenerator;
4
5
  const tslib_1 = require("tslib");
5
6
  const devkit_1 = require("@nx/devkit");
7
+ const node_child_process_1 = require("node:child_process");
6
8
  const node_fs_1 = require("node:fs");
7
9
  const node_path_1 = require("node:path");
8
10
  function readTemplate(relativePath) {
9
11
  return (0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, 'files', relativePath), 'utf-8');
10
12
  }
11
13
  const SITE_URL = 'https://atelier.pieper.io';
14
+ // Pinned to the exact version this monorepo runs (root package.json
15
+ // devDependencies) — the workshop needs the same Storybook every attendee's
16
+ // neighbour has, not whatever `latest` resolves to on the day of the cohort.
17
+ const STORYBOOK_VERSION = '10.6.0';
18
+ // Storybook's `framework.name` package per scaffolded app, and the exact
19
+ // devDependency set each one needs beyond the common addons below.
20
+ const STORYBOOK_FRAMEWORK_PACKAGE = {
21
+ angular: '@storybook/angular-vite',
22
+ react: '@storybook/react-vite',
23
+ vue: '@storybook/vue3-vite',
24
+ };
25
+ // Templates that contain literal TypeScript source (not JSX) are suffixed
26
+ // `.template` in files/storybook/<fw>/ so that neither tsc nor the asset-copy
27
+ // glob touches them before this generator ships: tsconfig.lib.json's
28
+ // `include: ["src/**/*.ts"]` would otherwise compile them as part of this
29
+ // package's own build, and project.json's build `assets` glob
30
+ // (`**/!(*.ts)`) explicitly excludes `.ts` files from the verbatim asset
31
+ // copy — between the two, a template literally named `main.ts` would be
32
+ // transpiled away and never reach dist/ under a name this generator's
33
+ // `readTemplate()` can find at runtime. `.tsx` files (no literal `.ts`
34
+ // suffix) hit neither rule and are stored under their real name. Verified the
35
+ // hard way once already: `contracts/types.ts` and `contracts/button.contract.ts`
36
+ // (no `.template` suffix) threw `ENOENT` from inside a packed npm tarball —
37
+ // see sync-preflight.mjs's comment on those two entries.
38
+ function storybookTemplateName(framework, base) {
39
+ if (base === 'main.ts')
40
+ return `storybook/${framework}/main.ts.template`;
41
+ // vitest.config.ts and .storybook/vitest.setup.ts are always written out
42
+ // as plain `.ts` regardless of framework — unlike main.ts/preview/the
43
+ // example story, react does NOT get a `.tsx` variant for these (neither
44
+ // file contains JSX), so both need the `.template` dodge on every framework.
45
+ if (base === 'vitest.config' || base === 'vitest.setup')
46
+ return `storybook/${framework}/${base}.ts.template`;
47
+ const ext = framework === 'react' ? 'tsx' : 'ts.template';
48
+ return `storybook/${framework}/${base}.${ext}`;
49
+ }
50
+ // The destination filename written into the scaffolded workspace — a plain,
51
+ // correct `.ts`/`.tsx` extension regardless of the source template's name.
52
+ function storybookOutputExt(framework) {
53
+ return framework === 'react' ? 'tsx' : 'ts';
54
+ }
55
+ // Splices `block` (one or more object literals, as raw source text, starting
56
+ // with a leading comma) into the flat ESLint config a framework's own
57
+ // application generator (@nx/angular, @nx/react, @nx/vue — all invoked with
58
+ // `linter: 'eslint'` below) already wrote at `path`, immediately before the
59
+ // file's closing `];`, and prepends any `imports` the block needs.
60
+ //
61
+ // This is text surgery, not an AST rewrite, and that is deliberate: the
62
+ // generated file is Nx's, not ours, and a future Nx version reshaping it
63
+ // should not silently swallow this addition the way editing its AST in place
64
+ // might. `@nx/eslint`'s own flat-config codegen (confirmed by actually
65
+ // running the three application generators against an in-memory Tree —
66
+ // verified for this Nx version, not assumed) always emits a single
67
+ // `export default [ ... ];` as the file's last statement, so anchoring on the
68
+ // final `];` is stable across the Nx versions this generator supports.
69
+ // `formatFiles(tree)` at the end of presetGenerator reformats whatever this
70
+ // produces, so exact indentation here doesn't matter.
71
+ //
72
+ // Throws loudly if the file is missing or doesn't have that shape, rather
73
+ // than silently skipping the addition — same reasoning as the storybook
74
+ // targets' `updateJson` calls below: a workshop app whose ESLint config
75
+ // silently didn't get the framework's accessibility rule is worse than a
76
+ // generator that stops.
77
+ function appendToFlatEslintConfig(tree, path, block, imports = []) {
78
+ const content = tree.read(path, 'utf-8');
79
+ if (content === null) {
80
+ throw new Error(`Cannot find ${path} — expected the framework application generator to have already written a flat ESLint config there (this generator always passes linter: 'eslint').`);
81
+ }
82
+ const closeIndex = content.lastIndexOf('];');
83
+ if (closeIndex === -1) {
84
+ throw new Error(`${path} does not end with a flat-config array ("];") — the framework application generator's ESLint output may have changed shape; appendToFlatEslintConfig needs updating.`);
85
+ }
86
+ const importPrefix = imports.length ? `${imports.join('\n')}\n` : '';
87
+ tree.write(path, importPrefix +
88
+ content.slice(0, closeIndex) +
89
+ block +
90
+ content.slice(closeIndex));
91
+ }
92
+ // Ported CSS-discipline stylelint rules (ADR-0130): assembles the scaffold's
93
+ // own stylelint.config.mjs — one override block per selected framework's own
94
+ // `workshop-<fw>/src` tree, mirroring this repo's own stylelint.config.mjs
95
+ // (which does the same per libs/{angular,react,vue}). Built as a template
96
+ // string here rather than a static files/ template, because both the block
97
+ // count and the app-relative paths it names depend on which frameworks were
98
+ // selected — the same reason CLAUDE.md/README.md below are assembled from
99
+ // `frameworks`, not copied verbatim.
100
+ //
101
+ // Only three of the four shipped rules are wired: `atelier/no-primitive-token`
102
+ // polices reaching past the semantic token tier into a primitive ramp (e.g.
103
+ // `--ui-color-teal-500`), which presupposes knowing that ramp exists — this
104
+ // workspace's own tokens.css has no such tiering to police. The file still
105
+ // ships (see the loop that writes tools/stylelint-rules/* below) because
106
+ // index.js requires all four rule files unconditionally, and a
107
+ // byte-identical index.js (kept in sync with the canonical copy by
108
+ // sync-preflight.mjs) is worth more than a scaffold-specific fork that drops
109
+ // one require().
110
+ //
111
+ // Neither wired rule is given `componentRoot` or `allowlistsFile`: this
112
+ // workspace ships no allowlists.js, so every exemption map defaults to empty
113
+ // and the staleness scan those two options drive never runs (documented in
114
+ // each rule's own header in tools/stylelint-rules/) — passing them here would
115
+ // configure a scan that can never find anything.
116
+ function buildStylelintConfig(frameworks) {
117
+ const overrides = frameworks
118
+ .map((framework) => {
119
+ const appName = `workshop-${framework}`;
120
+ const tokensCss = `${appName}/src/styles/tokens.css`;
121
+ return ` {
122
+ files: ['${appName}/src/**/*.css'],
123
+ rules: {
124
+ 'atelier/no-raw-color-literal': true,
125
+ 'atelier/no-undeclared-token': [true, { tokenFiles: ['${tokensCss}'] }],
126
+ 'atelier/no-token-bypass': [true, { tokenFile: '${tokensCss}' }],
127
+ },
128
+ },`;
129
+ })
130
+ .join('\n');
131
+ return `// This workspace's own CSS-discipline rules only ('tools/stylelint-rules/')
132
+ // — ported from the parent Atelier monorepo (ADR-0130). No
133
+ // stylelint-config-standard or any other base config: stylelint 16+ ships no
134
+ // built-in formatting/stylistic rules at all (split out to the separate,
135
+ // opt-in @stylistic plugin, which this workspace does not install), so there
136
+ // is nothing here that could fight Prettier.
137
+ //
138
+ // Three of the four shipped rules are wired below. atelier/no-primitive-token
139
+ // is NOT — it polices reaching past the semantic token tier into a primitive
140
+ // ramp, which presupposes knowing that ramp exists, and this workspace's own
141
+ // tokens.css has no such tiering. The three below catch what an attendee does
142
+ // by writing ordinary component CSS on day one: a raw color literal, a
143
+ // typo'd or undeclared --ui-* token, and a literal that duplicates a token's
144
+ // value instead of binding to it.
145
+ //
146
+ // tokens.css itself is EXCLUDED from every framework's stylelint target (via
147
+ // --ignore-pattern in the app's project.json, not an exemption here): it is
148
+ // the one file that legitimately spells out raw color/dimension literals as
149
+ // token DEFINITIONS — the opposite of what these rules police in a file that
150
+ // CONSUMES tokens.
151
+ import atelier from './tools/stylelint-rules/index.js';
152
+
153
+ export default {
154
+ plugins: [atelier],
155
+ rules: {},
156
+ overrides: [
157
+ ${overrides}
158
+ ],
159
+ };
160
+ `;
161
+ }
162
+ // The four storybookjs/mcp skills this generator installs post-scaffold (S2).
163
+ // Pinned the same way ADR-0110 pins figma-console-mcp: a skill install is a
164
+ // remote pull of skill *text*, and an un-pinned CLI could silently change what
165
+ // gets written into an attendee's .claude/skills/ between one workshop and the
166
+ // next.
167
+ const SKILLS_CLI_VERSION = '1.5.25';
168
+ const SKILLS_ADD_ARGV = [
169
+ '-y',
170
+ `skills@${SKILLS_CLI_VERSION}`,
171
+ 'add',
172
+ 'storybookjs/mcp',
173
+ '--skill',
174
+ '*',
175
+ '--agent',
176
+ 'claude-code',
177
+ '--yes',
178
+ '--copy',
179
+ ];
180
+ // Kept in sync with SKILLS_ADD_ARGV by hand — this is the string shown to a
181
+ // human (the failure warning, CLAUDE.md, README.md), quoted the way a real
182
+ // shell needs it. It is unrelated to how SKILLS_ADD_ARGV itself reaches the
183
+ // child process below: on POSIX it goes straight to `spawn` as an argv array
184
+ // with no shell involved (so nothing there needs quoting at all), and on
185
+ // Windows it is re-quoted for cmd.exe by `quoteForCmdExe` — this constant
186
+ // exists only for the text a person reads and re-types by hand.
187
+ const SKILLS_ADD_COMMAND_FOR_HUMANS = `npx -y skills@${SKILLS_CLI_VERSION} add storybookjs/mcp --skill "*" --agent claude-code --yes --copy`;
188
+ // Bounds the CLI's own git clone (it reads this env var itself) so a bad
189
+ // conference network fails fast with a clear message instead of hanging.
190
+ const SKILLS_CLONE_TIMEOUT_MS = 60000;
191
+ // The contract loop (ADR-0121 S4): figma-snapshot-contracts.mjs imports the
192
+ // MCP SDK directly (the same client figma-snapshot.mjs uses), pinned to the
193
+ // exact version this monorepo runs (root package.json devDependencies) so
194
+ // the scaffold's copy behaves the same as the canonical script.
195
+ const MCP_SDK_VERSION = '^1.29.0';
196
+ // ts-eval.js (shared by check-contracts.mjs and figma-snapshot-contracts.mjs)
197
+ // needs `typescript` at runtime. Every framework's Nx application generator
198
+ // already adds it, so this is a safety net, not the primary source — see the
199
+ // conditional add near the end of presetGenerator, which only includes this
200
+ // constant in the devDependencies write when `typescript` isn't already
201
+ // present.
202
+ const TYPESCRIPT_VERSION = '6.0.3';
203
+ // Ported CSS-discipline stylelint rules (ADR-0130): pinned the same way
204
+ // STORYBOOK_VERSION above is — the exact version this monorepo actually
205
+ // runs, not the caret range root package.json declares (`^17.15.0`); the
206
+ // resolved, installed version (package-lock.json) is 17.15.0, and that's
207
+ // the one every attendee's neighbour should get too. No `postcss-html`
208
+ // devDependency: that's only needed to lint `.astro` files, and the
209
+ // scaffold has none.
210
+ const STYLELINT_VERSION = '17.15.0';
211
+ // Formatting enforcement (measured 2026-09-12 against a real workspace
212
+ // generated through this preset via a local verdaccio, per
213
+ // tasks/todo.md — not assumed from Nx's docs): a freshly `create-nx-workspace`'d
214
+ // tree declares no `prettier` devDependency and writes no `.prettierrc` — this
215
+ // monorepo's own ADR-0127 baseline ("configured and never enforced") does not
216
+ // even get as far as "configured" here, it has to be added from scratch. Root
217
+ // package.json's own declaration (`~3.9.6`) is already a tight tilde, not the
218
+ // wide caret range some of this file's other *_VERSION constants have to
219
+ // narrow — package-lock.json resolves it to the same 3.9.6, so there's no
220
+ // "declared vs. actually resolved" gap to reconcile.
221
+ const PRETTIER_VERSION = '~3.9.6';
222
+ // Browser-mode Storybook tests (owner correction, 2026-09-10, to ADR-0123's
223
+ // "no test runner" decision — see the dated correction on that record).
224
+ // Every scaffolded app gets its own vitest.config.ts + storybook-test target,
225
+ // mirroring libs/{angular,react,vue}/vitest.storybook.config.ts. Versions are
226
+ // copied verbatim from the monorepo's own root package.json — most are exact
227
+ // pins there; `vitest` and `@vitest/browser-playwright` are themselves caret
228
+ // ranges in root package.json, so they stay caret ranges here too, rather
229
+ // than inventing an exact pin root itself doesn't have.
230
+ const VITEST_VERSION = '^4.0.8';
231
+ const VITEST_BROWSER_PLAYWRIGHT_VERSION = '^4.1.0';
232
+ // @vitest/browser-playwright declares a PEER (not transitive) dependency on
233
+ // `playwright` itself (peerDependencies: { playwright: "*" }). Root
234
+ // package.json has no bare `playwright` entry — only `@playwright/test`
235
+ // (^1.36.0), whose own dependency on `playwright` satisfies the peer there
236
+ // via monorepo-wide hoisting. A standalone scaffold gets no such hoist, so
237
+ // the bare peer is pinned here directly, at the same range @playwright/test
238
+ // itself uses — there is no "exact version root package.json has" for the
239
+ // bare package to copy, since root never names it.
240
+ const PLAYWRIGHT_VERSION = '^1.36.0';
241
+ const VITE_PLUGIN_REACT_VERSION = '6.1.1';
242
+ const VITE_PLUGIN_VUE_VERSION = '^6.0.5';
243
+ const ANALOGJS_VITE_PLUGIN_ANGULAR_VERSION = '2.7.1';
244
+ // Only Vue's vitest.setup.ts.template imports this (custom jest-dom
245
+ // matchers), mirroring libs/vue/.storybook/vitest.setup.ts exactly — React
246
+ // and Angular's setup files don't use it.
247
+ const TESTING_LIBRARY_JEST_DOM_VERSION = '^6.9.1';
248
+ // The Vue eslint.config.mjs addition above (see the appendToFlatEslintConfig
249
+ // call in the vue branch) imports this directly. @nx/eslint's own root config
250
+ // setup already adds it unconditionally regardless of framework or of
251
+ // whether `prettier` itself is installed (confirmed by running all three
252
+ // application generators against an empty Tree), so the conditional add near
253
+ // the end of presetGenerator below is a safety net, not the primary source —
254
+ // same reasoning as TYPESCRIPT_VERSION above. Version matches what that run
255
+ // installed.
256
+ const ESLINT_CONFIG_PRETTIER_VERSION = '^10.0.0';
257
+ // Hard kill for the whole child process — a backstop above the CLI's own
258
+ // clone timeout, covering the install/copy phase after the clone too.
259
+ const SKILLS_INSTALL_TIMEOUT_MS = 120000;
260
+ // `execFile` (and its promisified form) never actually spawns a shell — it
261
+ // forwards only a fixed allowlist of options to the underlying `spawn` call,
262
+ // and `stdio` is not on that list. Measured: `promisify(execFile)(...,
263
+ // { stdio: 'inherit' })` still resolves with the child's stdout captured in
264
+ // `result.stdout`, and nothing is written to this process's own stdout in
265
+ // the meantime — the attendee would see a silent multi-minute pause (the
266
+ // CLI's own clone can take that long over a bad conference network) and then
267
+ // either a success or failure with no progress in between. `spawn` (used
268
+ // below) genuinely honours `stdio: 'inherit'`, wiring the child's streams to
269
+ // this process's own so the CLI's progress reaches the terminal live.
270
+ //
271
+ // `process.platform` is read live inside `runSkillsAddCommand` (rather than
272
+ // cached in a module-level constant) so a test can stub it per-case without
273
+ // having to reset and re-import the module.
274
+ // `spawn`'s `shell: true` mode does not escape an `args` array for you — it
275
+ // just space-joins `[command, ...args]` before handing the result to the
276
+ // shell (this is exactly what Node's DEP0190 deprecation warns about:
277
+ // "Passing args to a child process with shell option true ... arguments are
278
+ // not escaped, only concatenated"). So this is not a general-purpose Windows
279
+ // command-line escaper — every token in SKILLS_ADD_ARGV is a fixed literal
280
+ // (a package specifier, a flag name, or the bare `*`) with no embedded quotes
281
+ // of its own, and wrapping a token in quotes whenever it contains whitespace
282
+ // or a metacharacter is sufficient for that fixed, known argv. `*`/`?` are
283
+ // included even though cmd.exe itself does not glob-expand them (unlike a
284
+ // POSIX shell, wildcard expansion on Windows is the invoked program's own
285
+ // job, not the shell's) — quoting them anyway is the belt-and-suspenders
286
+ // match for the POSIX side's guarantee, so `--skill "*"` reaches npx as a
287
+ // literal one-character string on both platforms by construction, not by
288
+ // relying on cmd.exe's particular behaviour.
289
+ function quoteForCmdExe(token) {
290
+ return /[\s"^&|<>()*?]/.test(token)
291
+ ? `"${token.replace(/"/g, '\\"')}"`
292
+ : token;
293
+ }
294
+ // Runs `npx <SKILLS_ADD_ARGV...>` with the child's stdio wired to this
295
+ // process's own, resolving on a clean exit and rejecting with a descriptive
296
+ // error otherwise (non-zero exit, the install timeout killing the child, or
297
+ // the child never starting at all) — installSkills' catch block below turns
298
+ // any of those into the same non-fatal warning.
299
+ function runSkillsAddCommand(cwd, env) {
300
+ return new Promise((resolve, reject) => {
301
+ const isWindows = process.platform === 'win32';
302
+ let settled = false;
303
+ // Set only by the Windows timeout backstop below — the `exit` handler
304
+ // needs it because a `taskkill /F` termination is not guaranteed to show
305
+ // up as a `signal` on the `exit` event the way POSIX's SIGTERM does.
306
+ let timedOut = false;
307
+ const settle = (fn) => {
308
+ if (settled)
309
+ return;
310
+ settled = true;
311
+ fn();
312
+ };
313
+ const spawnOptions = Object.assign({ cwd,
314
+ env, stdio: 'inherit' }, (isWindows
315
+ ? {}
316
+ : { timeout: SKILLS_INSTALL_TIMEOUT_MS, killSignal: 'SIGTERM' }));
317
+ const child = isWindows
318
+ ? // Windows can only execute a `.cmd` file (npx resolves to npx.cmd
319
+ // there) through cmd.exe, and Node no longer shells out to run one
320
+ // implicitly — a hardening in the Node 18.20 / 20.12 lines now
321
+ // requires the caller to opt in with `shell: true`, or the spawn
322
+ // never starts at all. That is exactly the platform `--copy` above
323
+ // was chosen for, so this path has to work. `shell: true` (rather
324
+ // than hardcoding the `npx.cmd` binary name) is enough on its own:
325
+ // once cmd.exe is in the loop it resolves the bare `npx` via its own
326
+ // PATHEXT lookup, the same as typing `npx` at a Windows prompt.
327
+ // Because `shell: true` does not escape an args array (see
328
+ // quoteForCmdExe above), the whole command line is built and quoted
329
+ // by hand instead of passing SKILLS_ADD_ARGV as a separate array.
330
+ (0, node_child_process_1.spawn)(['npx', ...SKILLS_ADD_ARGV].map(quoteForCmdExe).join(' '), Object.assign(Object.assign({}, spawnOptions), { shell: true }))
331
+ : // POSIX: no shell is spawned at all, so nothing is in a position to
332
+ // glob-expand the literal `*` in `--skill *` — `npx` is exec'd
333
+ // directly with SKILLS_ADD_ARGV as its argv.
334
+ (0, node_child_process_1.spawn)('npx', SKILLS_ADD_ARGV, spawnOptions);
335
+ // Windows-only backstop for SKILLS_INSTALL_TIMEOUT_MS. `spawn`'s own
336
+ // `timeout` + `killSignal` (used above for POSIX) only terminates the
337
+ // process `child` actually refers to — under `shell: true` that is
338
+ // cmd.exe, not `npx`. cmd.exe does not propagate termination to the
339
+ // `npx`/npm/git descendants it launched, so relying on the same option
340
+ // here would kill the shell while the clone it kicked off kept running
341
+ // in the background — orphaned, and still writing into the scaffolded
342
+ // workspace after this function had already rejected. `taskkill /T`
343
+ // kills the whole process tree rooted at cmd.exe's pid instead of just
344
+ // cmd.exe itself.
345
+ //
346
+ // UNVERIFIED ON WINDOWS: reasoned through from documented cmd.exe/
347
+ // taskkill behaviour, but there is no Windows machine or CI runner
348
+ // available in this environment to actually exercise it. Verify on a
349
+ // real Windows box before relying on it.
350
+ let windowsTimeoutHandle;
351
+ if (isWindows) {
352
+ windowsTimeoutHandle = setTimeout(() => {
353
+ if (settled)
354
+ return;
355
+ timedOut = true;
356
+ if (typeof child.pid === 'number') {
357
+ // `taskkill` is a stock Windows binary, so this should never itself
358
+ // fail to spawn — but an unhandled `error` event on a ChildProcess
359
+ // throws, and there is nothing more useful to do here than swallow
360
+ // it: the `exit` handler above has already been told (`timedOut`)
361
+ // and will reject with the real, readable timeout message either way.
362
+ (0, node_child_process_1.spawn)('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
363
+ stdio: 'ignore',
364
+ }).on('error', () => undefined);
365
+ }
366
+ }, SKILLS_INSTALL_TIMEOUT_MS);
367
+ }
368
+ child.on('error', (error) => {
369
+ // The child never started (e.g. `npx` not found on PATH).
370
+ clearTimeout(windowsTimeoutHandle);
371
+ settle(() => reject(error));
372
+ });
373
+ child.on('exit', (code, signal) => {
374
+ clearTimeout(windowsTimeoutHandle);
375
+ if (signal || timedOut) {
376
+ settle(() => reject(new Error(`npx was killed${signal ? ` by ${signal}` : ''} before finishing — most likely the ` +
377
+ `${SKILLS_INSTALL_TIMEOUT_MS}ms install timeout on a slow or unreachable network`)));
378
+ return;
379
+ }
380
+ if (code !== 0) {
381
+ settle(() => reject(new Error(`npx exited with code ${code}`)));
382
+ return;
383
+ }
384
+ settle(resolve);
385
+ });
386
+ });
387
+ }
388
+ // Exported (rather than inlined into the returned post-generator task) so it
389
+ // can be exercised directly in tests without also invoking the real
390
+ // `npm install` that `installTask`/`removePresetTask` run when called.
391
+ function installSkills(tree, skillsEnabled) {
392
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
393
+ if (!skillsEnabled)
394
+ return;
395
+ console.log(`\n◇ Installing storybookjs/mcp skills (stories, storybook-init, storybook-setup, storybook-upgrade)…`);
396
+ try {
397
+ // Runs in the scaffolded workspace's real root: by the time the
398
+ // post-generator task calls this, Nx has already flushed the virtual Tree
399
+ // to disk (that's what lets installTask run a real `npm install`).
400
+ // `--copy` (not the CLI's default symlink farm) because an attendee on
401
+ // Windows without developer mode cannot create symlinks. `--agent
402
+ // claude-code` (not `--all`) so the scaffold doesn't also grow
403
+ // `.cursor/`, `.codex/`, etc.
404
+ const env = Object.assign(Object.assign({}, process.env), { DO_NOT_TRACK: '1', SKILLS_CLONE_TIMEOUT_MS: String(SKILLS_CLONE_TIMEOUT_MS) });
405
+ yield runSkillsAddCommand(tree.root, env);
406
+ }
407
+ catch (error) {
408
+ // Non-fatal by design: a scaffold that dies on this last step because a
409
+ // conference network can't reach GitHub is a ruined workshop morning; a
410
+ // workspace merely missing its skills is a nuisance the attendee (or
411
+ // facilitator) can fix with the command below.
412
+ console.warn(`\n⚠ Could not install the storybookjs/mcp skills automatically (${error instanceof Error ? error.message : String(error)}).`);
413
+ console.warn(` Run this by hand once you have network access:\n`);
414
+ console.warn(` ${SKILLS_ADD_COMMAND_FOR_HUMANS}\n`);
415
+ }
416
+ });
417
+ }
12
418
  function presetGenerator(tree, options) {
13
419
  return tslib_1.__awaiter(this, void 0, void 0, function* () {
14
- var _a, _b;
420
+ var _a, _b, _c, _d, _e;
15
421
  const frameworks = ((_a = options.frameworks) !== null && _a !== void 0 ? _a : 'angular')
16
422
  .split(',')
17
423
  .map((f) => f.trim())
18
424
  .filter(Boolean);
425
+ const skillsEnabled = (_b = options.skills) !== null && _b !== void 0 ? _b : true;
19
426
  const deps = {};
427
+ // Every scaffolded workspace gets a local Storybook — not behind a flag
428
+ // (2026-09-10 owner decision, tasks/todo.md "Storybook + the storybookjs/mcp
429
+ // skills in the scaffolded workspace"). It exists so the storybookjs/mcp
430
+ // skills installed below have a Storybook ≥ 10.5 with `@storybook/addon-mcp`
431
+ // to talk to; without it, the `stories` skill's own first move on any UI task
432
+ // would be to propose installing one.
433
+ const storybookDevDeps = {
434
+ storybook: STORYBOOK_VERSION,
435
+ '@storybook/addon-mcp': STORYBOOK_VERSION,
436
+ '@storybook/addon-a11y': STORYBOOK_VERSION,
437
+ '@storybook/addon-docs': STORYBOOK_VERSION,
438
+ // Browser-mode Storybook tests (owner correction 2026-09-10 to ADR-0123) —
439
+ // shared regardless of how many frameworks are selected; the
440
+ // framework-specific Vite plugin is added conditionally, after the
441
+ // frameworks loop below, once every app generator has had a chance to
442
+ // bring its own in.
443
+ '@storybook/addon-vitest': STORYBOOK_VERSION,
444
+ vitest: VITEST_VERSION,
445
+ '@vitest/browser-playwright': VITEST_BROWSER_PLAYWRIGHT_VERSION,
446
+ playwright: PLAYWRIGHT_VERSION,
447
+ };
20
448
  for (const framework of frameworks) {
21
449
  const appName = `workshop-${framework}`;
22
450
  if (framework === 'angular') {
@@ -36,7 +464,7 @@ function presetGenerator(tree, options) {
36
464
  // ever ran. Optional peers are not auto-installed, so the first thing that
37
465
  // installs it is the line below, at the version that matches.
38
466
  yield (0, devkit_1.ensurePackage)('@nx/angular', devkit_1.NX_VERSION);
39
- const { applicationGenerator: angularAppGenerator } = require('@nx/angular/generators');
467
+ const { applicationGenerator: angularAppGenerator, } = require('@nx/angular/generators');
40
468
  yield angularAppGenerator(tree, {
41
469
  name: appName,
42
470
  directory: appName,
@@ -47,8 +475,39 @@ function presetGenerator(tree, options) {
47
475
  skipTests: true,
48
476
  e2eTestRunner: 'none',
49
477
  skipFormat: true,
478
+ // Without this, the generator's own linter choice defaults to
479
+ // `normalizeLinterOption`'s non-interactive fallback: follow an
480
+ // eslint setup already detected in the tree, or `'none'` if there is
481
+ // none yet. A single-framework Angular scaffold (the common,
482
+ // documented case — ADR-0084) starts from a genuinely empty tree, so
483
+ // that fallback silently produced ZERO eslint.config.mjs and no
484
+ // eslint devDependency at all — verified by running the real
485
+ // generator against an empty workspace with this line omitted.
486
+ // React and Vue below already pass this explicitly; Angular didn't,
487
+ // which was the actual defect, not merely a posture gap.
488
+ linter: 'eslint',
50
489
  });
51
490
  deps['@atelier-ui/angular'] = 'latest';
491
+ // `flat/angular-template` above (written into workshop-angular's own
492
+ // eslint.config.mjs by the application generator, confirmed by a real
493
+ // run) already carries angular-eslint's `templateAccessibility` preset
494
+ // — see libs/angular/eslint.config.mjs's comment for the 11 rules that
495
+ // covers. This one isn't part of that preset (not `:accessibility:`
496
+ // tagged in angular-eslint's README): WCAG 2.4.3, positive tabindex
497
+ // fights natural DOM tab order. Same addition libs/angular's own config
498
+ // makes on top of the identical preset.
499
+ appendToFlatEslintConfig(tree, `${appName}/eslint.config.mjs`, `,
500
+ {
501
+ // Not covered by \`flat/angular-template\` above (angular-eslint's own
502
+ // \`templateAccessibility\` preset only) — WCAG 2.4.3, positive tabindex
503
+ // fights natural DOM tab order. Mirrors libs/angular/eslint.config.mjs's
504
+ // identical addition on top of the same preset.
505
+ files: ['**/*.html'],
506
+ rules: {
507
+ '@angular-eslint/template/no-positive-tabindex': 'error',
508
+ },
509
+ },
510
+ `);
52
511
  }
53
512
  if (framework === 'react') {
54
513
  console.log(`\n◇ Generating React workshop app…`);
@@ -66,6 +525,11 @@ function presetGenerator(tree, options) {
66
525
  skipFormat: true,
67
526
  });
68
527
  deps['@atelier-ui/react'] = 'latest';
528
+ // Deliberately nothing added here. `flat/react` above (written into
529
+ // workshop-react's own eslint.config.mjs by the application generator,
530
+ // confirmed by a real run) already carries jsx-a11y's full 18-rule
531
+ // active set — the same rule set libs/react/eslint.config.mjs relies on
532
+ // without adding anything of its own either.
69
533
  }
70
534
  if (framework === 'vue') {
71
535
  console.log(`\n◇ Generating Vue workshop app…`);
@@ -82,6 +546,59 @@ function presetGenerator(tree, options) {
82
546
  skipFormat: true,
83
547
  });
84
548
  deps['@atelier-ui/vue'] = 'latest';
549
+ // @nx/vue's own application generator (confirmed by a real run against
550
+ // an in-memory Tree, packed from the exact pinned version — @nx/vue is
551
+ // an optional peer, not installed in this workspace) DOES match `.vue`
552
+ // files and wire vue-eslint-parser + typescript-eslint's parser for
553
+ // them via `parserOptions.parser` — the gap this generator's own
554
+ // libs/vue/eslint.config.mjs once had (`.vue` files matched by no
555
+ // config at all) does not exist in the scaffold. What IS missing,
556
+ // mirrored below from libs/vue/eslint.config.mjs:
557
+ //
558
+ // 1. `eslint-config-prettier` after eslint-plugin-vue's rules, scoped
559
+ // to `.vue` — without it, eslint-plugin-vue's layout/whitespace
560
+ // rules (max-attributes-per-line, html-self-closing, …) fight
561
+ // Prettier. eslint-config-prettier is already an unconditional
562
+ // devDependency here (added by @nx/eslint's own root config setup,
563
+ // confirmed by the same run), independent of whether `prettier`
564
+ // itself is installed — the defensive add near the end of
565
+ // presetGenerator below is a safety net for that, not the primary
566
+ // source.
567
+ // 2. `vue/no-unused-properties` — not part of any eslint-plugin-vue
568
+ // preset, opted in explicitly.
569
+ //
570
+ // Deliberately NOT mirrored: libs/vue/eslint.config.mjs also re-scopes
571
+ // eslint-plugin-vue's `flat/recommended` essential/strongly-recommended
572
+ // /recommended blocks to `**/*.vue` (verified here too: those blocks
573
+ // carry no `files` restriction in the installed eslint-plugin-vue, so
574
+ // @nx/vue's own `...vue.configs['flat/recommended']` spread — which
575
+ // this generator does not rewrite, only append to — applies Vue-only
576
+ // rules to every .ts/.js file in the app as well). Doing the same here
577
+ // would mean rewriting a block the application generator wrote, not
578
+ // appending to it, which is the one thing this helper is built to
579
+ // avoid; the practical exposure in a fresh single-app scaffold is low
580
+ // (no .spec.ts/.stories.ts test-double files exist yet at this point in
581
+ // the generator, and @nx/vue already turns off the rule most likely to
582
+ // misfire on plain .ts — vue/multi-word-component-names — project-wide
583
+ // on its own). Recorded here rather than silently left unmentioned.
584
+ appendToFlatEslintConfig(tree, `${appName}/eslint.config.mjs`, `,
585
+ {
586
+ // eslint-plugin-vue's flat/recommended (spread above by @nx/vue's own
587
+ // application generator) ships layout/whitespace rules that fight
588
+ // Prettier — same gap libs/vue/eslint.config.mjs closes.
589
+ files: ['**/*.vue'],
590
+ rules: eslintConfigPrettier.rules,
591
+ },
592
+ {
593
+ // Not part of any eslint-plugin-vue preset — opted in explicitly, same as
594
+ // libs/vue/eslint.config.mjs: catches a declared prop nothing in the
595
+ // component reads.
596
+ files: ['**/*.vue'],
597
+ rules: {
598
+ 'vue/no-unused-properties': ['error', { groups: ['props'] }],
599
+ },
600
+ },
601
+ `, [`import eslintConfigPrettier from 'eslint-config-prettier';`]);
85
602
  }
86
603
  // Copy design tokens into the scaffolded app so attendees can edit them
87
604
  // directly. They're not imported from the @atelier-ui/<fw> npm package
@@ -90,9 +607,192 @@ function presetGenerator(tree, options) {
90
607
  tree.write(`${appName}/src/styles/tokens.css`, readTemplate('styles/tokens.css'));
91
608
  // Prepend the tokens import to the app's global stylesheet
92
609
  const stylesPath = `${appName}/src/styles.css`;
93
- const existing = tree.exists(stylesPath) ? ((_b = tree.read(stylesPath, 'utf-8')) !== null && _b !== void 0 ? _b : '') : '';
610
+ const existing = tree.exists(stylesPath)
611
+ ? ((_c = tree.read(stylesPath, 'utf-8')) !== null && _c !== void 0 ? _c : '')
612
+ : '';
94
613
  tree.write(stylesPath, `@import './styles/tokens.css';\n\n${existing}`);
614
+ // Storybook config: mirrors libs/{angular,react,vue}/.storybook/main.ts
615
+ // minus staticDirs, the BUILD_STORYBOOK viteFinal block (hosted-path-only),
616
+ // and @storybook/addon-designs (no Figma handoff doc to link from here).
617
+ // @storybook/addon-vitest DOES ship (owner correction 2026-09-10 to
618
+ // ADR-0123's original "no test runner" call).
619
+ tree.write(`${appName}/.storybook/main.ts`, readTemplate(storybookTemplateName(framework, 'main.ts')));
620
+ tree.write(`${appName}/.storybook/preview.${storybookOutputExt(framework)}`, readTemplate(storybookTemplateName(framework, 'preview')));
621
+ if (framework === 'angular') {
622
+ // The only framework that needs its own Storybook-scoped tsconfig — see
623
+ // libs/angular/.storybook/tsconfig.json, which this mirrors against the
624
+ // tsconfig.json the Angular application generator actually writes (both
625
+ // shapes verified: `files: []`, `include: []`, `references` to app/spec).
626
+ tree.write(`${appName}/.storybook/tsconfig.json`, readTemplate('storybook/angular/tsconfig.json'));
627
+ }
628
+ // One example story per app: small on purpose — it exists so Storybook
629
+ // isn't empty and the attendee has a working pattern to copy, not as a
630
+ // second component showcase.
631
+ tree.write(`${appName}/src/atl-button.stories.${storybookOutputExt(framework)}`, readTemplate(storybookTemplateName(framework, 'atl-button.stories')));
632
+ // Browser-mode Storybook tests (owner correction 2026-09-10 to ADR-0123 —
633
+ // storybook-test / check:stories ship with the scaffold after all).
634
+ // `vitest.config.ts` is always plain `.ts`, never `.tsx` — see
635
+ // storybookTemplateName()'s comment. The literal filename matters:
636
+ // @storybook/addon-vitest's `test-run` tool walks up from a story's
637
+ // .storybook directory looking for the nearest vitest/vite config by
638
+ // this standard name.
639
+ tree.write(`${appName}/vitest.config.ts`, readTemplate(storybookTemplateName(framework, 'vitest.config')));
640
+ tree.write(`${appName}/.storybook/vitest.setup.ts`, readTemplate(storybookTemplateName(framework, 'vitest.setup')));
641
+ // The application generator (@nx/{angular,react,vue}:application, above)
642
+ // guarantees `${appName}/project.json` exists at this point — updateJson
643
+ // reads it first and throws `Cannot find ${path}` if it doesn't, which is
644
+ // exactly the loud failure we want: a workshop app with a Storybook config
645
+ // directory but no way to start it is worse than a generator that stops.
646
+ const storybookPort = 6006 + frameworks.indexOf(framework);
647
+ (0, devkit_1.updateJson)(tree, `${appName}/project.json`, (config) => {
648
+ var _a;
649
+ (_a = config.targets) !== null && _a !== void 0 ? _a : (config.targets = {});
650
+ config.targets['storybook'] = {
651
+ executor: 'nx:run-commands',
652
+ options: {
653
+ command: `npx storybook dev --config-dir ${appName}/.storybook --port ${storybookPort}`,
654
+ },
655
+ };
656
+ config.targets['build-storybook'] = {
657
+ executor: 'nx:run-commands',
658
+ outputs: [`{workspaceRoot}/dist/storybook/${appName}`],
659
+ options: {
660
+ command: `npx storybook build --config-dir ${appName}/.storybook --output-dir dist/storybook/${appName}`,
661
+ },
662
+ };
663
+ // Mirrors libs/{angular,react,vue}/project.json's own "storybook-test"
664
+ // target exactly, modulo the config filename (vitest.config.ts here,
665
+ // vitest.storybook.config.ts there — see storybookTemplateName()'s
666
+ // comment on why the scaffold uses the standard name instead).
667
+ config.targets['storybook-test'] = {
668
+ executor: 'nx:run-commands',
669
+ options: {
670
+ command: 'npx vitest run --config vitest.config.ts',
671
+ cwd: appName,
672
+ },
673
+ };
674
+ // Ported CSS-discipline rules (ADR-0130): a sibling target, not folded
675
+ // into `lint` — same reasoning as this repo's own libs/{fw}/project.json
676
+ // (lint is inferred by @nx/eslint/plugin per project; overriding it to
677
+ // also shell out would re-implement what inference gives for free).
678
+ // --ignore-pattern excludes this app's own tokens.css from the run: it
679
+ // is a token DEFINITION file, the one place these rules' raw literals
680
+ // are supposed to live, not a file that reads/consumes tokens.
681
+ config.targets['stylelint'] = {
682
+ executor: 'nx:run-commands',
683
+ options: {
684
+ command: `stylelint '${appName}/src/**/*.css' --ignore-pattern '${appName}/src/styles/tokens.css' --config stylelint.config.mjs`,
685
+ },
686
+ };
687
+ return config;
688
+ });
689
+ storybookDevDeps[STORYBOOK_FRAMEWORK_PACKAGE[framework]] =
690
+ STORYBOOK_VERSION;
691
+ // @storybook/react-vite and @storybook/vue3-vite each carry their
692
+ // non-vite renderer counterpart as a plain (non-peer) `dependency`, not
693
+ // something npm/pnpm is told the app needs directly — but the story and
694
+ // preview templates import straight from '@storybook/react' /
695
+ // '@storybook/vue3' for the `Meta`/`StoryObj`/`Preview` types. That
696
+ // resolves today only because npm hoists it; pnpm's stricter, non-hoisted
697
+ // layout would leave the import unresolved. Angular has no matching case:
698
+ // '@storybook/angular-vite' has no such counterpart to hoist, and the
699
+ // angular templates import their types from '@storybook/angular-vite'
700
+ // itself (see files/storybook/angular/*.template) — adding
701
+ // '@storybook/angular' back here would reintroduce exactly the ERESOLVE
702
+ // this generator now avoids (its peer on @angular-devkit/build-angular is
703
+ // not satisfiable by a freshly scaffolded Angular 22 app).
704
+ if (framework === 'react') {
705
+ storybookDevDeps['@storybook/react'] = STORYBOOK_VERSION;
706
+ }
707
+ if (framework === 'vue') {
708
+ storybookDevDeps['@storybook/vue3'] = STORYBOOK_VERSION;
709
+ }
710
+ }
711
+ // ─── Prettier (formatting enforcement) ───────────────────────────────────
712
+ // Measured 2026-09-12 (tasks/todo.md, "A. Formatting enforcement"): a
713
+ // fresh `create-nx-workspace` tree ships neither a `prettier`
714
+ // devDependency nor a `.prettierrc` — this is the ADR-0127 problem before
715
+ // even the "configured" half exists, so both are written here rather than
716
+ // assumed present. Content is byte-identical to this monorepo's own
717
+ // `.prettierrc` — no reason for a workshop attendee to start from a
718
+ // different formatting convention than the repo whose components they're
719
+ // consuming.
720
+ console.log(`\n◇ Wiring Prettier…`);
721
+ (0, devkit_1.writeJson)(tree, '.prettierrc', { singleQuote: true });
722
+ // ─── Stylelint (ported CSS-discipline rules, ADR-0130) ───────────────────
723
+ console.log(`\n◇ Wiring stylelint (ported CSS-discipline rules)…`);
724
+ tree.write('stylelint.config.mjs', buildStylelintConfig(frameworks));
725
+ // Six files, byte-identical to the canonical copies in tools/stylelint-rules/
726
+ // (kept that way by this repo's own sync-preflight.mjs) — see
727
+ // buildStylelintConfig's comment above for why no-primitive-token.js ships
728
+ // even though it's never wired into the config just written.
729
+ for (const ruleFile of [
730
+ 'index.js',
731
+ 'utils.js',
732
+ 'no-raw-color-literal.js',
733
+ 'no-undeclared-token.js',
734
+ 'no-primitive-token.js',
735
+ 'no-token-bypass.js',
736
+ ]) {
737
+ tree.write(`tools/stylelint-rules/${ruleFile}`, readTemplate(`tools/stylelint-rules/${ruleFile}`));
95
738
  }
739
+ // The config and the rule files above both live outside every project that
740
+ // reads them (the per-app `stylelint` targets added in the loop above) —
741
+ // exactly the cache trap ADR-0130 proved twice in this repo's own
742
+ // nx.json. Mirrors this repo's own targetDefaults.stylelint, minus
743
+ // `tools/scripts/lib/allowlists.js` (the scaffold ships no such file — see
744
+ // buildStylelintConfig's comment on why neither wired rule is given
745
+ // `allowlistsFile`). The application generators above are guaranteed to
746
+ // have already written nx.json (create-nx-workspace writes it before any
747
+ // preset runs), so `updateJson` here is safe the same way the package.json
748
+ // update below is.
749
+ (0, devkit_1.updateJson)(tree, 'nx.json', (config) => {
750
+ var _a;
751
+ (_a = config.targetDefaults) !== null && _a !== void 0 ? _a : (config.targetDefaults = {});
752
+ config.targetDefaults['stylelint'] = {
753
+ cache: true,
754
+ inputs: [
755
+ 'default',
756
+ '^default',
757
+ '{workspaceRoot}/stylelint.config.mjs',
758
+ '{workspaceRoot}/tools/stylelint-rules/**/*',
759
+ ...frameworks.map((framework) => `{workspaceRoot}/workshop-${framework}/src/styles/tokens.css`),
760
+ { externalDependencies: ['stylelint'] },
761
+ ],
762
+ };
763
+ return config;
764
+ });
765
+ // ─── The contract loop (ADR-0121 S4) ─────────────────────────────────────
766
+ // Ships once, scoped to the FIRST selected framework — a contracts.config.json
767
+ // names exactly one framework, the same "the workshop uses one framework"
768
+ // precedent frameworks[0] already sets elsewhere in this generator (the
769
+ // README's "Getting started" nx serve command).
770
+ const primaryFramework = frameworks[0];
771
+ const primaryApp = `workshop-${primaryFramework}`;
772
+ console.log(`\n◇ Writing the contract loop (check:contracts, the example AtlButton contract, the Figma snapshot projection)…`);
773
+ // .ts.template, not .ts — see the comment on storybookTemplateName() above:
774
+ // a literal `.ts` file under files/ is compiled away by this package's own
775
+ // tsconfig.lib.json (`include: ["src/**/*.ts"]`) and never reaches dist/
776
+ // under a name readTemplate() can find at runtime. The OUTPUT filenames
777
+ // below stay plain `.ts` — only the template source needs the suffix.
778
+ tree.write(`${primaryApp}/src/contracts/types.ts`, readTemplate('contracts/types.ts.template'));
779
+ tree.write(`${primaryApp}/src/contracts/README.md`, readTemplate('contracts/README.md'));
780
+ tree.write(`${primaryApp}/src/contracts/button.contract.ts`, readTemplate('contracts/button.contract.ts.template'));
781
+ tree.write('tools/scripts/check-contracts.mjs', readTemplate('tools/scripts/check-contracts.mjs'));
782
+ tree.write('tools/scripts/lib/ts-eval.js', readTemplate('tools/scripts/lib/ts-eval.js'));
783
+ tree.write('tools/scripts/lib/docgen.mjs', readTemplate('tools/scripts/lib/docgen.mjs'));
784
+ tree.write('tools/scripts/figma-snapshot-contracts.mjs', readTemplate('tools/scripts/figma-snapshot-contracts.mjs'));
785
+ // The AtlButton-only projection of this repo's own tools/figma/snapshot.json
786
+ // (gen-scaffold-snapshot.mjs) — gives check:contracts a Figma side for the
787
+ // example contract + story on day one, before the attendee ever runs
788
+ // figma:snapshot themselves.
789
+ tree.write('tools/figma/snapshot.json', readTemplate('figma/snapshot.json'));
790
+ (0, devkit_1.writeJson)(tree, 'contracts.config.json', {
791
+ framework: primaryFramework,
792
+ contracts: `${primaryApp}/src/contracts`,
793
+ stories: [`${primaryApp}/src`],
794
+ snapshot: 'tools/figma/snapshot.json',
795
+ });
96
796
  console.log(`\n◇ Writing project files (CLAUDE.md, README, .mcp.json)…`);
97
797
  // Write CLAUDE.md with framework-specific guidance
98
798
  const frameworkSections = frameworks
@@ -169,10 +869,126 @@ Key tokens:
169
869
  - Do not install other UI component libraries alongside Atelier
170
870
  - Do not add inline hex colors or hardcoded spacing values
171
871
 
872
+ ## Formatting
873
+
874
+ Prettier is configured (\`.prettierrc\`, \`{ singleQuote: true }\`): \`npm run format\`
875
+ (\`prettier --write .\`) and \`npm run check:format\` (\`prettier --check .\`). The
876
+ scaffold itself is already Prettier-clean — \`check:format\` passes right after
877
+ \`npm install\`, before you've written a line of your own.
878
+
172
879
  ## Apps
173
880
 
174
881
  ${frameworks.map((f) => `- \`workshop-${f}\` — run with \`npx nx serve workshop-${f}\``).join('\n')}
175
882
 
883
+ ## Storybook
884
+
885
+ Every app has its own local Storybook, started per framework:
886
+
887
+ ${frameworks.map((f, i) => `- \`workshop-${f}\` — \`npx nx storybook workshop-${f}\` — http://localhost:${6006 + i}`).join('\n')}
888
+
889
+ Build a static Storybook (CI, hosting) with \`npx nx build-storybook workshop-<fw>\`.
890
+
891
+ Every story is also a browser-mode test (\`@storybook/addon-vitest\`). One-time setup
892
+ after \`npm install\`:
893
+
894
+ \`\`\`bash
895
+ npx playwright install chromium
896
+ \`\`\`
897
+
898
+ Then run every app's stories headless in Chromium with axe checks:
899
+
900
+ \`\`\`bash
901
+ npm run check:stories
902
+ \`\`\`
903
+
904
+ ## Agent Skills
905
+
906
+ ${skillsEnabled
907
+ ? `The scaffold attempted to install four \`storybookjs/mcp\` skills for Claude
908
+ Code (\`.claude/skills/\`) during setup — a failed clone (offline, unreachable
909
+ registry) does not stop the workspace from being created, so this file can't
910
+ promise they actually landed. Check \`.claude/skills/\` for the four directories
911
+ below; re-run the command further down if any are missing:
912
+
913
+ - **stories** — invoke first, before creating, editing, or deleting components, stories, styles, CSS, themes, colors, or design tokens
914
+ - **storybook-init** — adding Storybook to a project that does not have it configured yet
915
+ - **storybook-setup** — Storybook is installed and you want a working \`preview\` file and stories for real components
916
+ - **storybook-upgrade** — Storybook exists but needs an upgrade
917
+
918
+ Re-run or update the install at any time:
919
+
920
+ \`\`\`bash
921
+ ${SKILLS_ADD_COMMAND_FOR_HUMANS}
922
+ \`\`\`
923
+
924
+ **Which MCP tools these skills can reach depends on \`.mcp.json\`, not just on the
925
+ skills being present.** \`.mcp.json\` wires only the *hosted* Storybook MCP servers
926
+ (\`storybook-<fw>\` → ${SITE_URL}/storybook-<fw>/mcp), which serve the \`docs\`
927
+ toolset — \`docs-list\`, \`docs-show\`, \`docs-show-story\` all work out of the box.
928
+ The skills' \`dev\`-toolset tools (\`stories-preview\`, \`get-storybook-story-instructions\`)
929
+ instead come from \`@storybook/addon-mcp\` inside a *running local* Storybook
930
+ (already configured in every app's \`.storybook/main.ts\`) — that local endpoint is
931
+ deliberately not wired into \`.mcp.json\` by default, the same as this repo's own
932
+ root \`.mcp.json\`, because a fixed \`localhost\` entry would fail to connect on
933
+ every Claude Code session started without that Storybook already running. To use
934
+ the \`dev\` toolset: start \`npx nx storybook workshop-<fw>\` (see Storybook below),
935
+ then add this to \`.mcp.json\`'s \`mcpServers\` (only while that Storybook is up):
936
+
937
+ \`\`\`json
938
+ "storybook-<fw>-local": {
939
+ "type": "http",
940
+ "url": "http://localhost:<port>/mcp"
941
+ }
942
+ \`\`\`
943
+
944
+ (\`<port>\` is the one listed for that app under Storybook below.) \`test-run\`
945
+ works too, once that local Storybook is running — every story here IS a
946
+ render + accessibility test (\`@storybook/addon-vitest\`, run offline via
947
+ \`npm run check:stories\`; see "The Contract Loop" below).`
948
+ : `Skipped for this workspace (\`skills: false\`). Install the four \`storybookjs/mcp\`
949
+ skills for Claude Code by hand:
950
+
951
+ \`\`\`bash
952
+ ${SKILLS_ADD_COMMAND_FOR_HUMANS}
953
+ \`\`\`
954
+ `}
955
+
956
+ ## The Contract Loop
957
+
958
+ A contract (\`${primaryApp}/src/contracts/<name>.contract.ts\`) is the one hand-authored
959
+ spec file per component: the Figma master's node id, plus intentional Figma ↔ code
960
+ mismatches (\`figmaOnly\`, \`codeOnly\`, \`axisMap\`) — never props, defaults, or
961
+ descriptions; those live in the component's own types/JSDoc and its stories.
962
+
963
+ Order: read the Figma handoff → write the contract → write one story per variant and
964
+ interaction state → \`npm run check:contracts\` → \`figma_check_design_parity\` in Storybook.
965
+
966
+ \`check:contracts\` joins the contract, the component's docgen, and \`tools/figma/snapshot.json\`
967
+ offline — no browser, no Storybook build — and reports one line per finding:
968
+ - \`[CONTRACT-MISSING]\` / \`[CONTRACT-NODE]\` — no contract file, or its node id disagrees
969
+ - \`[AXIS]\` / \`[BOOLEAN]\` / \`[ENUM-UNDRAWN]\` — a Figma property has no matching code prop
970
+ - \`[COVERAGE]\` / \`[COVERAGE-BOOL]\` — a variant value or boolean is never rendered by a story
971
+ - \`[FIGMA-ONLY]\` / \`[STALE-EXEMPTION]\` / \`[UNMIRRORED]\` — an exemption is missing, stale, or unexplained
972
+ - \`[NO-STORY-META]\` / \`[NO-MASTER]\` — a contract with nothing yet to check it against
973
+ - \`[CONTRACT-IMPORT]\` — a story meta doesn't import its component's contract and set
974
+ \`contract\` in \`parameters\` (a warning here, until this workspace ships a docs block
975
+ that renders it)
976
+
977
+ Refresh \`tools/figma/snapshot.json\` from the real master with the Figma Desktop Bridge
978
+ connected: edit the \`--file\` placeholder in \`package.json\`'s \`figma:snapshot\` script to
979
+ your own Figma file key, then run \`npm run figma:snapshot\`.
980
+
981
+ \`check:contracts\` proves shape and story coverage; \`npm run check:stories\` (every story,
982
+ rendered headless in Chromium via \`@storybook/addon-vitest\`, with axe) proves rendering
983
+ and accessibility. It does so for components whose source lives in this workspace. For
984
+ components imported from \`@atelier-ui/${primaryFramework}\` — the example \`AtlButton\`
985
+ included — the check recognises that the import resolves into \`node_modules\` and skips
986
+ docgen for it — the same rule in every framework — so it has nothing to compare and
987
+ reports only \`[NO-STORY-META]\`; their prop tables come from the hosted
988
+ Storybook MCP (\`docs-show\`) instead. A green \`check:contracts\` on the example story
989
+ therefore proves the wiring, not the example. Run \`npx playwright install chromium\`
990
+ once after \`npm install\` — see Storybook below.
991
+
176
992
  ## Troubleshooting
177
993
 
178
994
  Run the preflight self-check to verify your environment:
@@ -202,8 +1018,62 @@ file exports). The Desktop Bridge covers creation and inspection without a token
202
1018
  - MCP Playground (inspect tool responses): ${SITE_URL}/mcp
203
1019
  - CLAUDE.md template: ${SITE_URL}/claude-md
204
1020
  `);
205
- // Install selected @atelier-ui/* packages
206
- const installTask = (0, devkit_1.addDependenciesToPackageJson)(tree, deps, {});
1021
+ // The contract loop's own devDependencies (ADR-0121 S4): the MCP SDK
1022
+ // figma-snapshot-contracts.mjs imports directly, always; `typescript` only
1023
+ // when the framework application generator (above) didn't already add it —
1024
+ // read from the tree's package.json as it stands right now, after every
1025
+ // framework's generator has run and before this generator's own writes
1026
+ // below it.
1027
+ const pkgSoFar = (0, devkit_1.readJson)(tree, 'package.json');
1028
+ const existingDeps = Object.assign(Object.assign({}, ((_d = pkgSoFar.dependencies) !== null && _d !== void 0 ? _d : {})), ((_e = pkgSoFar.devDependencies) !== null && _e !== void 0 ? _e : {}));
1029
+ const hasTypescript = Boolean(existingDeps.typescript);
1030
+ const contractLoopDevDeps = {
1031
+ '@modelcontextprotocol/sdk': MCP_SDK_VERSION,
1032
+ };
1033
+ if (!hasTypescript) {
1034
+ contractLoopDevDeps.typescript = TYPESCRIPT_VERSION;
1035
+ }
1036
+ // Browser-mode Storybook tests (owner correction 2026-09-10 to ADR-0123):
1037
+ // each selected framework needs its own Vite plugin for the vitest browser
1038
+ // pipeline, added only when the framework's own application generator
1039
+ // (above) didn't already bring it in. React and Vue's vite-based app
1040
+ // generators typically already do; Angular's esbuild-based one never does.
1041
+ const viteFrameworkDevDeps = {};
1042
+ if (frameworks.includes('react') && !existingDeps['@vitejs/plugin-react']) {
1043
+ viteFrameworkDevDeps['@vitejs/plugin-react'] = VITE_PLUGIN_REACT_VERSION;
1044
+ }
1045
+ if (frameworks.includes('vue') && !existingDeps['@vitejs/plugin-vue']) {
1046
+ viteFrameworkDevDeps['@vitejs/plugin-vue'] = VITE_PLUGIN_VUE_VERSION;
1047
+ }
1048
+ if (frameworks.includes('angular') &&
1049
+ !existingDeps['@analogjs/vite-plugin-angular']) {
1050
+ viteFrameworkDevDeps['@analogjs/vite-plugin-angular'] =
1051
+ ANALOGJS_VITE_PLUGIN_ANGULAR_VERSION;
1052
+ }
1053
+ if (frameworks.includes('vue') &&
1054
+ !existingDeps['@testing-library/jest-dom']) {
1055
+ viteFrameworkDevDeps['@testing-library/jest-dom'] =
1056
+ TESTING_LIBRARY_JEST_DOM_VERSION;
1057
+ }
1058
+ if (frameworks.includes('vue') && !existingDeps['eslint-config-prettier']) {
1059
+ viteFrameworkDevDeps['eslint-config-prettier'] =
1060
+ ESLINT_CONFIG_PRETTIER_VERSION;
1061
+ }
1062
+ // Ported CSS-discipline rules (ADR-0130). No postcss-html: that's only
1063
+ // needed to lint .astro files, and the scaffold has none.
1064
+ const stylelintDevDeps = {
1065
+ stylelint: STYLELINT_VERSION,
1066
+ };
1067
+ // Formatting enforcement — see the "Prettier" section above for why this
1068
+ // is written at all.
1069
+ const prettierDevDeps = {
1070
+ prettier: PRETTIER_VERSION,
1071
+ };
1072
+ // Install selected @atelier-ui/* packages (dependencies) and Storybook +
1073
+ // the contract loop's own tools + the vitest browser-mode tooling +
1074
+ // stylelint + prettier (devDependencies, exact pins — see the *_VERSION
1075
+ // constants above)
1076
+ const installTask = (0, devkit_1.addDependenciesToPackageJson)(tree, deps, Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, storybookDevDeps), contractLoopDevDeps), viteFrameworkDevDeps), stylelintDevDeps), prettierDevDeps));
207
1077
  // Remove the preset package itself — create-nx-workspace adds it automatically
208
1078
  // but it's a build-time tool and should not be in the workspace's dependencies
209
1079
  const removePresetTask = (0, devkit_1.removeDependenciesFromPackageJson)(tree, ['@atelier-ui/create-workspace'], []);
@@ -217,6 +1087,50 @@ file exports). The Desktop Bridge covers creation and inspection without a token
217
1087
  var _a;
218
1088
  pkg.scripts = (_a = pkg.scripts) !== null && _a !== void 0 ? _a : {};
219
1089
  pkg.scripts.preflight = 'node tools/scripts/preflight.mjs';
1090
+ // The contract loop (ADR-0121 S4). `figma:snapshot` ships with a literal
1091
+ // placeholder rather than the Atelier file key — this preset has no
1092
+ // "which Figma file is yours" schema option (only the boolean
1093
+ // `figmaMcp`), and QMnDD8uZQPldPrlCwZZ58T is THIS repo's own file, not
1094
+ // the attendee's. CLAUDE.md's "The contract loop" section spells out how
1095
+ // to fill it in.
1096
+ pkg.scripts['check:contracts'] = 'node tools/scripts/check-contracts.mjs';
1097
+ pkg.scripts['figma:snapshot'] =
1098
+ 'node tools/scripts/figma-snapshot-contracts.mjs --file <YOUR_FIGMA_FILE_KEY>';
1099
+ // Browser-mode Storybook tests (owner correction 2026-09-10 to ADR-0123).
1100
+ // Identical to the monorepo's own root package.json script.
1101
+ pkg.scripts['check:stories'] = 'nx run-many -t storybook-test --parallel=1';
1102
+ // Ported CSS-discipline rules (ADR-0130). Identical to the monorepo's own
1103
+ // root package.json script.
1104
+ pkg.scripts['check:stylelint'] = 'nx run-many -t stylelint';
1105
+ // Formatting enforcement. Deliberately this monorepo's own
1106
+ // `prettier --write .` / `prettier --check .` shape, not `nx format:*` —
1107
+ // measured 2026-09-12 against a real generated workspace, two distinct
1108
+ // failure modes:
1109
+ // 1. Unconditional, on the pristine, untouched scaffold: with no
1110
+ // formatter resolvable in node_modules, `nx format:check` exits 0
1111
+ // printing "No formatter configured" — a silent pass before any git
1112
+ // logic even runs. Worth keeping in mind even though this preset
1113
+ // now installs prettier: the tool's own failure mode is "nothing is
1114
+ // configured, so nothing is wrong", not a loud error.
1115
+ // 2. Conditional: once prettier is installed, `nx format:check`
1116
+ // un-flagged does NOT silently pass immediately after scaffolding —
1117
+ // `git init` stages files without committing, so the `main` ref
1118
+ // doesn't resolve yet, Nx catches that specific git failure and
1119
+ // falls back to an all-files scan, correctly reporting every
1120
+ // unformatted file with exit 1 (after an alarming
1121
+ // `Command failed: ... fatal: ambiguous argument 'main'` on
1122
+ // stderr). The silent zero-file pass only appears once the
1123
+ // attendee makes their own first commit, putting `main` and `HEAD`
1124
+ // at the same revision — the steady state a workspace reaches
1125
+ // within minutes of scaffolding, and the one `nx format:check`
1126
+ // un-flagged reads as "nothing changed, nothing to check".
1127
+ // `prettier --check .` has neither failure mode: it always scans the
1128
+ // whole tree (honouring .gitignore's node_modules/dist/.nx exclusions
1129
+ // already, so no .prettierignore is needed), and if prettier were ever
1130
+ // missing it fails loudly rather than reporting a silent pass. A script
1131
+ // an attendee runs unmodified should not depend on remembering `--all`.
1132
+ pkg.scripts.format = 'prettier --write .';
1133
+ pkg.scripts['check:format'] = 'prettier --check .';
220
1134
  return pkg;
221
1135
  });
222
1136
  // Write .mcp.json with MCP servers for selected frameworks
@@ -257,9 +1171,44 @@ ${frameworks.map((f) => `- \`workshop-${f}\` — \`@atelier-ui/${f}\``).join('\n
257
1171
 
258
1172
  \`\`\`bash
259
1173
  npm install
1174
+ npx playwright install chromium # one-time — needed by npm run check:stories
260
1175
  npx nx serve workshop-${frameworks[0]}
261
1176
  \`\`\`
262
1177
 
1178
+ ## Formatting
1179
+
1180
+ Prettier is configured (\`.prettierrc\`): \`npm run format\` (\`prettier --write .\`) and
1181
+ \`npm run check:format\` (\`prettier --check .\`). The scaffold is already
1182
+ Prettier-clean out of the box — \`check:format\` passes right after \`npm install\`.
1183
+
1184
+ ## Storybook
1185
+
1186
+ ${frameworks.map((f, i) => `- \`workshop-${f}\` — \`npx nx storybook workshop-${f}\` — http://localhost:${6006 + i}`).join('\n')}
1187
+
1188
+ Every story is also a browser-mode test — run them all headless in Chromium with
1189
+ \`npm run check:stories\`.
1190
+
1191
+ ## Agent Skills
1192
+
1193
+ ${skillsEnabled
1194
+ ? `The scaffold attempted to install the four \`storybookjs/mcp\` skills (\`stories\`,
1195
+ \`storybook-init\`, \`storybook-setup\`, \`storybook-upgrade\`) for Claude Code under
1196
+ \`.claude/skills/\` — check that directory, since a failed clone doesn't stop the
1197
+ workspace from being created. Re-run or update them with:
1198
+
1199
+ \`\`\`bash
1200
+ ${SKILLS_ADD_COMMAND_FOR_HUMANS}
1201
+ \`\`\`
1202
+
1203
+ Note: \`test-run\` works once a local Storybook is running — every story here is a
1204
+ render + accessibility test (\`@storybook/addon-vitest\`, also runnable offline via
1205
+ \`npm run check:stories\`). See CLAUDE.md for details.`
1206
+ : `Skipped (\`skills: false\`). Install by hand:
1207
+
1208
+ \`\`\`bash
1209
+ ${SKILLS_ADD_COMMAND_FOR_HUMANS}
1210
+ \`\`\``}
1211
+
263
1212
  ## MCP
264
1213
 
265
1214
  Claude Code MCP servers are pre-configured in \`.mcp.json\`.
@@ -275,6 +1224,7 @@ Browse components at ${SITE_URL}
275
1224
  yield installTask();
276
1225
  console.log(`\n◇ Cleaning up preset package…`);
277
1226
  yield removePresetTask();
1227
+ yield installSkills(tree, skillsEnabled);
278
1228
  });
279
1229
  });
280
1230
  }