@codepress/design-kit 0.1.1 → 0.1.2

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.
package/README.md CHANGED
@@ -19,6 +19,47 @@ It runs on Node 20 or later. Playwright is a peer dependency, so the capture
19
19
  drives the Playwright and Chromium your project already has. The `kit`
20
20
  commands need neither.
21
21
 
22
+ ## Set up in your repository
23
+
24
+ `design-kit init` sets a kit up as one reviewable change. Run it in the
25
+ repository's root, or in a monorepo with `--root <package>` (the config goes
26
+ there; the workflow, skill and instructions line go at the repository root):
27
+
28
+ ```bash
29
+ npx @codepress/design-kit init --dry-run # what it found and would write
30
+ npx @codepress/design-kit init
31
+ ```
32
+
33
+ It looks for Storybook (a `storybook` or `@storybook/*` dependency, or a
34
+ `.storybook` folder, in the project or its workspace packages) and its
35
+ version, the framework (Next.js, Vite, Remix, Create React App, Astro or
36
+ other), the package manager and lockfile, the dev or start script and the
37
+ app's local URL, an existing Playwright, and whether the app switches to dark
38
+ with a class or a `data-theme` attribute. Then it writes:
39
+
40
+ - `design-kit.config.json`: the `stories` section when Storybook is found, a
41
+ `pages` section with `TODO` selectors to fill in, and the `theme`.
42
+ - `.github/workflows/design-kit.yml`: a daily build (and `workflow_dispatch`)
43
+ on `ubuntu-latest`. It skips when there are no commits since the newest
44
+ `design-kit` artifact's commit; otherwise it installs dependencies, this
45
+ package at a pinned version and Chromium, builds Storybook and captures the
46
+ stories, captures the pages once no `TODO` is left in the config, and
47
+ uploads the `design-kit` artifact for 30 days. A run with nothing to
48
+ capture yet (no stories, pages still `TODO`) ends with a warning and
49
+ uploads nothing. The page capture gets
50
+ `DESIGN_KIT_USERNAME` and `DESIGN_KIT_PASSWORD` from repository secrets for
51
+ a sign-in module (`pages.auth.setup`).
52
+ - `.claude/skills/design-kit/SKILL.md`: how agents fetch the newest kit,
53
+ compose mockups from it and caption them, and `.agents/skills/design-kit` as
54
+ a symlink to it (a copy when the repository keeps real folders there).
55
+ - One line in `AGENTS.md`, or `CLAUDE.md` when only that exists (a new
56
+ `AGENTS.md` when neither does), pointing at the skill.
57
+
58
+ It prints what it found and what is still to decide: the page selectors, the
59
+ start command, sign-in, a missing Storybook. It never overwrites an existing
60
+ file without `--force` (a repository with a `design-kit.config.json` exits 1,
61
+ "already set up") and never writes a secret.
62
+
22
63
  ## Configuration
23
64
 
24
65
  The commands read `design-kit.config.json` from the working directory or the
@@ -109,8 +150,9 @@ export returns (`async ({ browser, baseUrl, config }) => state`).
109
150
  for apps that render a different frame at phone width; `layoutSlots[<that
110
151
  value>]` then replaces `slots`.
111
152
  - `forbidStrings`: values that must never reach the kit, such as a test
112
- account's token. A page whose output contains one, as written, escaped for
113
- HTML or JSON, or (for a token) any long segment of it, is not written.
153
+ account's token. When any page's output contains one, as written, escaped
154
+ for HTML or JSON, or (for a token) any long segment of it, the capture
155
+ fails (exit 1) and writes no page, so a CI job uploads nothing.
114
156
  - `fontsPath`, `copyFonts`: the fonts the pages load are copied into the
115
157
  kit's `fonts/` folder (`copyFonts: false` keeps them out); `fontsPath` says
116
158
  which URL path holds them when not every font file is one.
@@ -122,6 +164,7 @@ value>]` then replaces `slots`.
122
164
  ## Commands
123
165
 
124
166
  ```bash
167
+ design-kit init [--root dir] [--dry-run] [--force]
125
168
  design-kit capture stories [--config f] [--out dir] [--storybook-dir dir]
126
169
  [--only id,... | --only-file f] [--base previous-kit]
127
170
  design-kit capture pages [--config f] [--out dir] [--base-url url] [--only name,...]
@@ -136,7 +179,8 @@ byte. The two captures share one kit folder and keep each other's files: run
136
179
  both with the same `--out`.
137
180
 
138
181
  Exit codes: 0 done, 1 a capture could not run (no Storybook build, the app
139
- did not start), 2 a config or usage error. A story or page that fails is a
182
+ did not start, a forbidden string) or `init` found the repository already set
183
+ up, 2 a config or usage error. A story or page that fails is a
140
184
  `gaps.json` row and does not fail the run.
141
185
 
