@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 +47 -3
- package/package.json +1 -1
- package/src/capture-pages.mjs +22 -5
- package/src/cli.mjs +18 -1
- package/src/init.mjs +1017 -0
- package/src/templates/design-kit-skill.md +156 -0
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.
|
|
113
|
-
HTML or JSON, or (for a token) any long segment of it,
|
|
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
|
|
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
package/src/capture-pages.mjs
CHANGED
|
@@ -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
|
|
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.
|