basis-design 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/capture.html +12 -0
- package/package.json +72 -68
- package/server/agent.ts +33 -12
- package/server/brief.ts +32 -3
- package/server/capture.ts +128 -0
- package/server/claude.ts +72 -0
- package/server/folders.ts +125 -0
- package/server/index.ts +134 -3
- package/server/project.ts +9 -1
- package/server/setup.ts +201 -219
- package/server/template.ts +168 -0
- package/server/verify-kit/capture.mjs +206 -0
- package/server/verify-kit/compare.d.mts +39 -0
- package/server/verify-kit/compare.mjs +307 -0
- package/server/verify.ts +383 -0
- package/server/workspace.ts +19 -1
- package/shared/capture.ts +75 -0
- package/shared/protocol.ts +45 -2
- package/src/Workbench.tsx +4 -1
- package/src/bridge.ts +173 -163
- package/src/capture/capture.css +50 -0
- package/src/capture/main.tsx +146 -0
- package/src/chat/ChatPanel.tsx +10 -1
- package/src/preview/specimens.tsx +21 -0
- package/src/shell/ExportDialog.tsx +41 -4
- package/src/shell/FolderField.tsx +131 -0
- package/src/shell/ProjectMenu.tsx +58 -21
- package/src/shell/Shell.module.css +1146 -1067
- package/src/shell/VerifyKit.tsx +133 -0
- package/template/CHANGES.md +25 -0
- package/template/components/Field.module.css +10 -0
- package/template/components/Field.tsx +6 -0
- package/template/components/specimen.ts +24 -1
- package/template/history.json +123 -0
- package/template/patterns/Navbar.module.css +38 -12
- package/template/patterns/Navbar.specimen.tsx +1 -1
- package/template/patterns/Navbar.tsx +27 -3
package/server/verify.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { strToU8, zipSync, type Zippable } from "fflate";
|
|
7
|
+
import { build } from "vite";
|
|
8
|
+
import { READY_ATTRIBUTE, shotFile } from "../shared/capture.ts";
|
|
9
|
+
import type { Design } from "../shared/design.ts";
|
|
10
|
+
import type { VerifyKitSummary } from "../shared/protocol.ts";
|
|
11
|
+
import { CAPTURE_ARGS, captureViews, launchCaptureBrowser } from "./capture.ts";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The verification kit: what an agent implementing a feature needs to prove the implementation looks like the
|
|
15
|
+
* design. A static, runnable copy of the design (design/), the screenshots Basis took of every screen of every
|
|
16
|
+
* view (screens/), the settings they were taken with (basis-verify.json), and two scripts that capture the
|
|
17
|
+
* implementation the same way and compare the two pixel for pixel (capture.mjs, compare.mjs).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
21
|
+
const kitSource = path.join(import.meta.dirname, "verify-kit");
|
|
22
|
+
|
|
23
|
+
/** The kit's folder inside a project, and the folder inside its zip. */
|
|
24
|
+
export const KIT_FOLDER = "basis-verify";
|
|
25
|
+
export const MANIFEST = "basis-verify.json";
|
|
26
|
+
/** What Basis writes and replaces; everything else in the kit's folder (targets.json, exceptions.json, actual/) is the project's. */
|
|
27
|
+
const GENERATED = [
|
|
28
|
+
"design",
|
|
29
|
+
"screens",
|
|
30
|
+
"expected",
|
|
31
|
+
"diff",
|
|
32
|
+
MANIFEST,
|
|
33
|
+
"README.md",
|
|
34
|
+
"capture.mjs",
|
|
35
|
+
"compare.mjs",
|
|
36
|
+
"targets.example.json",
|
|
37
|
+
"exceptions.example.json",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Rules the implementation's pages get while they are captured, so nothing moves between two captures. */
|
|
41
|
+
export const NEUTRAL_CSS =
|
|
42
|
+
"*,*::before,*::after{transition:none!important;animation:none!important;caret-color:transparent!important}";
|
|
43
|
+
|
|
44
|
+
export interface KitFile {
|
|
45
|
+
path: string;
|
|
46
|
+
data: Uint8Array;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TYPES: Record<string, string> = {
|
|
50
|
+
".html": "text/html; charset=utf-8",
|
|
51
|
+
".js": "text/javascript; charset=utf-8",
|
|
52
|
+
".css": "text/css; charset=utf-8",
|
|
53
|
+
".json": "application/json",
|
|
54
|
+
".svg": "image/svg+xml",
|
|
55
|
+
".png": "image/png",
|
|
56
|
+
".woff2": "font/woff2",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Builds the capture page with the workspace's components into `outDir`, as index.html beside design.json. */
|
|
60
|
+
async function buildDesign(design: Design, workspaceDir: string, outDir: string): Promise<void> {
|
|
61
|
+
await build({
|
|
62
|
+
root: repoRoot,
|
|
63
|
+
configFile: path.join(repoRoot, "vite.config.ts"),
|
|
64
|
+
base: "./",
|
|
65
|
+
logLevel: "silent",
|
|
66
|
+
resolve: {
|
|
67
|
+
alias: {
|
|
68
|
+
"@ds": path.join(workspaceDir, "components", "index.ts"),
|
|
69
|
+
"@workspace": workspaceDir,
|
|
70
|
+
},
|
|
71
|
+
dedupe: ["react", "react-dom"],
|
|
72
|
+
},
|
|
73
|
+
build: {
|
|
74
|
+
outDir,
|
|
75
|
+
emptyOutDir: true,
|
|
76
|
+
reportCompressedSize: false,
|
|
77
|
+
chunkSizeWarningLimit: 100_000,
|
|
78
|
+
rolldownOptions: { input: path.join(repoRoot, "capture.html") },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await rename(path.join(outDir, "capture.html"), path.join(outDir, "index.html"));
|
|
82
|
+
await writeFile(path.join(outDir, "design.json"), `${JSON.stringify(design, null, 2)}\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Serves a folder on a free local port, for the browser to open the built design from. */
|
|
86
|
+
async function serve(dir: string): Promise<{ url: string; close: () => void }> {
|
|
87
|
+
const server = http.createServer((req, res) => {
|
|
88
|
+
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://local").pathname);
|
|
89
|
+
const file = path.join(dir, pathname === "/" ? "index.html" : pathname);
|
|
90
|
+
if (!file.startsWith(dir + path.sep)) {
|
|
91
|
+
res.writeHead(403).end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
readFile(file).then(
|
|
95
|
+
(body) => {
|
|
96
|
+
res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] ?? "application/octet-stream" });
|
|
97
|
+
res.end(body);
|
|
98
|
+
},
|
|
99
|
+
() => res.writeHead(404).end(),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
103
|
+
const { port } = server.address() as { port: number };
|
|
104
|
+
return {
|
|
105
|
+
url: `http://127.0.0.1:${port}/index.html`,
|
|
106
|
+
close: () => {
|
|
107
|
+
server.closeAllConnections();
|
|
108
|
+
server.close();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function filesIn(dir: string, prefix: string): Promise<KitFile[]> {
|
|
114
|
+
const entries = (await readdir(dir, { recursive: true })).sort();
|
|
115
|
+
const files: KitFile[] = [];
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const full = path.join(dir, entry);
|
|
118
|
+
if (!(await stat(full)).isFile()) continue;
|
|
119
|
+
files.push({ path: `${prefix}/${entry.split(path.sep).join("/")}`, data: await readFile(full) });
|
|
120
|
+
}
|
|
121
|
+
return files;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function packageVersion(): Promise<string> {
|
|
125
|
+
return (JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8")) as { version: string }).version;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface BuildKitOptions {
|
|
129
|
+
design: Design;
|
|
130
|
+
workspaceDir: string;
|
|
131
|
+
onProgress?: (step: string, done?: number, total?: number) => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Builds the design, photographs every screen and returns the kit's files with a summary for the user. */
|
|
135
|
+
export async function buildVerificationKit({
|
|
136
|
+
design,
|
|
137
|
+
workspaceDir,
|
|
138
|
+
onProgress,
|
|
139
|
+
}: BuildKitOptions): Promise<{ files: KitFile[]; summary: VerifyKitSummary }> {
|
|
140
|
+
const temp = await mkdtemp(path.join(os.tmpdir(), "basis-verify-"));
|
|
141
|
+
const designDir = path.join(temp, "design");
|
|
142
|
+
try {
|
|
143
|
+
onProgress?.("Building the design");
|
|
144
|
+
await buildDesign(design, workspaceDir, designDir);
|
|
145
|
+
onProgress?.("Starting the browser");
|
|
146
|
+
const { browser, name, version } = await launchCaptureBrowser();
|
|
147
|
+
const server = await serve(designDir);
|
|
148
|
+
let result;
|
|
149
|
+
try {
|
|
150
|
+
result = await captureViews(server.url, browser, (done, total) =>
|
|
151
|
+
onProgress?.("Taking screenshots", done, total),
|
|
152
|
+
);
|
|
153
|
+
} finally {
|
|
154
|
+
server.close();
|
|
155
|
+
await browser.close();
|
|
156
|
+
}
|
|
157
|
+
const { views, problems, shots } = result;
|
|
158
|
+
const byFile = new Map(shots.map((shot) => [shot.file, shot]));
|
|
159
|
+
const generator = `basis-design ${await packageVersion()}`;
|
|
160
|
+
const manifest = {
|
|
161
|
+
format: 1,
|
|
162
|
+
generator,
|
|
163
|
+
createdAt: new Date().toISOString(),
|
|
164
|
+
designSystem: design.name,
|
|
165
|
+
browser: { name, version, platform: process.platform },
|
|
166
|
+
render: {
|
|
167
|
+
deviceScaleFactor: 1,
|
|
168
|
+
reducedMotion: "reduce",
|
|
169
|
+
colorScheme: "light",
|
|
170
|
+
args: CAPTURE_ARGS,
|
|
171
|
+
neutralCss: NEUTRAL_CSS,
|
|
172
|
+
base: await readFile(path.join(repoRoot, "src", "capture", "capture.css"), "utf8"),
|
|
173
|
+
},
|
|
174
|
+
design: {
|
|
175
|
+
entry: "design/index.html",
|
|
176
|
+
query: "?view={view}&screen={screen}&theme={theme}",
|
|
177
|
+
ready: `html[${READY_ATTRIBUTE}]`,
|
|
178
|
+
error: "html[data-basis-error]",
|
|
179
|
+
},
|
|
180
|
+
views: views.map((view) => ({
|
|
181
|
+
id: view.id,
|
|
182
|
+
title: view.title,
|
|
183
|
+
screens: view.screens.map((screen) => ({
|
|
184
|
+
id: screen.id,
|
|
185
|
+
label: screen.label,
|
|
186
|
+
shots: screen.shots.map((planned) => {
|
|
187
|
+
const shot = byFile.get(shotFile(view.id, screen.id, planned));
|
|
188
|
+
return {
|
|
189
|
+
...planned,
|
|
190
|
+
file: shot?.file,
|
|
191
|
+
imageHeight: shot?.imageHeight,
|
|
192
|
+
stable: shot?.stable,
|
|
193
|
+
failed: shot?.failed,
|
|
194
|
+
};
|
|
195
|
+
}),
|
|
196
|
+
})),
|
|
197
|
+
})),
|
|
198
|
+
problems,
|
|
199
|
+
};
|
|
200
|
+
const targets = {
|
|
201
|
+
baseUrl: "http://localhost:3000",
|
|
202
|
+
screens: Object.fromEntries(
|
|
203
|
+
views.flatMap((view) => view.screens.map((screen) => [`${view.id}/${screen.id}`, "/"])),
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
const exceptions = {
|
|
207
|
+
exceptions: [
|
|
208
|
+
{
|
|
209
|
+
name: "example-avatar",
|
|
210
|
+
reason: "Why this part may differ, in a sentence the reviewer can check.",
|
|
211
|
+
view: views[0]?.id ?? "<view id>",
|
|
212
|
+
screen: views[0]?.screens[0]?.id,
|
|
213
|
+
widths: [375],
|
|
214
|
+
region: { x: 16, y: 16, width: 40, height: 40 },
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
const text = (file: string, content: string): KitFile => ({ path: file, data: strToU8(content) });
|
|
219
|
+
const files: KitFile[] = [
|
|
220
|
+
text(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`),
|
|
221
|
+
text("README.md", readme(design.name, generator, name, version)),
|
|
222
|
+
text("capture.mjs", await readFile(path.join(kitSource, "capture.mjs"), "utf8")),
|
|
223
|
+
text("compare.mjs", await readFile(path.join(kitSource, "compare.mjs"), "utf8")),
|
|
224
|
+
text("targets.example.json", `${JSON.stringify(targets, null, 2)}\n`),
|
|
225
|
+
text("exceptions.example.json", `${JSON.stringify(exceptions, null, 2)}\n`),
|
|
226
|
+
...(await filesIn(designDir, "design")),
|
|
227
|
+
...shots.map((shot) => ({ path: shot.file, data: new Uint8Array(shot.png) })),
|
|
228
|
+
];
|
|
229
|
+
const summary: VerifyKitSummary = {
|
|
230
|
+
browser: `${name} ${version}`,
|
|
231
|
+
views: views.length,
|
|
232
|
+
screens: views.reduce((sum, view) => sum + view.screens.length, 0),
|
|
233
|
+
shots: shots.length,
|
|
234
|
+
unstable: shots.filter((shot) => !shot.stable).map((shot) => shot.file),
|
|
235
|
+
failed: shots.filter((shot) => shot.failed).map((shot) => shot.file),
|
|
236
|
+
problems,
|
|
237
|
+
};
|
|
238
|
+
return { files, summary };
|
|
239
|
+
} finally {
|
|
240
|
+
await rm(temp, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function zipVerificationKit(files: KitFile[]): Uint8Array {
|
|
245
|
+
const entries: Zippable = {};
|
|
246
|
+
// PNGs are compressed already; deflating them again only costs time.
|
|
247
|
+
for (const file of files)
|
|
248
|
+
entries[`${KIT_FOLDER}/${file.path}`] = [file.data, { level: file.path.endsWith(".png") ? 0 : 6 }];
|
|
249
|
+
return zipSync(entries);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Writes the kit into `<project>/basis-verify/`. A folder by that name that is not a kit (no manifest) is left
|
|
254
|
+
* alone; in a kit, the files Basis generates are replaced and the project's own (targets.json, exceptions.json,
|
|
255
|
+
* actual/) are kept.
|
|
256
|
+
*/
|
|
257
|
+
export async function writeVerificationKit(
|
|
258
|
+
files: KitFile[],
|
|
259
|
+
project: string,
|
|
260
|
+
): Promise<{ ok: true; dir: string } | { ok: false; error: string }> {
|
|
261
|
+
const trimmed = project.trim();
|
|
262
|
+
if (!trimmed || !path.isAbsolute(trimmed)) return { ok: false, error: "Enter an absolute folder path." };
|
|
263
|
+
const root = path.resolve(trimmed);
|
|
264
|
+
const relativeToBasis = path.relative(repoRoot, root);
|
|
265
|
+
if (!relativeToBasis.startsWith("..") && !path.isAbsolute(relativeToBasis)) {
|
|
266
|
+
return { ok: false, error: "That folder is inside Basis itself. Pick your project's folder." };
|
|
267
|
+
}
|
|
268
|
+
if (!existsSync(root) || !(await stat(root)).isDirectory())
|
|
269
|
+
return { ok: false, error: "That folder does not exist." };
|
|
270
|
+
const dir = path.join(root, KIT_FOLDER);
|
|
271
|
+
if (existsSync(dir) && !existsSync(path.join(dir, MANIFEST)) && (await readdir(dir)).length > 0) {
|
|
272
|
+
return { ok: false, error: `${dir} exists and is not a verification kit. Move it away or pick another folder.` };
|
|
273
|
+
}
|
|
274
|
+
for (const entry of GENERATED) await rm(path.join(dir, entry), { recursive: true, force: true });
|
|
275
|
+
for (const file of files) {
|
|
276
|
+
const destination = path.join(dir, file.path);
|
|
277
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
278
|
+
await writeFile(destination, file.data);
|
|
279
|
+
}
|
|
280
|
+
return { ok: true, dir };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readme(name: string, generator: string, browser: string, version: string): string {
|
|
284
|
+
return `# Verification kit: ${name}
|
|
285
|
+
|
|
286
|
+
Generated by ${generator} with ${browser} ${version}. Basis replaces this folder's generated files
|
|
287
|
+
(\`design/\`, \`screens/\`, \`expected/\`, \`diff/\`, \`basis-verify.json\`, the scripts and the examples) each
|
|
288
|
+
time it writes the kit; \`targets.json\`, \`exceptions.json\` and \`actual/\` are yours and stay.
|
|
289
|
+
|
|
290
|
+
The kit says what each screen of each view must look like, pixel for pixel, at fixed sizes. The target is
|
|
291
|
+
0% difference. Anything allowed to differ is a named exception in \`exceptions.json\`, so a reviewer can read
|
|
292
|
+
every one of them.
|
|
293
|
+
|
|
294
|
+
## Contents
|
|
295
|
+
|
|
296
|
+
- \`design/\`: the design as a static site. Serve the folder and open
|
|
297
|
+
\`index.html?view=<view>&screen=<screen>&theme=light\` to see one screen alone, at the size of the window.
|
|
298
|
+
The screens are clickable where the design moves between them.
|
|
299
|
+
- \`screens/\`: what Basis captured, named \`<view>/<screen>@<width>[x<height>][-dark].png\`.
|
|
300
|
+
- \`basis-verify.json\`: every view, screen and shot (width, viewport height, full page or not, theme), and the
|
|
301
|
+
settings the shots were taken with: device scale 1, reduced motion, light color scheme, the Chromium flags in
|
|
302
|
+
\`render.args\`, and the CSS every captured page gets (\`render.neutralCss\`).
|
|
303
|
+
- \`capture.mjs\`: takes the same shots of the design or of the implementation, on this machine.
|
|
304
|
+
- \`compare.mjs\`: compares the two sets and writes diff images.
|
|
305
|
+
|
|
306
|
+
## Check an implementation
|
|
307
|
+
|
|
308
|
+
Both scripts need Node 22 and Playwright in the project:
|
|
309
|
+
|
|
310
|
+
\`\`\`bash
|
|
311
|
+
npm i -D playwright
|
|
312
|
+
npx playwright install chromium
|
|
313
|
+
\`\`\`
|
|
314
|
+
|
|
315
|
+
1. Capture the design on this machine. Fonts and antialiasing differ between operating systems, so compare
|
|
316
|
+
images taken on the same machine with the same browser. \`screens/\` is the reference only when nothing
|
|
317
|
+
else is there.
|
|
318
|
+
|
|
319
|
+
\`\`\`bash
|
|
320
|
+
node basis-verify/capture.mjs design
|
|
321
|
+
\`\`\`
|
|
322
|
+
|
|
323
|
+
2. Point each screen at the page that implements it: copy \`targets.example.json\` to \`targets.json\` and fill
|
|
324
|
+
in the URLs (relative to \`baseUrl\`, or absolute). An entry is a URL, or
|
|
325
|
+
\`{ "url": "/team/42", "waitFor": "[data-loaded]" }\` to wait for an element before capturing. Optional:
|
|
326
|
+
\`"storageState": "auth.json"\` (a Playwright storage state, for pages behind a login) and
|
|
327
|
+
\`"themeAttribute": "data-theme"\` (set on \`<html>\` to the shot's theme, for apps that switch themes with an
|
|
328
|
+
attribute rather than the color scheme).
|
|
329
|
+
|
|
330
|
+
3. Start the app and capture it:
|
|
331
|
+
|
|
332
|
+
\`\`\`bash
|
|
333
|
+
node basis-verify/capture.mjs app
|
|
334
|
+
\`\`\`
|
|
335
|
+
|
|
336
|
+
4. Compare:
|
|
337
|
+
|
|
338
|
+
\`\`\`bash
|
|
339
|
+
node basis-verify/compare.mjs
|
|
340
|
+
\`\`\`
|
|
341
|
+
|
|
342
|
+
Each shot prints PASS, FAIL (with the share of differing pixels), MISSING or EXCEPTED. Failures get a diff
|
|
343
|
+
image in \`diff/\`: matching pixels faded, differing pixels red, excepted regions blue. \`diff/report.json\` has
|
|
344
|
+
the same results for tools. The command exits with 1 when any shot fails or is missing.
|
|
345
|
+
|
|
346
|
+
A screen is the whole page as the product shows it. The page's size matters too: a full-page shot that is
|
|
347
|
+
taller or shorter than the design fails even when the top matches.
|
|
348
|
+
|
|
349
|
+
## Make it match
|
|
350
|
+
|
|
351
|
+
- Use the design's tokens and the same page structure. Compare against \`design/\` in a browser at the same
|
|
352
|
+
width when a diff is hard to read.
|
|
353
|
+
- Data must be the same as in the design. The design uses the product's real data where it could; where the
|
|
354
|
+
implementation loads data, point it at the same records, or seed them.
|
|
355
|
+
- Anything that changes between two loads (clocks, random values, animations, content that arrives late)
|
|
356
|
+
makes a shot unstable: \`capture.mjs\` says so, and such a shot can never match.
|
|
357
|
+
|
|
358
|
+
## Exceptions
|
|
359
|
+
|
|
360
|
+
When part of a screen cannot match for a reason you can name, add it to \`exceptions.json\` (start from
|
|
361
|
+
\`exceptions.example.json\`):
|
|
362
|
+
|
|
363
|
+
\`\`\`json
|
|
364
|
+
{
|
|
365
|
+
"exceptions": [
|
|
366
|
+
{
|
|
367
|
+
"name": "avatar-photo",
|
|
368
|
+
"reason": "Real profile photos replace the design's initials.",
|
|
369
|
+
"view": "team-members",
|
|
370
|
+
"screen": "list",
|
|
371
|
+
"widths": [375],
|
|
372
|
+
"themes": ["light"],
|
|
373
|
+
"region": { "x": 16, "y": 16, "width": 40, "height": 40 }
|
|
374
|
+
}
|
|
375
|
+
]
|
|
376
|
+
}
|
|
377
|
+
\`\`\`
|
|
378
|
+
|
|
379
|
+
\`screen\`, \`widths\` and \`themes\` narrow where it applies; without them it applies to every screen, width or
|
|
380
|
+
theme of the view. \`region\` is in the image's pixels; without it the whole shot is excepted. Keep exceptions
|
|
381
|
+
few and specific: every one is a part of the design nobody checks.
|
|
382
|
+
`;
|
|
383
|
+
}
|
package/server/workspace.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { DEFAULT_DESIGN, parseDesignText, type Design } from "../shared/design.ts";
|
|
6
6
|
import { fixedColorFindings } from "../shared/fixed-colors.ts";
|
|
7
|
-
import type { DesignState, HistoryEntry, Snapshot } from "../shared/protocol.ts";
|
|
7
|
+
import type { DesignState, HistoryEntry, Snapshot, TemplateUpdate } from "../shared/protocol.ts";
|
|
8
8
|
import { REVIEW_FILE, type ReviewState } from "../shared/review.ts";
|
|
9
9
|
import { git, lines, typecheck } from "./exec.ts";
|
|
10
10
|
import { commitToHistory, describeSource, loadHistory, type EntryMeta, type Source } from "./history.ts";
|
|
@@ -12,6 +12,7 @@ import { DESIGN_FILE, isSourcePath, PRODUCT_FILE, relativeInside, SOURCE_DIRS }
|
|
|
12
12
|
import { reachedSpecimens } from "./reach.ts";
|
|
13
13
|
import { Reviews } from "./reviews.ts";
|
|
14
14
|
import { claimWorkspaceFormat, seedWorkspace, writeGeneratedFiles } from "./setup.ts";
|
|
15
|
+
import { recordTemplateVersion, templateUpdate, writeTemplateCopy } from "./template.ts";
|
|
15
16
|
import { Snapshots } from "./snapshots.ts";
|
|
16
17
|
|
|
17
18
|
const DIFF_LIMIT = 400_000;
|
|
@@ -46,6 +47,8 @@ export class Workspace extends EventEmitter<Events> {
|
|
|
46
47
|
/** A hand edit being checked and recorded (see onFsEvent); close() waits for it. */
|
|
47
48
|
private settling: Promise<void> = Promise.resolve();
|
|
48
49
|
private closed = false;
|
|
50
|
+
/** A newer starter template this workspace has not caught up with (see server/template.ts). */
|
|
51
|
+
private template: TemplateUpdate | null = null;
|
|
49
52
|
/** True while an agent turn runs; its changes are committed once, when the turn ends. */
|
|
50
53
|
agentActive = false;
|
|
51
54
|
|
|
@@ -73,6 +76,8 @@ export class Workspace extends EventEmitter<Events> {
|
|
|
73
76
|
const hasCommits = (await git(this.dir, ["rev-list", "-n", "1", "--all"]).catch(() => "")).trim() !== "";
|
|
74
77
|
await this.commit(hasCommits ? (note ? "upgrade" : "external") : "init", note ? { label: note } : {});
|
|
75
78
|
this.history = await loadHistory(this.dir);
|
|
79
|
+
this.template = await templateUpdate(this.dir);
|
|
80
|
+
if (this.template) await writeTemplateCopy(this.dir);
|
|
76
81
|
// Copies of past versions from earlier runs, except the open review's starting point.
|
|
77
82
|
await this.snapshots.prune(this.reviews.state?.baseline);
|
|
78
83
|
await this.readReviewRequest();
|
|
@@ -101,9 +106,22 @@ export class Workspace extends EventEmitter<Events> {
|
|
|
101
106
|
reviewErrors: this.reviews.errors,
|
|
102
107
|
colorFindings: this.colorFindings,
|
|
103
108
|
workspace: this.dir,
|
|
109
|
+
templateUpdate: this.template,
|
|
104
110
|
};
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
get templateUpdate(): TemplateUpdate | null {
|
|
114
|
+
return this.template;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Records the newer template as caught up with, whether its changes were brought in or skipped. */
|
|
118
|
+
async settleTemplateUpdate(): Promise<void> {
|
|
119
|
+
if (!this.template) return;
|
|
120
|
+
await recordTemplateVersion(this.dir, this.template.to);
|
|
121
|
+
this.template = null;
|
|
122
|
+
this.emit("design", this.state);
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
get entries(): HistoryEntry[] {
|
|
108
126
|
return this.history;
|
|
109
127
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Capturing views for verification: each screen of a view is photographed at fixed sizes, and the implementation
|
|
3
|
+
* of the feature is later checked against those screenshots pixel for pixel. Shared by the capture page (which
|
|
4
|
+
* renders one screen) and the server (which takes the screenshots).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type CaptureTheme = "light" | "dark";
|
|
8
|
+
|
|
9
|
+
/** What a flow block may say about capturing (template/components/specimen.ts, `Capture`). */
|
|
10
|
+
export interface CaptureOptions {
|
|
11
|
+
widths?: number[];
|
|
12
|
+
height?: "full" | number;
|
|
13
|
+
themes?: CaptureTheme[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One screenshot of one screen. */
|
|
17
|
+
export interface CaptureShot {
|
|
18
|
+
width: number;
|
|
19
|
+
/** The viewport's height. With `fullPage` the image is as tall as the page; without, exactly this tall. */
|
|
20
|
+
viewportHeight: number;
|
|
21
|
+
fullPage: boolean;
|
|
22
|
+
theme: CaptureTheme;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_CAPTURE_WIDTHS = [375, 768, 1440];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The viewport height that goes with a width, for full-page captures too: elements sized in vh (an app shell,
|
|
29
|
+
* a full-height hero) take it, so the design and the implementation must be photographed with the same one.
|
|
30
|
+
*/
|
|
31
|
+
export function viewportHeightFor(width: number): number {
|
|
32
|
+
if (width <= 480) return 812;
|
|
33
|
+
if (width <= 1024) return 1024;
|
|
34
|
+
return 900;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MIN_SIZE = 200;
|
|
38
|
+
const MAX_SIZE = 4000;
|
|
39
|
+
|
|
40
|
+
/** The screenshots a flow block asks for, with Basis' defaults filled in and nonsense left out. */
|
|
41
|
+
export function captureShots(options: CaptureOptions | undefined): CaptureShot[] {
|
|
42
|
+
const widths = [...new Set((options?.widths?.length ? options.widths : DEFAULT_CAPTURE_WIDTHS).map(Math.round))]
|
|
43
|
+
.filter((width) => width >= MIN_SIZE && width <= MAX_SIZE)
|
|
44
|
+
.sort((a, b) => a - b);
|
|
45
|
+
const themes = [...new Set(options?.themes?.length ? options.themes : (["light"] as CaptureTheme[]))].filter(
|
|
46
|
+
(theme) => theme === "light" || theme === "dark",
|
|
47
|
+
);
|
|
48
|
+
const fixed = typeof options?.height === "number" ? Math.round(options.height) : null;
|
|
49
|
+
const height = fixed !== null && fixed >= MIN_SIZE && fixed <= MAX_SIZE ? fixed : null;
|
|
50
|
+
return themes.flatMap((theme) =>
|
|
51
|
+
widths.map((width) => ({
|
|
52
|
+
width,
|
|
53
|
+
viewportHeight: height ?? viewportHeightFor(width),
|
|
54
|
+
fullPage: height === null,
|
|
55
|
+
theme,
|
|
56
|
+
})),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The file name of a shot inside the kit: "screens/<view>/<screen>@<width>[-dark].png". */
|
|
61
|
+
export function shotFile(view: string, screen: string, shot: CaptureShot): string {
|
|
62
|
+
const theme = shot.theme === "light" ? "" : `-${shot.theme}`;
|
|
63
|
+
const height = shot.fullPage ? "" : `x${shot.viewportHeight}`;
|
|
64
|
+
return `screens/${view}/${screen}@${shot.width}${height}${theme}.png`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What the capture page reports about the views it can render (`?list`). */
|
|
68
|
+
export interface CaptureView {
|
|
69
|
+
id: string;
|
|
70
|
+
title: string;
|
|
71
|
+
screens: { id: string; label: string; shots: CaptureShot[] }[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The attribute the capture page sets on <html> once a screen has settled and can be photographed. */
|
|
75
|
+
export const READY_ATTRIBUTE = "data-basis-ready";
|
package/shared/protocol.ts
CHANGED
|
@@ -115,6 +115,29 @@ export interface ExportWriteResult {
|
|
|
115
115
|
error?: string;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** What a finished verification kit holds, for the Export dialog to report. */
|
|
119
|
+
export interface VerifyKitSummary {
|
|
120
|
+
/** The browser that took the screenshots, with its version. */
|
|
121
|
+
browser: string;
|
|
122
|
+
views: number;
|
|
123
|
+
screens: number;
|
|
124
|
+
shots: number;
|
|
125
|
+
/** Shots whose two captures differed: those screens do not render the same twice. */
|
|
126
|
+
unstable: string[];
|
|
127
|
+
/** Shots of screens that threw while rendering. */
|
|
128
|
+
failed: string[];
|
|
129
|
+
/** Specimen files that could not be loaded. */
|
|
130
|
+
problems: string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type VerifyKitResult =
|
|
134
|
+
| {
|
|
135
|
+
ok: true;
|
|
136
|
+
summary: VerifyKitSummary;
|
|
137
|
+
/** Where it was written; absent for a zip, which is then ready to download. */ dir?: string;
|
|
138
|
+
}
|
|
139
|
+
| { ok: false; error: string };
|
|
140
|
+
|
|
118
141
|
export interface DesignState {
|
|
119
142
|
design: Design;
|
|
120
143
|
/** Validation errors for the file on disk; null when it is valid. The preview keeps the last valid design. */
|
|
@@ -126,6 +149,17 @@ export interface DesignState {
|
|
|
126
149
|
/** Colours in tokens and stylesheets that stay the same in both themes (see shared/fixed-colors.ts). */
|
|
127
150
|
colorFindings: string[];
|
|
128
151
|
workspace: string;
|
|
152
|
+
/** A newer starter template whose changes the agent can bring into this workspace; null when caught up. */
|
|
153
|
+
templateUpdate: TemplateUpdate | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A newer starter template the workspace has not caught up with (see server/template.ts). */
|
|
157
|
+
export interface TemplateUpdate {
|
|
158
|
+
/** The template version the workspace last caught up with (0: before versions were recorded). */
|
|
159
|
+
from: number;
|
|
160
|
+
to: number;
|
|
161
|
+
/** Starter files the workspace has changed, which differ from the current template. */
|
|
162
|
+
files: string[];
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
/** A past version materialised for a review: its tokens, and where the preview can import its source. */
|
|
@@ -175,7 +209,10 @@ export type ServerMessage =
|
|
|
175
209
|
| { type: "chat-remove"; id: string }
|
|
176
210
|
| { type: "snapshot"; snapshot: Snapshot }
|
|
177
211
|
| { type: "export-manifest"; folder: string; files: { path: string; bytes: number }[] }
|
|
178
|
-
| { type: "export-result"; result: ExportWriteResult }
|
|
212
|
+
| { type: "export-result"; result: ExportWriteResult }
|
|
213
|
+
/** How far the verification kit has come; `done`/`total` while it takes screenshots. */
|
|
214
|
+
| { type: "verify-progress"; step: string; done?: number; total?: number }
|
|
215
|
+
| { type: "verify-result"; result: VerifyKitResult };
|
|
179
216
|
|
|
180
217
|
export type ClientMessage =
|
|
181
218
|
/** With `alternatives` (2 or 3), the agent makes that many takes on the prompt to choose between. */
|
|
@@ -207,6 +244,8 @@ export type ClientMessage =
|
|
|
207
244
|
| { type: "preview-errors"; errors: string[]; visible: boolean; checks?: string[]; checking?: boolean }
|
|
208
245
|
| { type: "export-manifest" }
|
|
209
246
|
| { type: "export-write"; target: string; overwrite: boolean }
|
|
247
|
+
/** Builds the verification kit: into `<project>/basis-verify/` with `project`, else as a zip to download. */
|
|
248
|
+
| { type: "verify-kit"; project?: string }
|
|
210
249
|
| { type: "project-switch"; id: string }
|
|
211
250
|
/** Creates a project from Basis' defaults, or as a copy of the open project's design system, and opens it. */
|
|
212
251
|
/**
|
|
@@ -223,4 +262,8 @@ export type ClientMessage =
|
|
|
223
262
|
*/
|
|
224
263
|
| { type: "project-repo"; id: string; repo: string | null; bringIn?: boolean }
|
|
225
264
|
/** Starts the guided turn that brings the open project's product repo design system into the workspace. */
|
|
226
|
-
| { type: "import-design" }
|
|
265
|
+
| { type: "import-design" }
|
|
266
|
+
/** Starts the guided turn that brings the newer starter template's changes into the workspace. */
|
|
267
|
+
| { type: "template-update" }
|
|
268
|
+
/** Marks the newer template as seen without bringing its changes in. */
|
|
269
|
+
| { type: "template-skip" };
|
package/src/Workbench.tsx
CHANGED
|
@@ -214,9 +214,10 @@ export function Workbench({ state, send }: { state: BridgeState; send: (message:
|
|
|
214
214
|
picking={picking}
|
|
215
215
|
onTogglePick={() => (picking ? finishPicking() : setPicking(true))}
|
|
216
216
|
reviewing={Boolean(review)}
|
|
217
|
+
resizer={!chatHidden && <ChatResizer width={chatWidth} onResize={setChatWidth} />}
|
|
217
218
|
/>
|
|
218
|
-
{!chatHidden && <ChatResizer width={chatWidth} onResize={setChatWidth} />}
|
|
219
219
|
<main className={styles.main}>
|
|
220
|
+
<h1 className="visually-hidden">Basis</h1>
|
|
220
221
|
<TopBar
|
|
221
222
|
chatHidden={chatHidden}
|
|
222
223
|
onToggleChat={() => setChatHidden((hidden) => !hidden)}
|
|
@@ -236,6 +237,7 @@ export function Workbench({ state, send }: { state: BridgeState; send: (message:
|
|
|
236
237
|
projects={state.projects}
|
|
237
238
|
agentBusy={state.agent?.status === "busy"}
|
|
238
239
|
error={state.projectError}
|
|
240
|
+
templateUpdate={state.design?.templateUpdate ?? null}
|
|
239
241
|
send={send}
|
|
240
242
|
/>
|
|
241
243
|
}
|
|
@@ -334,6 +336,7 @@ export function Workbench({ state, send }: { state: BridgeState; send: (message:
|
|
|
334
336
|
result={state.exportResult}
|
|
335
337
|
send={send}
|
|
336
338
|
onClose={() => setExporting(false)}
|
|
339
|
+
verify={{ repo: state.project?.repo, progress: state.verifyProgress, result: state.verifyResult }}
|
|
337
340
|
/>
|
|
338
341
|
)}
|
|
339
342
|
</div>
|