142
186
  Operational knobs: `DESIGN_KIT_CONCURRENCY` (browser pages at once, default
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Capture your Storybook and real app pages as a static design kit, and compose self-contained mockups from it",
5
5
  "type": "module",
6
6
  "bin": {
@@ -376,8 +376,16 @@ function themeClassesOf(theme) {
376
376
  * browser}: set `interrupted` to stop between pages; `browser` is set once
377
377
  * launched so a signal handler can close it).
378
378
  *
379
- * Returns 0, or 130 when interrupted (nothing is written then).
379
+ * Returns 0, or 130 when interrupted (nothing is written then). Throws,
380
+ * writing nothing, when any page's markup carries a forbidStrings value.
380
381
  */
382
+ /** A page's markup carries a forbidStrings value. */
383
+ class ForbiddenStringError extends Error {
384
+ constructor() {
385
+ super("the page's markup carries a forbidStrings value");
386
+ }
387
+ }
388
+
381
389
  export async function capturePages(config, options = {}) {
382
390
  const settings = config.pages;
383
391
  if (!settings) {
@@ -442,6 +450,7 @@ export async function capturePages(config, options = {}) {
442
450
  );
443
451
  const timings = {};
444
452
  const gaps = [];
453
+ const leaks = [];
445
454
  const rows = [];
446
455
  const files = new Map();
447
456
  const fontUrls = new Map();
@@ -547,10 +556,7 @@ export async function capturePages(config, options = {}) {
547
556
  ].join("\n"),
548
557
  needles,
549
558
  );
550
- if (found)
551
- throw new Error(
552
- "the page's markup carries a forbidStrings value; not written",
553
- );
559
+ if (found) throw new ForbiddenStringError();
554
560
 
555
561
  // CSS assets on the app origin (other than fonts) are inlined.
556
562
  const inlined = new Map();
@@ -610,6 +616,11 @@ export async function capturePages(config, options = {}) {
610
616
  `captured ${id} (${capture.layout}, slots ${capture.slots.join(",")}, ${Math.round(Buffer.byteLength(html) / 1024)} KB, ${((performance.now() - pageStart) / 1000).toFixed(1)}s)`,
611
617
  );
612
618
  } catch (error) {
619
+ if (error instanceof ForbiddenStringError) {
620
+ leaks.push(id);
621
+ log(`${id}: ${error.message}`);
622
+ continue;
623
+ }
613
624
  const reason = String(error?.message ?? error)
614
625
  .split("\n")
615
626
  .slice(0, 3)
@@ -630,6 +641,12 @@ export async function capturePages(config, options = {}) {
630
641
  fs.rmSync(shotsDir, { recursive: true, force: true });
631
642
  }
632
643
 
644
+ // A leaked value fails the whole capture: no page of this run is written,
645
+ // so a CI job stops before it uploads anything.
646
+ if (leaks.length)
647
+ throw new Error(
648
+ `${leaks.join(", ")} carried a forbidStrings value; nothing was written`,
649
+ );
633
650
  writePagesKit(out, {
634
651
  rows,
635
652
  files,
package/src/cli.mjs CHANGED
@@ -13,6 +13,9 @@ const VERSION = JSON.parse(
13
13
 
14
14
  const USAGE = `usage: design-kit <command> [options]
15
15
 
16
+ init set a kit up in this repository: the config, a daily
17
+ GitHub Actions build, and a skill for its agents
18
+ [--root <dir>] [--dry-run] [--force]
16
19
  capture stories capture every story of a Storybook 7/8 static build
17
20
  [--config <file>] [--out <dir>] [--storybook-dir <dir>]
18
21
  [--only <id,...> | --only-file <file>] [--base <previous kit>]
@@ -27,7 +30,8 @@ const USAGE = `usage: design-kit <command> [options]
27
30
 
28
31
  The config is the nearest design-kit.config.json at or above the working
29
32
  directory unless --config names one. Exit codes: 0 done, 1 a capture could
30
- not run, 2 a config or usage error.`;
33
+ not run (or init found the repository already set up), 2 a config or usage
34
+ error.`;
31
35
 
32
36
  function parse(argv, spec) {
33
37
  const options = {};
@@ -153,6 +157,19 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
153
157
  return 0;
154
158
  }
155
159
  if (command === "kit") return await kitCommand(argv.slice(1), env);
160
+ if (command === "init") {
161
+ const options = parse(argv.slice(1), {
162
+ root: "value",
163
+ "dry-run": "flag",
164
+ force: "flag",
165
+ });
166
+ const { init } = await import("./init.mjs");
167
+ return init({
168
+ root: options.root ?? ".",
169
+ dryRun: Boolean(options["dry-run"]),
170
+ force: Boolean(options.force),
171
+ });
172
+ }
156
173
  if (command === "validate") {
157
174
  const options = parse(argv.slice(1), { config: "value" });
158
175
  const config = loadConfig(options.config);
package/src/init.mjs ADDED
@@ -0,0 +1,1017 @@
1
+ /**
2
+ * `design-kit init`: sets a design kit up in a repository. It looks at the
3
+ * project (Storybook, framework, package manager, dev script, Playwright,
4
+ * theme) and writes design-kit.config.json, a daily GitHub Actions workflow
5
+ * that uploads the kit, a design-kit skill for the repository's agents and
6
+ * one line in AGENTS.md or CLAUDE.md. It never overwrites a file without
7
+ * --force and never writes a secret.
8
+ */
9
+ import {
10
+ existsSync,
11
+ lstatSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ rmSync,
16
+ statSync,
17
+ symlinkSync,
18
+ writeFileSync,
19
+ cpSync,
20
+ } from "node:fs";
21
+ import { dirname, join, relative, resolve, sep } from "node:path";
22
+
23
+ import { CONFIG_NAME } from "./config.mjs";
24
+
25
+ const PACKAGE = JSON.parse(
26
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
27
+ );
28
+ const SKILL_TEMPLATE = new URL(
29
+ "./templates/design-kit-skill.md",
30
+ import.meta.url,
31
+ );
32
+
33
+ export const WORKFLOW_PATH = ".github/workflows/design-kit.yml";
34
+ export const SKILL_DIR = ".claude/skills/design-kit";
35
+ export const AGENTS_SKILL_DIR = ".agents/skills/design-kit";
36
+ const TODO = "TODO";
37
+
38
+ const IGNORED_DIRS = new Set([
39
+ "node_modules",
40
+ ".git",
41
+ "dist",
42
+ "build",
43
+ "out",
44
+ ".next",
45
+ ".nuxt",
46
+ ".svelte-kit",
47
+ ".astro",
48
+ ".turbo",
49
+ ".cache",
50
+ "coverage",
51
+ "storybook-static",
52
+ "design-kit",
53
+ "vendor",
54
+ ]);
55
+ const SCAN_LIMIT = 4000;
56
+ const SCAN_BYTES = 256 * 1024;
57
+
58
+ /** A problem `init` refuses to work past (exit 1). */
59
+ export class InitError extends Error {}
60
+
61
+ // ── reading the project ─────────────────────────────────────────────────────
62
+
63
+ function readJson(file) {
64
+ try {
65
+ return JSON.parse(readFileSync(file, "utf8"));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function readText(file) {
72
+ try {
73
+ return readFileSync(file, "utf8");
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function isDir(path) {
80
+ try {
81
+ return statSync(path).isDirectory();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function deps(pkg) {
88
+ return {
89
+ ...pkg?.peerDependencies,
90
+ ...pkg?.optionalDependencies,
91
+ ...pkg?.dependencies,
92
+ ...pkg?.devDependencies,
93
+ };
94
+ }
95
+
96
+ /** The folder holding `.git` at or above `from`, else `from` itself. */
97
+ export function findRepoRoot(from) {
98
+ let dir = resolve(from);
99
+ for (;;) {
100
+ if (existsSync(join(dir, ".git"))) return dir;
101
+ const parent = dirname(dir);
102
+ if (parent === dir) return resolve(from);
103
+ dir = parent;
104
+ }
105
+ }
106
+
107
+ /** `dir`, then each folder above it up to and including `top`. */
108
+ function upTo(dir, top) {
109
+ const dirs = [];
110
+ let current = resolve(dir);
111
+ for (;;) {
112
+ dirs.push(current);
113
+ if (current === top) break;
114
+ const parent = dirname(current);
115
+ if (parent === current || !`${current}${sep}`.startsWith(`${top}${sep}`))
116
+ break;
117
+ current = parent;
118
+ }
119
+ return dirs;
120
+ }
121
+
122
+ /** Workspace package folders declared by pnpm-workspace.yaml or package.json. */
123
+ function workspacePackages(dir) {
124
+ const patterns = [];
125
+ const pnpm = readText(join(dir, "pnpm-workspace.yaml"));
126
+ if (pnpm) {
127
+ let inPackages = false;
128
+ for (const line of pnpm.split("\n")) {
129
+ if (/^packages:\s*$/.test(line)) inPackages = true;
130
+ else if (/^\S/.test(line)) inPackages = false;
131
+ else if (inPackages) {
132
+ const match = line.match(/^\s*-\s*["']?([^"'#]+?)["']?\s*(#.*)?$/);
133
+ if (match) patterns.push(match[1].trim());
134
+ }
135
+ }
136
+ }
137
+ const pkg = readJson(join(dir, "package.json"));
138
+ const declared = Array.isArray(pkg?.workspaces)
139
+ ? pkg.workspaces
140
+ : (pkg?.workspaces?.packages ?? []);
141
+ patterns.push(...declared);
142
+
143
+ const found = [];
144
+ for (const pattern of patterns) {
145
+ if (pattern.startsWith("!")) continue;
146
+ const parts = pattern.replace(/\/+$/, "").split("/");
147
+ let dirs = [dir];
148
+ for (const part of parts) {
149
+ const next = [];
150
+ for (const base of dirs) {
151
+ if (part === "*" || part === "**") {
152
+ for (const entry of safeReaddir(base))
153
+ if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name))
154
+ next.push(join(base, entry.name));
155
+ } else if (isDir(join(base, part))) next.push(join(base, part));
156
+ }
157
+ dirs = next;
158
+ }
159
+ for (const candidate of dirs)
160
+ if (
161
+ existsSync(join(candidate, "package.json")) &&
162
+ !found.includes(candidate)
163
+ )
164
+ found.push(candidate);
165
+ }
166
+ return found.sort();
167
+ }
168
+
169
+ function safeReaddir(dir) {
170
+ try {
171
+ return readdirSync(dir, { withFileTypes: true });
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+
177
+ function installedVersion(name, from, top) {
178
+ for (const dir of upTo(from, top)) {
179
+ const pkg = readJson(join(dir, "node_modules", name, "package.json"));
180
+ if (pkg?.version) return pkg.version;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function majorOf(version) {
186
+ const match = String(version ?? "").match(/(\d+)/);
187
+ return match ? Number(match[1]) : null;
188
+ }
189
+
190
+ function storybookIn(dir, repoRoot) {
191
+ const pkg = readJson(join(dir, "package.json"));
192
+ const all = deps(pkg);
193
+ const names = Object.keys(all).filter(
194
+ (name) => name === "storybook" || name.startsWith("@storybook/"),
195
+ );
196
+ const hasConfig = isDir(join(dir, ".storybook"));
197
+ if (!names.length && !hasConfig) return null;
198
+ const versionName = names.includes("storybook")
199
+ ? "storybook"
200
+ : (names.find((name) =>
201
+ /^@storybook\/(react|vue3|svelte|angular|web-components|html|preact|nextjs|react-vite)/.test(
202
+ name,
203
+ ),
204
+ ) ?? names[0]);
205
+ const version =
206
+ (versionName && installedVersion(versionName, dir, repoRoot)) ??
207
+ (versionName ? all[versionName] : null);
208
+ return {
209
+ dir,
210
+ hasConfig,
211
+ version: version ?? null,
212
+ major: majorOf(version),
213
+ };
214
+ }
215
+
216
+ const FRAMEWORKS = [
217
+ { name: "Next.js", deps: ["next"], port: 3000, configs: ["next.config"] },
218
+ {
219
+ name: "Remix",
220
+ deps: ["@remix-run/dev", "@remix-run/react"],
221
+ port: 3000,
222
+ configs: ["vite.config", "remix.config"],
223
+ },
224
+ { name: "Astro", deps: ["astro"], port: 4321, configs: ["astro.config"] },
225
+ {
226
+ name: "Create React App",
227
+ deps: ["react-scripts"],
228
+ port: 3000,
229
+ configs: [],
230
+ },
231
+ { name: "Vite", deps: ["vite"], port: 5173, configs: ["vite.config"] },
232
+ ];
233
+
234
+ function frameworkIn(dir) {
235
+ const all = deps(readJson(join(dir, "package.json")));
236
+ return FRAMEWORKS.find((framework) =>
237
+ framework.deps.some((name) => name in all),
238
+ );
239
+ }
240
+
241
+ function scriptsIn(dir) {
242
+ return readJson(join(dir, "package.json"))?.scripts ?? {};
243
+ }
244
+
245
+ function devScriptName(scripts) {
246
+ return ["dev", "start", "serve", "preview"].find((name) =>
247
+ Boolean(scripts[name]),
248
+ );
249
+ }
250
+
251
+ const LOCKFILES = [
252
+ ["pnpm-lock.yaml", "pnpm"],
253
+ ["yarn.lock", "yarn"],
254
+ ["bun.lock", "bun"],
255
+ ["bun.lockb", "bun"],
256
+ ["package-lock.json", "npm"],
257
+ ["npm-shrinkwrap.json", "npm"],
258
+ ];
259
+
260
+ function packageManagerOf(project, repoRoot) {
261
+ let lockfile = null;
262
+ for (const dir of upTo(project, repoRoot)) {
263
+ const hit = LOCKFILES.find(([file]) => existsSync(join(dir, file)));
264
+ if (hit) {
265
+ lockfile = { file: hit[0], name: hit[1], dir };
266
+ break;
267
+ }
268
+ }
269
+ let declared = null;
270
+ for (const dir of upTo(project, repoRoot)) {
271
+ const field = readJson(join(dir, "package.json"))?.packageManager;
272
+ if (typeof field === "string") {
273
+ const match = field.match(/^(npm|pnpm|yarn|bun)@([^+\s]+)/);
274
+ if (match) {
275
+ declared = { name: match[1], version: match[2] };
276
+ break;
277
+ }
278
+ }
279
+ }
280
+ const name = declared?.name ?? lockfile?.name ?? "npm";
281
+ const installDir = lockfile?.dir ?? project;
282
+ let version = declared?.name === name ? declared.version : null;
283
+ if (!version && name === "pnpm" && lockfile) {
284
+ const lock = readText(join(lockfile.dir, lockfile.file)) ?? "";
285
+ const lockVersion = majorOf(
286
+ lock.match(/lockfileVersion:\s*'?([\d.]+)/)?.[1],
287
+ );
288
+ version = lockVersion >= 9 ? "10" : lockVersion === 6 ? "8" : null;
289
+ }
290
+ const berry =
291
+ name === "yarn" &&
292
+ (existsSync(join(installDir, ".yarnrc.yml")) || majorOf(version) >= 2);
293
+ return {
294
+ name,
295
+ version,
296
+ declared: Boolean(declared),
297
+ lockfile: lockfile?.file ?? null,
298
+ installDir,
299
+ berry,
300
+ };
301
+ }
302
+
303
+ function playwrightOf(project, repoRoot) {
304
+ for (const dir of upTo(project, repoRoot)) {
305
+ const all = deps(readJson(join(dir, "package.json")));
306
+ for (const name of ["@playwright/test", "playwright"])
307
+ if (all[name])
308
+ return {
309
+ name,
310
+ version: installedVersion(name, dir, repoRoot) ?? all[name],
311
+ dir,
312
+ };
313
+ const config = safeReaddir(dir).find((entry) =>
314
+ /^playwright\.config\.(m?[jt]s|cjs)$/.test(entry.name),
315
+ );
316
+ if (config) return { name: "@playwright/test", version: null, dir };
317
+ }
318
+ return null;
319
+ }
320
+
321
+ /** The app's local URL: a Playwright baseURL, a configured port, or the framework's default. */
322
+ function appUrlOf(project, appDir, framework, repoRoot) {
323
+ for (const dir of [...new Set([project, appDir, repoRoot])]) {
324
+ for (const entry of safeReaddir(dir)) {
325
+ if (!/^playwright\.config\.(m?[jt]s|cjs)$/.test(entry.name)) continue;
326
+ const text = readText(join(dir, entry.name)) ?? "";
327
+ const match = text.match(/baseURL:\s*["'`](https?:\/\/[^"'`\s/]+)/);
328
+ if (match)
329
+ return {
330
+ url: `${match[1]}/`,
331
+ source: relative(repoRoot, join(dir, entry.name)),
332
+ };
333
+ }
334
+ }
335
+ for (const name of framework?.configs ?? []) {
336
+ for (const entry of safeReaddir(appDir)) {
337
+ if (!entry.name.startsWith(`${name}.`)) continue;
338
+ const text = readText(join(appDir, entry.name)) ?? "";
339
+ const match = text.match(/\bport:\s*(\d{2,5})\b/);
340
+ if (match)
341
+ return {
342
+ url: `http://localhost:${match[1]}/`,
343
+ source: relative(repoRoot, join(appDir, entry.name)),
344
+ };
345
+ }
346
+ }
347
+ return {
348
+ url: `http://localhost:${framework?.port ?? 3000}/`,
349
+ source: framework ? `${framework.name}'s default port` : "a guess",
350
+ };
351
+ }
352
+
353
+ /** Class or data-theme: whichever the project's styles and scripts use more. */
354
+ function themeOf(dirs) {
355
+ const score = { class: 0, attribute: 0 };
356
+ let attributeName = "data-theme";
357
+ let darkValue = null;
358
+ let lightValue = null;
359
+ let scanned = 0;
360
+ const visit = (dir) => {
361
+ for (const entry of safeReaddir(dir)) {
362
+ if (scanned >= SCAN_LIMIT) return;
363
+ const path = join(dir, entry.name);
364
+ if (entry.isDirectory()) {
365
+ if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith("."))
366
+ visit(path);
367
+ else if (entry.name === ".storybook") visit(path);
368
+ continue;
369
+ }
370
+ if (
371
+ !/\.(css|scss|sass|less|[cm]?[jt]sx?|html|vue|svelte|astro)$/.test(
372
+ entry.name,
373
+ )
374
+ )
375
+ continue;
376
+ scanned += 1;
377
+ let text;
378
+ try {
379
+ if (statSync(path).size > SCAN_BYTES) continue;
380
+ text = readFileSync(path, "utf8");
381
+ } catch {
382
+ continue;
383
+ }
384
+ const attr = text.match(
385
+ /\[(data-(?:theme|mode|color-scheme))=["']?(dark)["']?\]|(data-(?:theme|mode|color-scheme))=["'](dark)["']|setAttribute\(\s*["'](data-(?:theme|mode|color-scheme))["']|attribute=["'](data-[a-z-]+)["']/,
386
+ );
387
+ if (attr) {
388
+ score.attribute += 2;
389
+ attributeName =
390
+ attr[1] ?? attr[3] ?? attr[5] ?? attr[6] ?? attributeName;
391
+ if (attr[2] || attr[4]) darkValue = "dark";
392
+ const light = text.match(
393
+ new RegExp(
394
+ `\\[${attributeName}=["']?(light)["']?\\]|${attributeName}=["'](light)["']`,
395
+ ),
396
+ );
397
+ if (light) lightValue = "light";
398
+ }
399
+ if (
400
+ /darkMode:\s*\[?\s*["'](class|selector)["']/.test(text) ||
401
+ /@custom-variant\s+dark\s+\(&:where\(\.dark/.test(text) ||
402
+ /classList\.(add|toggle|remove)\(\s*["']dark["']/.test(text) ||
403
+ /attribute=["']class["']/.test(text) ||
404
+ /(^|[\s,}])\.dark\s*[{,:.\s]/m.test(text)
405
+ )
406
+ score.class += 1;
407
+ }
408
+ };
409
+ for (const dir of dirs) visit(dir);
410
+ if (score.attribute > score.class)
411
+ return {
412
+ attribute: attributeName,
413
+ dark: darkValue ?? "dark",
414
+ ...(lightValue ? { light: lightValue } : {}),
415
+ evidence: `${attributeName} attribute`,
416
+ };
417
+ return {
418
+ attribute: "class",
419
+ dark: "dark",
420
+ evidence: score.class ? "a dark class" : null,
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Everything `init` decides from: the project folder (`root`, where the
426
+ * config goes) and the repository around it.
427
+ */
428
+ export function detectProject(root) {
429
+ const project = resolve(root);
430
+ if (!isDir(project)) throw new InitError(`${project}: no such folder`);
431
+ const repoRoot = findRepoRoot(project);
432
+ const workspaces = workspacePackages(project);
433
+ const packages = [project, ...workspaces];
434
+
435
+ const storybooks = packages
436
+ .map((dir) => storybookIn(dir, repoRoot))
437
+ .filter(Boolean);
438
+ const storybook = storybooks[0] ?? null;
439
+
440
+ // The app: the project itself when it is one, else the first workspace
441
+ // package with a framework and a dev script that is not only a Storybook.
442
+ let app = null;
443
+ for (const dir of packages) {
444
+ const framework = frameworkIn(dir);
445
+ const script = devScriptName(scriptsIn(dir));
446
+ const onlyStorybook =
447
+ storybookIn(dir, repoRoot) &&
448
+ (!script || /storybook/.test(scriptsIn(dir)[script]));
449
+ if (framework && script && !onlyStorybook) {
450
+ app = { dir, framework, script };
451
+ break;
452
+ }
453
+ }
454
+ const framework = app?.framework ?? frameworkIn(project) ?? null;
455
+ const appDir = app?.dir ?? project;
456
+
457
+ const pm = packageManagerOf(project, repoRoot);
458
+ const rootScript = devScriptName(scriptsIn(project));
459
+ let dev = null;
460
+ if (rootScript && !/storybook/.test(scriptsIn(project)[rootScript]))
461
+ dev = {
462
+ dir: project,
463
+ script: rootScript,
464
+ command: runScript(pm, rootScript),
465
+ };
466
+ else if (app)
467
+ dev = {
468
+ dir: app.dir,
469
+ script: app.script,
470
+ command: runWorkspaceScript(
471
+ pm,
472
+ readJson(join(app.dir, "package.json"))?.name,
473
+ app.script,
474
+ relative(project, app.dir),
475
+ ),
476
+ };
477
+
478
+ return {
479
+ project,
480
+ repoRoot,
481
+ workspaces,
482
+ storybook,
483
+ otherStorybooks: storybooks.slice(1).map((sb) => sb.dir),
484
+ framework: framework ? { name: framework.name, dir: appDir } : null,
485
+ packageManager: pm,
486
+ dev,
487
+ appUrl: appUrlOf(project, appDir, framework, repoRoot),
488
+ playwright: playwrightOf(project, repoRoot),
489
+ theme: themeOf(packages),
490
+ nodeVersion: nodeVersionOf(project, repoRoot),
491
+ };
492
+ }
493
+
494
+ function nodeVersionOf(project, repoRoot) {
495
+ for (const dir of upTo(project, repoRoot)) {
496
+ for (const file of [".nvmrc", ".node-version"])
497
+ if (existsSync(join(dir, file)))
498
+ return { file: relative(repoRoot, join(dir, file)) || file };
499
+ }
500
+ for (const dir of upTo(project, repoRoot)) {
501
+ const engines = readJson(join(dir, "package.json"))?.engines?.node;
502
+ const major = majorOf(engines);
503
+ if (major) return { version: String(Math.max(major, 20)) };
504
+ }
505
+ return { version: "22" };
506
+ }
507
+
508
+ // ── package manager commands ────────────────────────────────────────────────
509
+
510
+ function runScript(pm, script) {
511
+ return pm.name === "npm" ? `npm run ${script}` : `${pm.name} run ${script}`;
512
+ }
513
+
514
+ function runWorkspaceScript(pm, name, script, dir) {
515
+ if (!name) return `cd ${dir} && ${runScript(pm, script)}`;
516
+ if (pm.name === "pnpm") return `pnpm --filter ${name} run ${script}`;
517
+ if (pm.name === "yarn") return `yarn workspace ${name} run ${script}`;
518
+ if (pm.name === "bun") return `bun run --filter ${name} ${script}`;
519
+ return `npm run ${script} --workspace ${name}`;
520
+ }
521
+
522
+ function execBin(pm) {
523
+ if (pm.name === "pnpm") return "pnpm exec";
524
+ if (pm.name === "yarn") return pm.berry ? "yarn exec" : "npx";
525
+ if (pm.name === "bun") return "bunx";
526
+ return "npx";
527
+ }
528
+
529
+ function installCommand(pm) {
530
+ if (!pm.lockfile)
531
+ return pm.name === "npm" ? "npm install" : `${pm.name} install`;
532
+ if (pm.name === "pnpm") return "pnpm install --frozen-lockfile";
533
+ if (pm.name === "yarn")
534
+ return pm.berry
535
+ ? "yarn install --immutable"
536
+ : "yarn install --frozen-lockfile";
537
+ if (pm.name === "bun") return "bun install --frozen-lockfile";
538
+ return "npm ci";
539
+ }
540
+
541
+ // ── what init writes ────────────────────────────────────────────────────────
542
+
543
+ /** The Playwright this package is tested with, pinned in what init writes. */
544
+ function playwrightPin() {
545
+ return PACKAGE.devDependencies?.["@playwright/test"] ?? "1.58.2";
546
+ }
547
+
548
+ function posix(path) {
549
+ return path.split(sep).join("/");
550
+ }
551
+
552
+ /** design-kit.config.json, as an object. */
553
+ export function buildConfig(found) {
554
+ const config = {
555
+ $schema: `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`,
556
+ version: 1,
557
+ out: "design-kit",
558
+ theme: {
559
+ attribute: found.theme.attribute,
560
+ dark: found.theme.dark,
561
+ ...(found.theme.light ? { light: found.theme.light } : {}),
562
+ },
563
+ };
564
+ if (found.storybook) {
565
+ const root = posix(relative(found.project, found.storybook.dir)) || ".";
566
+ config.stories = {
567
+ root,
568
+ buildCommand: `${execBin(found.packageManager)} storybook build --quiet -o storybook-static`,
569
+ staticDir: "storybook-static",
570
+ ...(isDir(join(found.storybook.dir, "src"))
571
+ ? { gaps: { componentGlob: "src/**/[A-Z]*.{tsx,jsx,vue,svelte}" } }
572
+ : {}),
573
+ };
574
+ }
575
+ config.pages = {
576
+ startCommand:
577
+ found.dev?.command ?? `${TODO}: the command that starts the app`,
578
+ waitUrl: found.appUrl.url,
579
+ pages: [
580
+ {
581
+ name: "home",
582
+ title: "Home",
583
+ path: "/",
584
+ ready: `${TODO}: a selector that shows once the page has its content, such as "main h1"`,
585
+ slots: {
586
+ main: [
587
+ `${TODO}: the selector of the page's main content region, such as "main"`,
588
+ ],
589
+ },
590
+ },
591
+ ],
592
+ };
593
+ return config;
594
+ }
595
+
596
+ /** .github/workflows/design-kit.yml, as text. */
597
+ export function buildWorkflow(found) {
598
+ const pm = found.packageManager;
599
+ const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
600
+ const projectDir = rel(found.project);
601
+ const at = (path) => (projectDir === "." ? path : `${projectDir}/${path}`);
602
+ const installDir = rel(pm.installDir);
603
+ const node = found.nodeVersion.file
604
+ ? `node-version-file: ${found.nodeVersion.file}`
605
+ : `node-version: "${found.nodeVersion.version}"`;
606
+ const cache =
607
+ pm.lockfile && pm.name !== "bun" ? `\n cache: ${pm.name}` : "";
608
+ const cachePath =
609
+ cache && installDir !== "."
610
+ ? `\n cache-dependency-path: ${installDir}/${pm.lockfile}`
611
+ : "";
612
+
613
+ const setup = [];
614
+ if (pm.name === "pnpm")
615
+ setup.push(
616
+ pm.declared || !pm.version
617
+ ? " - uses: pnpm/action-setup@v6"
618
+ : ` - uses: pnpm/action-setup@v6\n with:\n version: ${pm.version}`,
619
+ );
620
+ if (pm.name === "yarn" && pm.berry)
621
+ setup.push(" - run: corepack enable");
622
+ if (pm.name === "bun") setup.push(" - uses: oven-sh/setup-bun@v2");
623
+ setup.push(
624
+ ` - uses: actions/setup-node@v5\n with:\n ${node}${cache}${cachePath}`,
625
+ );
626
+
627
+ const storiesStep = found.storybook
628
+ ? `
629
+ - name: Build Storybook and capture stories
630
+ working-directory: \${{ runner.temp }}/design-kit-cli
631
+ run: design-kit capture stories --config "$CONFIG"
632
+ `
633
+ : "";
634
+
635
+ return `name: Design kit
636
+
637
+ # Captures this repository's design kit once a day: ${found.storybook ? "every Storybook story and " : ""}the
638
+ # pages listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts
639
+ # they load. It uploads the result as the \`design-kit\` artifact (kept 30
640
+ # days), which agents download to draw mockups from the app's real markup.
641
+ # A day with no new commits since the newest kit builds nothing.
642
+ #
643
+ # Written by \`design-kit init\` (${PACKAGE.name}). Edit it freely.
644
+
645
+ on:
646
+ schedule:
647
+ - cron: "23 5 * * *"
648
+ workflow_dispatch:
649
+ inputs:
650
+ force:
651
+ description: Build even when the newest kit is from this commit
652
+ type: boolean
653
+ default: false
654
+ package:
655
+ description: "The ${PACKAGE.name} to install, as an npm package spec (default: the pinned version below)"
656
+ type: string
657
+ default: ""
658
+
659
+ permissions:
660
+ contents: read
661
+
662
+ concurrency:
663
+ group: design-kit-\${{ github.ref }}
664
+ cancel-in-progress: false
665
+
666
+ env:
667
+ DESIGN_KIT_PACKAGE: "${PACKAGE.name}@${PACKAGE.version}"
668
+ PLAYWRIGHT_PACKAGE: "@playwright/test@${playwrightPin()}"
669
+ CONFIG: \${{ github.workspace }}/${at(CONFIG_NAME)}
670
+
671
+ jobs:
672
+ check:
673
+ name: Any commits since the newest kit?
674
+ runs-on: ubuntu-latest
675
+ timeout-minutes: 5
676
+ permissions:
677
+ actions: read
678
+ outputs:
679
+ build: \${{ steps.last.outputs.build }}
680
+ steps:
681
+ - id: last
682
+ env:
683
+ GH_TOKEN: \${{ github.token }}
684
+ FORCE: \${{ inputs.force || 'false' }}
685
+ run: |
686
+ last=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=design-kit&per_page=100" \\
687
+ --jq "[.artifacts[] | select((.expired | not) and .workflow_run.head_branch == \\"$GITHUB_REF_NAME\\")] | sort_by(.created_at) | last | .workflow_run.head_sha // \\"\\"") || last=""
688
+ if [ "$FORCE" != "true" ] && [ -n "$last" ] && [ "$last" = "$GITHUB_SHA" ]; then
689
+ echo "The newest design kit is from $GITHUB_SHA and there are no commits since: nothing to build." | tee -a "$GITHUB_STEP_SUMMARY"
690
+ echo "build=false" >> "$GITHUB_OUTPUT"
691
+ else
692
+ echo "build=true" >> "$GITHUB_OUTPUT"
693
+ fi
694
+
695
+ build:
696
+ name: Build the design kit
697
+ needs: check
698
+ if: needs.check.outputs.build == 'true'
699
+ runs-on: ubuntu-latest
700
+ timeout-minutes: 60
701
+ steps:
702
+ - uses: actions/checkout@v5
703
+ with:
704
+ persist-credentials: false
705
+
706
+ ${setup.join("\n\n")}
707
+
708
+ - name: Install dependencies${installDir === "." ? "" : `\n working-directory: ${installDir}`}
709
+ run: ${installCommand(pm)}
710
+
711
+ # The capture runs from this folder so it drives the pinned Playwright
712
+ # and the Chromium installed for it, whatever the project depends on.
713
+ - name: Install design-kit and Chromium
714
+ working-directory: \${{ runner.temp }}
715
+ env:
716
+ PACKAGE: \${{ inputs.package || env.DESIGN_KIT_PACKAGE }}
717
+ run: |
718
+ mkdir -p design-kit-cli && cd design-kit-cli
719
+ npm init -y > /dev/null
720
+ npm install --no-audit --no-fund "$PACKAGE" "$PLAYWRIGHT_PACKAGE"
721
+ npx playwright install --with-deps chromium
722
+ echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
723
+ ${storiesStep}
724
+ # Signing in: when the pages need a signed-in user, name a sign-in
725
+ # module in pages.auth.setup and have it read these secrets (add them
726
+ # under Settings > Secrets and variables > Actions). Never commit a
727
+ # credential. Until the TODO page settings are filled in, pages are
728
+ # skipped with a warning.
729
+ - name: Capture pages
730
+ working-directory: \${{ runner.temp }}/design-kit-cli
731
+ env:
732
+ DESIGN_KIT_USERNAME: \${{ secrets.DESIGN_KIT_USERNAME }}
733
+ DESIGN_KIT_PASSWORD: \${{ secrets.DESIGN_KIT_PASSWORD }}
734
+ run: |
735
+ if grep -q '"${TODO}' "$CONFIG"; then
736
+ echo "::warning title=Design kit::${at(CONFIG_NAME)} still has ${TODO} page settings, so no pages were captured. Fill them in, or remove the pages section."
737
+ else
738
+ design-kit capture pages --config "$CONFIG"
739
+ fi
740
+
741
+ # Nothing captured yet (no stories, and the pages still have ${TODO}s):
742
+ # the run ends here with a warning instead of failing to upload.
743
+ - name: Nothing to capture yet
744
+ if: hashFiles('${at("design-kit")}/manifest.json') == ''
745
+ run: |
746
+ echo "::warning title=Design kit::Nothing to capture yet, so no kit was uploaded: ${at(CONFIG_NAME)} has no stories and its pages still have ${TODO} settings. Fill in the pages (or add Storybook) and run this workflow again."
747
+
748
+ - if: hashFiles('${at("design-kit")}/manifest.json') != ''
749
+ uses: actions/upload-artifact@v5
750
+ with:
751
+ name: design-kit
752
+ path: ${at("design-kit")}
753
+ retention-days: 30
754
+ if-no-files-found: error
755
+ `;
756
+ }
757
+
758
+ /** .claude/skills/design-kit/SKILL.md, as text. */
759
+ export function buildSkill(found) {
760
+ const pm = found.packageManager;
761
+ const projectDir = posix(relative(found.repoRoot, found.project)) || ".";
762
+ const watched = found.storybook
763
+ ? [...new Set([found.storybook.dir, found.framework?.dir ?? found.project])]
764
+ : [found.framework?.dir ?? found.project];
765
+ const watchedPaths =
766
+ watched
767
+ .map((dir) => posix(relative(found.repoRoot, dir)) || ".")
768
+ .join(" ") || ".";
769
+ const cli = `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} -p @playwright/test@${playwrightPin()} design-kit`;
770
+ const values = {
771
+ CLI: cli,
772
+ CONFIG: projectDir === "." ? CONFIG_NAME : `${projectDir}/${CONFIG_NAME}`,
773
+ PROJECT_DIR: projectDir,
774
+ WATCHED_PATHS: watchedPaths,
775
+ STORIES_LINE: found.storybook
776
+ ? `$DK capture stories --config "$CONFIG" --out "$KIT" # builds Storybook first`
777
+ : `# (no Storybook: this kit has pages only)`,
778
+ PACKAGE: PACKAGE.name,
779
+ INSTALL: installCommand(pm),
780
+ // The capture drives the project's own Playwright when it has one.
781
+ PLAYWRIGHT_INSTALL: found.playwright
782
+ ? `${execBin(pm)} playwright install chromium`
783
+ : `npx --yes -p @playwright/test@${playwrightPin()} playwright install chromium`,
784
+ };
785
+ const template = readFileSync(SKILL_TEMPLATE, "utf8");
786
+ return template.replace(/\{\{([A-Z_]+)\}\}/g, (match, key) =>
787
+ key in values ? values[key] : match,
788
+ );
789
+ }
790
+
791
+ const AGENTS_LINE =
792
+ "- **Mockups and UI designs:** use the `design-kit` skill (`.claude/skills/design-kit/SKILL.md`). It composes mockups from this app's real pages and components, which `.github/workflows/design-kit.yml` captures every day.";
793
+
794
+ function agentsSkillMode(repoRoot) {
795
+ const dir = join(repoRoot, ".agents", "skills");
796
+ const entries = safeReaddir(dir);
797
+ if (entries.some((entry) => entry.isSymbolicLink())) return "symlink";
798
+ if (entries.some((entry) => entry.isDirectory())) return "copy";
799
+ return "symlink";
800
+ }
801
+
802
+ function instructionsFile(repoRoot) {
803
+ for (const name of ["AGENTS.md", "CLAUDE.md"])
804
+ if (existsSync(join(repoRoot, name))) return name;
805
+ return "AGENTS.md";
806
+ }
807
+
808
+ /**
809
+ * The plan: every file init writes, with its content, and the notes for the
810
+ * summary. Nothing is written here.
811
+ */
812
+ export function planInit(found) {
813
+ const at = (path) => join(found.repoRoot, path);
814
+ const skill = buildSkill(found);
815
+ const instructions = instructionsFile(found.repoRoot);
816
+ const instructionsText = readText(at(instructions));
817
+ const writes = [
818
+ {
819
+ path: join(found.project, CONFIG_NAME),
820
+ kind: "file",
821
+ content: `${JSON.stringify(buildConfig(found), null, 2)}\n`,
822
+ },
823
+ { path: at(WORKFLOW_PATH), kind: "file", content: buildWorkflow(found) },
824
+ { path: at(`${SKILL_DIR}/SKILL.md`), kind: "file", content: skill },
825
+ agentsSkillMode(found.repoRoot) === "symlink"
826
+ ? {
827
+ path: at(AGENTS_SKILL_DIR),
828
+ kind: "symlink",
829
+ target: posix(relative(dirname(at(AGENTS_SKILL_DIR)), at(SKILL_DIR))),
830
+ }
831
+ : { path: at(AGENTS_SKILL_DIR), kind: "copy", from: at(SKILL_DIR) },
832
+ ];
833
+ if (instructionsText?.includes("skills/design-kit"))
834
+ writes.push({ path: at(instructions), kind: "unchanged" });
835
+ else
836
+ writes.push({
837
+ path: at(instructions),
838
+ kind: instructionsText === null ? "file" : "append",
839
+ content:
840
+ instructionsText === null
841
+ ? `# Agent instructions\n\n${AGENTS_LINE}\n`
842
+ : `${instructionsText.endsWith("\n") ? "" : "\n"}\n${AGENTS_LINE}\n`,
843
+ });
844
+ return { writes };
845
+ }
846
+
847
+ function exists(path) {
848
+ try {
849
+ lstatSync(path);
850
+ return true;
851
+ } catch {
852
+ return false;
853
+ }
854
+ }
855
+
856
+ function apply(write) {
857
+ mkdirSync(dirname(write.path), { recursive: true });
858
+ if (write.kind === "file") writeFileSync(write.path, write.content);
859
+ else if (write.kind === "append")
860
+ writeFileSync(write.path, write.content, { flag: "a" });
861
+ else if (write.kind === "symlink") {
862
+ rmSync(write.path, { recursive: true, force: true });
863
+ symlinkSync(write.target, write.path, "dir");
864
+ } else if (write.kind === "copy") {
865
+ rmSync(write.path, { recursive: true, force: true });
866
+ cpSync(write.from, write.path, { recursive: true });
867
+ }
868
+ }
869
+
870
+ // ── the summary ─────────────────────────────────────────────────────────────
871
+
872
+ function describe(found) {
873
+ const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
874
+ const pm = found.packageManager;
875
+ const sb = found.storybook;
876
+ return [
877
+ [
878
+ "Storybook",
879
+ sb
880
+ ? `${sb.version ?? "version unknown"} in ${rel(sb.dir)}${sb.hasConfig ? " (.storybook)" : " (no .storybook folder)"}${found.otherStorybooks.length ? `; also in ${found.otherStorybooks.map(rel).join(", ")}` : ""}`
881
+ : "none",
882
+ ],
883
+ [
884
+ "Framework",
885
+ found.framework
886
+ ? `${found.framework.name} in ${rel(found.framework.dir)}`
887
+ : "not recognized (other)",
888
+ ],
889
+ [
890
+ "Packages",
891
+ `${pm.name}${pm.version ? ` ${pm.version}` : ""}${pm.lockfile ? ` (${rel(join(pm.installDir, pm.lockfile))})` : " (no lockfile)"}`,
892
+ ],
893
+ [
894
+ "Dev script",
895
+ found.dev
896
+ ? `${found.dev.command} -> ${found.appUrl.url} (from ${found.appUrl.source})`
897
+ : "none found",
898
+ ],
899
+ [
900
+ "Playwright",
901
+ found.playwright
902
+ ? `${found.playwright.name}${found.playwright.version ? ` ${found.playwright.version}` : ""}`
903
+ : "none (the workflow installs its own)",
904
+ ],
905
+ [
906
+ "Theme",
907
+ found.theme.evidence
908
+ ? `${found.theme.attribute === "class" ? `class "${found.theme.dark}"` : `${found.theme.attribute}="${found.theme.dark}"`} (from ${found.theme.evidence})`
909
+ : `class "dark" (a guess: no dark theme found)`,
910
+ ],
911
+ ];
912
+ }
913
+
914
+ function todo(found) {
915
+ const items = [];
916
+ const sb = found.storybook;
917
+ if (!sb)
918
+ items.push(
919
+ "No Storybook: the config captures pages only. Decide whether to add Storybook with stories for the components the main screens use, capture real pages, or both.",
920
+ );
921
+ else if (sb.major && (sb.major < 7 || sb.major > 8))
922
+ items.push(
923
+ `Storybook ${sb.version}: captures are supported for Storybook 7 and 8 static builds; check that \`capture stories\` works, or use page capture.`,
924
+ );
925
+ items.push(
926
+ `Pages: replace the ${TODO} selectors in ${CONFIG_NAME} (pages[].ready and slots.main), add the app's main screens, and check startCommand and waitUrl. Until then the workflow skips page capture.`,
927
+ );
928
+ if (!found.dev)
929
+ items.push(
930
+ "No dev or start script found: set pages.startCommand to the command that serves the app.",
931
+ );
932
+ items.push(
933
+ "Sign-in: if the pages need a signed-in user, use a seeded test user or the app's mock mode, and a sign-in module (pages.auth.setup) that reads repository secrets in CI. Never commit a credential.",
934
+ );
935
+ if (!found.theme.evidence)
936
+ items.push(
937
+ "Theme: no dark theme was found; check the theme section, or remove it if the app has no dark mode.",
938
+ );
939
+ items.push(
940
+ "Build the kit once locally (see the design-kit skill) and say in the pull request what it captured and what it missed.",
941
+ );
942
+ return items;
943
+ }
944
+
945
+ function summary(found, plan, { dryRun }) {
946
+ const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
947
+ const lines = [
948
+ `design-kit init${dryRun ? " --dry-run" : ""}: ${found.project}`,
949
+ "",
950
+ "Found",
951
+ ];
952
+ const rows = describe(found);
953
+ const width = Math.max(...rows.map(([label]) => label.length));
954
+ for (const [label, value] of rows)
955
+ lines.push(` ${label.padEnd(width)} ${value}`);
956
+ lines.push("", dryRun ? "Would write" : "Wrote");
957
+ for (const write of plan.writes) {
958
+ const note =
959
+ write.kind === "symlink"
960
+ ? ` -> ${write.target}`
961
+ : write.kind === "copy"
962
+ ? ` (a copy of ${rel(write.from)})`
963
+ : write.kind === "append"
964
+ ? " (one line added)"
965
+ : write.kind === "unchanged"
966
+ ? " (unchanged: it already points at the skill)"
967
+ : "";
968
+ lines.push(` ${rel(write.path)}${note}`);
969
+ }
970
+ lines.push("", "Still to decide");
971
+ for (const item of todo(found)) lines.push(` - ${item}`);
972
+ return lines.join("\n");
973
+ }
974
+
975
+ // ── the command ─────────────────────────────────────────────────────────────
976
+
977
+ /**
978
+ * Runs init. Options: `root` (the project folder, default the working
979
+ * directory), `dryRun`, `force`, `log`. Returns the exit code: 0 done, 1
980
+ * already set up or another file in the way.
981
+ */
982
+ export function init({
983
+ root = ".",
984
+ dryRun = false,
985
+ force = false,
986
+ log = console.log,
987
+ } = {}) {
988
+ const found = detectProject(root);
989
+ const plan = planInit(found);
990
+ if (!force) {
991
+ const config = join(found.project, CONFIG_NAME);
992
+ if (exists(config))
993
+ throw new InitError(
994
+ `already set up: ${config} exists. Nothing was written; pass --force to replace the files init writes.`,
995
+ );
996
+ const inTheWay = plan.writes
997
+ .filter(
998
+ (write) =>
999
+ write.kind === "file" ||
1000
+ write.kind === "symlink" ||
1001
+ write.kind === "copy",
1002
+ )
1003
+ .filter((write) => exists(write.path))
1004
+ .map((write) => posix(relative(found.repoRoot, write.path)));
1005
+ if (inTheWay.length)
1006
+ throw new InitError(
1007
+ `${inTheWay.join(", ")} already exist${inTheWay.length === 1 ? "s" : ""}. Nothing was written; pass --force to replace ${inTheWay.length === 1 ? "it" : "them"}.`,
1008
+ );
1009
+ }
1010
+ if (!dryRun) {
1011
+ // The skill folder is written before the copy or link that points at it.
1012
+ for (const write of plan.writes)
1013
+ if (write.kind !== "unchanged") apply(write);
1014
+ }
1015
+ log(summary(found, plan, { dryRun }));
1016
+ return 0;
1017
+ }
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: design-kit
3
+ description: "Draw mockups of this app's screens from its design kit: real pages and Storybook stories captured every day as static HTML, composed into self-contained pages with only the new parts hand-drawn. Use for any mockup, design or screen page of this app. Covers fetching the newest kit with gh, kit find/show/compose, <kit-snippet> and data-kit-new, staleness, captions saying what is real and what is new, and what to do when no kit can be fetched."
4
+ ---
5
+
6
+ # Design kit
7
+
8
+ Mockups of this app's screens start from its **design kit**: real pages of
9
+ the app and its Storybook stories, rendered to static HTML with the app's own
10
+ CSS and fonts, plus `kit.mjs`, a dependency-free tool that finds pieces and
11
+ composes self-contained pages. Draw only what is new; the frame and every
12
+ existing component come from the kit, so the mockup looks like the app it will
13
+ ship in.
14
+
15
+ The kit is configured in `{{CONFIG}}` and built by the `Design kit` workflow
16
+ (`.github/workflows/design-kit.yml`). The format and every command are in the
17
+ `{{PACKAGE}}` README.
18
+
19
+ ## 1. Fetch the newest kit
20
+
21
+ The workflow runs once a day and on demand (`workflow_dispatch`), and uploads
22
+ the `design-kit` artifact (kept 30 days). A day with no new commits skips and
23
+ uploads nothing, yet still succeeds, so look up the newest artifact rather than
24
+ the newest run, then download it once per kit commit:
25
+
26
+ ```bash
27
+ REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
28
+ BRANCH=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)
29
+ read -r RUN SHA < <(gh api "repos/$REPO/actions/artifacts?name=design-kit&per_page=100" \
30
+ --jq "[.artifacts[] | select((.expired | not) and .workflow_run.head_branch == \"$BRANCH\")]
31
+ | sort_by(.created_at) | last // empty | \"\(.workflow_run.id) \(.workflow_run.head_sha)\"")
32
+ echo "run=$RUN sha=$SHA" # empty → go to "When there is no kit"
33
+ KIT="${TMPDIR:-/tmp}/design-kit-$SHA"
34
+ if [ -n "$RUN" ] && [ ! -f "$KIT/manifest.json" ]; then
35
+ rm -rf "$KIT.partial" &&
36
+ gh run download "$RUN" --repo "$REPO" -n design-kit -D "$KIT.partial" &&
37
+ mv "$KIT.partial" "$KIT"
38
+ fi
39
+ node "$KIT/kit.mjs" info # manifest: sha, built_at, counts, sources
40
+ ```
41
+
42
+ Reuse `$KIT` while the newest kit's SHA is unchanged. Keep the kit and your
43
+ working files in a scratch folder, never in the repository.
44
+
45
+ ## 2. Check staleness
46
+
47
+ The kit is a snapshot of `manifest.sha`, up to a day behind. Check whether UI
48
+ code changed since:
49
+
50
+ ```bash
51
+ KIT_SHA=$(node -p 'require(process.argv[1]).sha' "$KIT/manifest.json")
52
+ git fetch -q origin "$BRANCH" && git log --oneline "$KIT_SHA..origin/$BRANCH" -- {{WATCHED_PATHS}}
53
+ ```
54
+
55
+ When the list is not empty, say so on the mockup: "Kit from `<sha7>`; N UI
56
+ commits since, so parts of the frame may be out of date". When one of those
57
+ commits touches a component the mockup shows, read it and say what the mockup
58
+ is missing.
59
+
60
+ ## 3. Find what is real
61
+
62
+ ```bash
63
+ node "$KIT/kit.mjs" find button # ≤ 30 lines: id, kind, width, bytes, title
64
+ node "$KIT/kit.mjs" show route:home@1280 # path, size and an outline with slots
65
+ ```
66
+
67
+ - **Frames** are whole screens: `route:<page>@<width>` rows, real pages of the
68
+ app at 1280 and 390 wide, with replaceable regions marked `data-kit-slot`
69
+ (`main` on every page). New content goes only into a slot, and only `route:`
70
+ frames have slots. A story frame is still useful unchanged, as the "today"
71
+ view or as real context beside a mockup.
72
+ - **Snippets** are stories: `story:<storybook-id>@<width>`, one per component
73
+ state. Search by component and state (`find empty`, `find dialog`).
74
+ - `show` prints a short outline. Use `show --full` only on a small snippet
75
+ whose structure you need, never on a frame.
76
+ - Anything not in the kit has no story or its capture failed (`gaps.json` in
77
+ the kit lists the failures). Draw it as new and say it is approximated.
78
+
79
+ ## 4. Write only the new content
80
+
81
+ Write one HTML fragment per slot you change, in your scratch folder:
82
+
83
+ ```html
84
+ <!-- $WORK/after-main.html → fills the frame's `main` slot -->
85
+ <div class="page">
86
+ <kit-snippet id="story:components-card--default@1280"></kit-snippet>
87
+ <section data-kit-new class="card">…only what the change adds…</section>
88
+ </div>
89
+ ```
90
+
91
+ - `<kit-snippet id="…">` places a real component; `compose` expands it with
92
+ the captured markup.
93
+ - Mark every hand-drawn element with `data-kit-new` (the outermost new element
94
+ is enough).
95
+ - Style new parts with the app's own classes and CSS variables. The kit's CSS
96
+ holds only the rules the app actually loads, so check a class exists with
97
+ `grep -c '\.<class>' "$KIT"/css/*.css`, or use an inline `style` with the
98
+ app's variables.
99
+ - To show several states of the new part, stack them inside the fragment, each
100
+ with a short label.
101
+
102
+ ## 5. Compose
103
+
104
+ ```bash
105
+ node "$KIT/kit.mjs" compose --frame route:home@1280 \
106
+ --slot main="$WORK/after-main.html" --out "$WORK/after-desktop.html"
107
+ node "$KIT/kit.mjs" compose --frame route:home@1280 --out "$WORK/today.html"
108
+ ```
109
+
110
+ The result is one self-contained page: CSS and fonts inlined, scripts and
111
+ remote URLs removed, and `data-kit-sha` and `data-kit-id` on `<html>`. Add
112
+ `--theme dark` for the dark theme and `--show-new` for a dashed outline on
113
+ every `data-kit-new` element. An unknown frame, slot or snippet id exits 2 and
114
+ names near matches.
115
+
116
+ Never paste snippet or frame markup through your own output, and do not read a
117
+ composed page back: it carries the whole CSS and fonts. Open it in a browser,
118
+ screenshot it, and share the screenshot or the file.
119
+
120
+ ## 6. Caption: what is real, what is new
121
+
122
+ Under every mockup, say which parts are real and which are new, and where you
123
+ approximated:
124
+
125
+ > Real: the Home page (`route:home@1280`) and the card
126
+ > (`story:components-card--default@1280`), from kit `3f2a9c1` built
127
+ > 2026-11-03. New (dashed): the Pinned section. Approximated: the pin menu has
128
+ > no story yet.
129
+
130
+ Add the staleness line from step 2 when it applies. For other widths, compose
131
+ the capture on the same side of the breakpoint and say that layout decided in
132
+ script stays as captured.
133
+
134
+ ## When there is no kit
135
+
136
+ 1. **Build it locally** from an up-to-date checkout with dependencies
137
+ installed (`{{INSTALL}}`):
138
+
139
+ ```bash
140
+ DK="{{CLI}}"
141
+ CONFIG="$PWD/{{CONFIG}}"
142
+ KIT="${TMPDIR:-/tmp}/design-kit-local-$(git rev-parse --short HEAD)"
143
+ {{PLAYWRIGHT_INSTALL}}
144
+ {{STORIES_LINE}}
145
+ $DK capture pages --config "$CONFIG" --out "$KIT" # starts the app itself
146
+ ```
147
+
148
+ Then follow steps 3–6 with this `$KIT` and caption it as "local build at
149
+ `<sha7>`". A page capture that fails leaves a kit of stories only: compose
150
+ story frames unchanged for real context, draw the new screen by hand and
151
+ say so.
152
+
153
+ 2. **No kit at all**: approximate from the app's styles and components, and say
154
+ on every mockup "Approximated: the design kit was unavailable (<reason>)".
155
+
156
+ Either way the work goes ahead; the caption says what you could not use.