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.
@@ -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
+ }
@@ -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";
@@ -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>