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/README.md
CHANGED
|
@@ -18,12 +18,12 @@ npx basis-design
|
|
|
18
18
|
|
|
19
19
|
Or install it once with `npm install -g basis-design` and start it with `basis`. Open http://localhost:5320. Basis starts Claude Code by default (shown as Claude in the picker above the chat); switch to Codex there. Basis needs Node.js 22.12 or later and `git` on the PATH.
|
|
20
20
|
|
|
21
|
-
- **Claude Code:** log in once with `claude` in a terminal. Basis
|
|
21
|
+
- **Claude Code:** install it and log in once with `claude` in a terminal. Basis runs your own Claude Code through `@agentclientprotocol/claude-agent-acp`, which npx fetches on first use without a copy of Claude Code of its own. If Basis does not find it, set `CLAUDE_CODE_EXECUTABLE` to its path.
|
|
22
22
|
- **Codex:** log in once with `codex login`. Basis starts it through `npx @agentclientprotocol/codex-acp`, at a fixed version that npx fetches on first use.
|
|
23
23
|
|
|
24
24
|
Usage counts against your own plan. A turn can take up to three agent runs (the turn and up to two fix-ups), and ×2 or ×3 alternatives multiply that.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
To work on Basis itself, clone the repository and run `npm install` and `npm run dev`.
|
|
27
27
|
|
|
28
28
|
| Variable | Default | Purpose |
|
|
29
29
|
| --- | --- | --- |
|
|
@@ -37,11 +37,12 @@ The first install is large, about 300 MB, because the Claude adapter brings its
|
|
|
37
37
|
|
|
38
38
|
Each project is a workspace of its own: its own design system, history, chat and review. Switch, create and open projects from the menu with the project's name at the top left.
|
|
39
39
|
|
|
40
|
-
A project can link the **product repo** it designs for (project menu, **Link product repo…**). The agent reads and searches it without asking, to match the product's look, components and terms, and never writes there: writes outside the workspace are refused. **New project…** can also start from a product repo: the project takes the repo folder's name unless you give one, links the repo and starts by bringing in its design. **Bring in its design** (offered when you link, and in the project menu) starts a guided turn: the agent maps the repo's tokens onto `design-system.json`, restyles the components and patterns to resemble the product's, writes what it learned to `product.md` (where the tokens live, an inventory of the product's components and their Basis counterparts, terms to reuse) and opens a review.
|
|
40
|
+
A project can link the **product repo** it designs for (project menu, **Link product repo…**): pick it from the repos Basis finds in your usual code folders, choose it with **Browse…** in your system's folder dialog, or paste its path. The agent reads and searches it without asking, to match the product's look, components and terms, and never writes there: writes outside the workspace are refused. **New project…** can also start from a product repo: the project takes the repo folder's name unless you give one, links the repo and starts by bringing in its design. **Bring in its design** (offered when you link, and in the project menu) starts a guided turn: the agent maps the repo's tokens onto `design-system.json`, restyles the components and patterns to resemble the product's, writes what it learned to `product.md` (where the tokens live, an inventory of the product's components and their Basis counterparts, terms to reuse) and opens a review.
|
|
41
41
|
|
|
42
42
|
- **New project** starts from Basis' defaults, or as a copy of the open project's tokens and components (not its history). It lives in `~/.basis/workspaces/`.
|
|
43
43
|
- **Open folder** adds a workspace from anywhere, or an empty or new folder for Basis to set up, inside a product repo for instance. Basis refuses a folder that already has other files, so it never starts a history in someone's project.
|
|
44
44
|
- Switching waits while the agent works. The agent of the project you leave stops, and coming back resumes its conversation, so it remembers where you were. The export remembers its target folder per project.
|
|
45
|
+
- When Basis' starter components improve, the files a project never changed update by themselves. For the ones the agent changed, the project menu offers a guided turn that brings the improvements in, as a review, keeping the project's own look.
|
|
45
46
|
- Removing a project from the list never deletes its folder. Earlier versions kept the project list in the Basis folder; it moves to `~/.basis` on first start, and its projects stay where they are. The `./workspace` of versions before projects becomes the first project.
|
|
46
47
|
|
|
47
48
|
## The workspace
|
|
@@ -106,6 +107,7 @@ Every `components/*.specimen.tsx` file becomes a gallery section and a sidebar e
|
|
|
106
107
|
- a README on wiring it up.
|
|
107
108
|
|
|
108
109
|
The kit needs React 18+ and CSS Modules, nothing else. The other tabs export the token formats one at a time.
|
|
110
|
+
- **Verification kit:** proof that a feature was built as its view shows. The **Verification kit** tab photographs every screen of every view at fixed widths (375, 768 and 1440 pixels, full page, unless the view's flow block sets `capture`) and packages the screenshots with a static, clickable copy of the design and two scripts, as a zip or into `basis-verify/` in the product's folder (the linked repo by default). The agent that implements the feature runs `capture.mjs` to photograph the design and its implementation on the same machine with the same settings, and `compare.mjs` to compare them pixel for pixel, aiming at 0% difference. Parts allowed to differ are named exceptions in `exceptions.json`, each with a reason. Basis takes the screenshots with Playwright's Chromium when it is installed (`npx playwright install chromium`), otherwise with Chrome or Edge; each screen is captured twice, and screens that render differently each time are reported, since nothing can match them.
|
|
109
111
|
|
|
110
112
|
## Privacy
|
|
111
113
|
|
package/capture.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Basis capture</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/capture/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,68 +1,72 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "basis-design",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Prompt a complete design system into existence with your own coding agent over ACP.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"design-system",
|
|
7
|
-
"design-tokens",
|
|
8
|
-
"react",
|
|
9
|
-
"claude-code",
|
|
10
|
-
"codex",
|
|
11
|
-
"agent-client-protocol"
|
|
12
|
-
],
|
|
13
|
-
"license": "Apache-2.0",
|
|
14
|
-
"author": "Lucas Skog",
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/sockulags/basis-design.git"
|
|
18
|
-
},
|
|
19
|
-
"type": "module",
|
|
20
|
-
"bin": {
|
|
21
|
-
"basis": "bin/basis.js"
|
|
22
|
-
},
|
|
23
|
-
"files": [
|
|
24
|
-
"bin",
|
|
25
|
-
"server",
|
|
26
|
-
"shared",
|
|
27
|
-
"src",
|
|
28
|
-
"template",
|
|
29
|
-
"index.html",
|
|
30
|
-
"preview.html",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"@
|
|
51
|
-
"@
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "basis-design",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Prompt a complete design system into existence with your own coding agent over ACP.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"design-system",
|
|
7
|
+
"design-tokens",
|
|
8
|
+
"react",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"agent-client-protocol"
|
|
12
|
+
],
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"author": "Lucas Skog",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/sockulags/basis-design.git"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"basis": "bin/basis.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"server",
|
|
26
|
+
"shared",
|
|
27
|
+
"src",
|
|
28
|
+
"template",
|
|
29
|
+
"index.html",
|
|
30
|
+
"preview.html",
|
|
31
|
+
"capture.html",
|
|
32
|
+
"vite.config.ts",
|
|
33
|
+
"NOTICE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.12.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsx server/index.ts",
|
|
40
|
+
"start": "tsx server/index.ts",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "node --import tsx --test \"test/*.test.ts\"",
|
|
43
|
+
"test:e2e": "playwright test",
|
|
44
|
+
"template:history": "tsx scripts/template-history.ts",
|
|
45
|
+
"lint": "oxlint",
|
|
46
|
+
"format": "prettier --write .",
|
|
47
|
+
"check": "tsc --noEmit && oxlint && prettier --check . && npm test"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@agentclientprotocol/sdk": "^1.9.0",
|
|
51
|
+
"@types/react": "^19.3.0",
|
|
52
|
+
"@types/react-dom": "^19.3.0",
|
|
53
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
54
|
+
"axe-core": "^4.14.0",
|
|
55
|
+
"fflate": "^0.8.3",
|
|
56
|
+
"playwright-core": "1.64.1",
|
|
57
|
+
"react": "^19.3.1",
|
|
58
|
+
"react-dom": "^19.3.1",
|
|
59
|
+
"tsx": "^4.23.15",
|
|
60
|
+
"typescript": "^7.0.2",
|
|
61
|
+
"vite": "^8.3.4",
|
|
62
|
+
"ws": "^8.22.0",
|
|
63
|
+
"zod": "^4.6.7"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@playwright/test": "^1.64.1",
|
|
67
|
+
"@types/node": "^26.6.5",
|
|
68
|
+
"@types/ws": "^8.18.2",
|
|
69
|
+
"oxlint": "1.88.0",
|
|
70
|
+
"prettier": "3.9.10"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/server/agent.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
4
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { Readable, Writable } from "node:stream";
|
|
8
7
|
import * as acp from "@agentclientprotocol/sdk";
|
|
@@ -22,35 +21,52 @@ import { ALTERNATIVE_LABELS, describeTarget, MAX_ALTERNATIVES } from "../shared/
|
|
|
22
21
|
import { loadChat, saveChat } from "./chatlog.ts";
|
|
23
22
|
import { PRODUCT_FILE, relativeInside } from "./paths.ts";
|
|
24
23
|
import { AGENT_BRIEF, FIRST_PROMPT_PREAMBLE } from "./brief.ts";
|
|
24
|
+
import { CLAUDE_MISSING, findClaude } from "./claude.ts";
|
|
25
25
|
import type { Workspace } from "./workspace.ts";
|
|
26
26
|
|
|
27
|
-
const require = createRequire(import.meta.url);
|
|
28
|
-
|
|
29
27
|
interface LaunchSpec {
|
|
30
28
|
command: string;
|
|
31
29
|
args: string[];
|
|
32
30
|
shell: boolean;
|
|
33
31
|
loginHint: string;
|
|
32
|
+
/** Variables the agent needs on top of Basis' own environment. */
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
/** Why the agent cannot start, shown instead of starting it. */
|
|
35
|
+
missing?: string;
|
|
36
|
+
/** Added when the agent exits on its own, for the failures an update fixes. */
|
|
37
|
+
exitHint?: string;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The adapters Basis was tested with, at fixed versions: an unpinned one would fetch whatever was published last.
|
|
42
|
+
* npx fetches each on first use and reuses it after (--prefer-offline, so Basis also starts offline).
|
|
43
|
+
*/
|
|
44
|
+
const CLAUDE_ACP = "@agentclientprotocol/claude-agent-acp@0.92.0";
|
|
37
45
|
const CODEX_ACP = "@agentclientprotocol/codex-acp@2.2.2";
|
|
38
46
|
|
|
39
47
|
/** How each supported agent is started. Both reuse the user's own CLI login, so no API keys are needed. */
|
|
40
48
|
function launchSpec(id: AgentId): LaunchSpec {
|
|
49
|
+
// The end-to-end tests run a stand-in agent (test/e2e/fake-agent.mjs), since no CLI is logged in on CI.
|
|
50
|
+
if (process.env.BASIS_AGENT_SCRIPT) {
|
|
51
|
+
const script = path.resolve(process.env.BASIS_AGENT_SCRIPT);
|
|
52
|
+
return { command: process.execPath, args: [script], shell: false, loginHint: "" };
|
|
53
|
+
}
|
|
41
54
|
if (id === "claude") {
|
|
42
|
-
|
|
55
|
+
// Without --omit=optional the adapter's SDK brings its own copy of Claude Code; Basis runs the user's instead.
|
|
56
|
+
const claude = findClaude();
|
|
43
57
|
return {
|
|
44
|
-
command:
|
|
45
|
-
args: [
|
|
46
|
-
shell:
|
|
58
|
+
command: "npx",
|
|
59
|
+
args: ["-y", "--prefer-offline", "--omit=optional", CLAUDE_ACP],
|
|
60
|
+
shell: true,
|
|
61
|
+
env: claude ? { CLAUDE_CODE_EXECUTABLE: claude } : undefined,
|
|
62
|
+
missing: claude ? undefined : CLAUDE_MISSING,
|
|
47
63
|
loginHint: "Run `claude` in a terminal and log in, then restart the agent.",
|
|
64
|
+
exitHint: "If it keeps happening, update Claude Code with `claude update`.",
|
|
48
65
|
};
|
|
49
66
|
}
|
|
50
67
|
return {
|
|
51
68
|
command: "npx",
|
|
52
|
-
|
|
53
|
-
args: ["-y", CODEX_ACP],
|
|
69
|
+
args: ["-y", "--prefer-offline", CODEX_ACP],
|
|
54
70
|
shell: true,
|
|
55
71
|
loginHint: "Run `codex login` in a terminal, then restart the agent.",
|
|
56
72
|
};
|
|
@@ -180,13 +196,17 @@ export class AgentHost extends EventEmitter<Events> {
|
|
|
180
196
|
await this.stop();
|
|
181
197
|
this.setState({ id: agentId, status: "starting", detail: "Starting agent…" });
|
|
182
198
|
const spec = launchSpec(agentId);
|
|
199
|
+
if (spec.missing) {
|
|
200
|
+
this.fail(spec.missing);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
183
203
|
// Through a shell, the command goes as one line: Node no longer accepts separate arguments there.
|
|
184
204
|
const [command, args] = spec.shell ? [[spec.command, ...spec.args].join(" "), []] : [spec.command, spec.args];
|
|
185
205
|
const proc = spawn(command, args, {
|
|
186
206
|
cwd: this.workspace.dir,
|
|
187
207
|
shell: spec.shell,
|
|
188
208
|
stdio: ["pipe", "pipe", "pipe"],
|
|
189
|
-
env: agentEnv(),
|
|
209
|
+
env: { ...agentEnv(), ...spec.env },
|
|
190
210
|
windowsHide: true,
|
|
191
211
|
});
|
|
192
212
|
this.proc = proc;
|
|
@@ -204,7 +224,8 @@ export class AgentHost extends EventEmitter<Events> {
|
|
|
204
224
|
this.connection = undefined;
|
|
205
225
|
this.sessionId = undefined;
|
|
206
226
|
const tail = this.stderrTail.slice(-3).join(" ");
|
|
207
|
-
|
|
227
|
+
const hint = spec.exitHint ? ` ${spec.exitHint}` : "";
|
|
228
|
+
this.fail(`The agent exited (code ${code ?? "?"}).${tail ? ` ${tail}` : ""}${hint}`);
|
|
208
229
|
});
|
|
209
230
|
|
|
210
231
|
const stream = acp.ndJsonStream(
|
package/server/brief.ts
CHANGED
|
@@ -160,9 +160,20 @@ The rules that always apply:
|
|
|
160
160
|
restyle or copy them inside a view: if a screen needs something they lack, extend the component or pattern,
|
|
161
161
|
or add a pattern. A view's own code is layout and wiring; a small layout style inline or in a
|
|
162
162
|
\`.module.css\` next to it is fine.
|
|
163
|
-
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
- A screen is the whole page as the product shows it at that size: its shell, navigation and page padding
|
|
164
|
+
included, filling the window. The verification kit photographs each screen alone at fixed widths, and the
|
|
165
|
+
implementation of the feature must match those images pixel for pixel.
|
|
166
|
+
- Fill views with realistic data, consistent across the screens (the row you open is the one the detail
|
|
167
|
+
shows). When a product repo is linked, use the product's own data where you can find it (fixtures, seeds,
|
|
168
|
+
example responses, its wording), so the implementation can show exactly the same records. Otherwise make it
|
|
169
|
+
up in the product's language. Keep data in the view file or a \`views/*-data.ts\` file.
|
|
170
|
+
- Screens must render the same every time: no \`Date.now()\`, \`new Date()\` without a fixed date, random
|
|
171
|
+
values, or animations and loading states that never settle. Basis captures each screen twice and reports
|
|
172
|
+
the ones that differ.
|
|
173
|
+
- A flow block may say how its screens are captured: \`capture: { widths: [375, 768, 1440], height: "full",
|
|
174
|
+
themes: ["light"] }\` (those are the defaults). \`height\` is \`"full"\` for the whole page or a number for
|
|
175
|
+
exactly one viewport; add \`"dark"\` to \`themes\` when the product has a dark theme. Set widths to the
|
|
176
|
+
product's breakpoints when you know them.
|
|
166
177
|
- Views are not exported with the design system and need no contract or index.
|
|
167
178
|
- To review a view, show its specimen; \`rows\` picks screens by label or id (\`"rows": ["Shipment"]\`). The panes
|
|
168
179
|
of a review move through the flow together, so before and after always show the same screen.
|
|
@@ -350,6 +361,24 @@ export function importInstruction(repo: string): string {
|
|
|
350
361
|
].join("\n");
|
|
351
362
|
}
|
|
352
363
|
|
|
364
|
+
/**
|
|
365
|
+
* The guided turn that brings a newer starter template into a workspace whose agent changed the starter files.
|
|
366
|
+
* `changes` is the part of template/CHANGES.md the workspace has not caught up with.
|
|
367
|
+
*/
|
|
368
|
+
export function templateInstruction(changes: string, files: string[]): string {
|
|
369
|
+
return [
|
|
370
|
+
"Basis has improved its starter template since this design system started from it. Bring the improvements below into this workspace's own components, patterns and views, in this turn.",
|
|
371
|
+
"",
|
|
372
|
+
changes,
|
|
373
|
+
"",
|
|
374
|
+
`1. The current template is in .basis/template/ (components/, patterns/, views/). These files here differ from it because they were changed: ${files.join(", ")}. Read each and its template version with your read tool; the list above says what changed, so you need no shell commands or diffs.`,
|
|
375
|
+
"2. Bring in each improvement where the workspace's file still has the part it concerns. Keep this design system's own look, tokens, variants and additions, and every contract. If the workspace already solves it another way, leave it.",
|
|
376
|
+
"3. Edit the files with your tools; never copy a template file over the workspace's own.",
|
|
377
|
+
"4. Write review.json showing the specimens that changed most (up to four), so the user compares before and after.",
|
|
378
|
+
"Finish with a short summary: which improvements you brought in and where, and what you left out and why.",
|
|
379
|
+
].join("\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
353
382
|
/** Prepended to the first prompt when the agent cannot take an appended system prompt. */
|
|
354
383
|
export const FIRST_PROMPT_PREAMBLE =
|
|
355
384
|
"You are working inside Basis. Read AGENTS.md in the current folder first and follow it: you edit only design-system.json, review.json, product.md and files under components/, patterns/ and views/.";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { chromium, type Browser, type LaunchOptions } from "playwright-core";
|
|
2
|
+
import { READY_ATTRIBUTE, shotFile, type CaptureShot, type CaptureView } from "../shared/capture.ts";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Taking the verification kit's screenshots: a headless browser opens each screen of each view on the capture
|
|
6
|
+
* page (capture.html) at the size its shots ask for. Everything that could make two runs differ is fixed: the
|
|
7
|
+
* device scale, the colour profile, text antialiasing, reduced motion. Each shot is taken twice, and a screen
|
|
8
|
+
* whose two images differ is reported as unstable, since nothing could ever match it pixel for pixel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Chromium flags that keep rendering the same between runs and machines of one platform. */
|
|
12
|
+
export const CAPTURE_ARGS = [
|
|
13
|
+
"--force-color-profile=srgb",
|
|
14
|
+
"--font-render-hinting=none",
|
|
15
|
+
"--disable-lcd-text",
|
|
16
|
+
"--hide-scrollbars",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export interface CaptureBrowser {
|
|
20
|
+
browser: Browser;
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Playwright's own Chromium when it is installed (`npx playwright install chromium`): the same build on every
|
|
27
|
+
* machine, which is what comparing screenshots wants. Otherwise the Chrome or Edge already on this computer.
|
|
28
|
+
*/
|
|
29
|
+
export async function launchCaptureBrowser(): Promise<CaptureBrowser> {
|
|
30
|
+
const candidates: { name: string; options: LaunchOptions }[] = [
|
|
31
|
+
{ name: "Playwright Chromium", options: {} },
|
|
32
|
+
{ name: "Google Chrome", options: { channel: "chrome" } },
|
|
33
|
+
{ name: "Microsoft Edge", options: { channel: "msedge" } },
|
|
34
|
+
];
|
|
35
|
+
for (const { name, options } of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
const browser = await chromium.launch({ ...options, headless: true, args: CAPTURE_ARGS });
|
|
38
|
+
return { browser, name, version: browser.version() };
|
|
39
|
+
} catch {
|
|
40
|
+
// Not installed; try the next one.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(
|
|
44
|
+
"No browser to take the screenshots with. Install Google Chrome or Microsoft Edge, or run `npx playwright install chromium`.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Shot extends CaptureShot {
|
|
49
|
+
view: string;
|
|
50
|
+
screen: string;
|
|
51
|
+
file: string;
|
|
52
|
+
png: Buffer;
|
|
53
|
+
/** The image's height in pixels: the page's height for full-page shots. */
|
|
54
|
+
imageHeight: number;
|
|
55
|
+
/** False when two captures of the shot differed, so the screen does not render the same every time. */
|
|
56
|
+
stable: boolean;
|
|
57
|
+
/** Set when the screen threw while rendering. */
|
|
58
|
+
failed: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CaptureResult {
|
|
62
|
+
views: CaptureView[];
|
|
63
|
+
problems: string[];
|
|
64
|
+
shots: Shot[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The height stored in a PNG's IHDR chunk. */
|
|
68
|
+
function pngHeight(png: Buffer): number {
|
|
69
|
+
return png.length >= 24 ? png.readUInt32BE(20) : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Captures every shot of every view the capture page at `pageUrl` lists. `pageUrl` is the page without a query
|
|
74
|
+
* (".../capture.html" on the preview server, ".../index.html" in the kit's copy of the design).
|
|
75
|
+
*/
|
|
76
|
+
export async function captureViews(
|
|
77
|
+
pageUrl: string,
|
|
78
|
+
browser: Browser,
|
|
79
|
+
onProgress?: (done: number, total: number) => void,
|
|
80
|
+
): Promise<CaptureResult> {
|
|
81
|
+
const context = await browser.newContext({
|
|
82
|
+
deviceScaleFactor: 1,
|
|
83
|
+
reducedMotion: "reduce",
|
|
84
|
+
colorScheme: "light",
|
|
85
|
+
viewport: { width: 1440, height: 900 },
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
const page = await context.newPage();
|
|
89
|
+
await page.goto(`${pageUrl}?list`);
|
|
90
|
+
await page.waitForSelector(`html[${READY_ATTRIBUTE}]`, { state: "attached", timeout: 60_000 });
|
|
91
|
+
const listed = await page.evaluate(() => window.__basisViews);
|
|
92
|
+
const views = listed?.views ?? [];
|
|
93
|
+
const problems = listed?.problems ?? [];
|
|
94
|
+
const total = views.reduce((sum, view) => sum + view.screens.reduce((count, s) => count + s.shots.length, 0), 0);
|
|
95
|
+
const shots: Shot[] = [];
|
|
96
|
+
for (const view of views) {
|
|
97
|
+
for (const screen of view.screens) {
|
|
98
|
+
for (const shot of screen.shots) {
|
|
99
|
+
const url = `${pageUrl}?view=${encodeURIComponent(view.id)}&screen=${encodeURIComponent(screen.id)}&theme=${shot.theme}`;
|
|
100
|
+
await page.setViewportSize({ width: shot.width, height: shot.viewportHeight });
|
|
101
|
+
const take = async () => {
|
|
102
|
+
await page.goto(url);
|
|
103
|
+
await page.waitForSelector(`html[${READY_ATTRIBUTE}]`, { state: "attached", timeout: 60_000 });
|
|
104
|
+
const failed = await page.evaluate(() => document.documentElement.hasAttribute("data-basis-error"));
|
|
105
|
+
const png = await page.screenshot({ fullPage: shot.fullPage, animations: "disabled", caret: "hide" });
|
|
106
|
+
return { png, failed };
|
|
107
|
+
};
|
|
108
|
+
const first = await take();
|
|
109
|
+
const second = await take();
|
|
110
|
+
shots.push({
|
|
111
|
+
...shot,
|
|
112
|
+
view: view.id,
|
|
113
|
+
screen: screen.id,
|
|
114
|
+
file: shotFile(view.id, screen.id, shot),
|
|
115
|
+
png: first.png,
|
|
116
|
+
imageHeight: pngHeight(first.png),
|
|
117
|
+
stable: first.png.equals(second.png),
|
|
118
|
+
failed: first.failed,
|
|
119
|
+
});
|
|
120
|
+
onProgress?.(shots.length, total);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { views, problems, shots };
|
|
125
|
+
} finally {
|
|
126
|
+
await context.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
package/server/claude.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Basis runs the user's own Claude Code rather than a copy of its own: the Claude adapter is fetched without the
|
|
7
|
+
* native binary its SDK would otherwise bring (about 260 MB), and is pointed at the user's executable through
|
|
8
|
+
* CLAUDE_CODE_EXECUTABLE. The user already has it, logged in, since that is how they sign in to Claude.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface Where {
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
platform: NodeJS.Platform;
|
|
14
|
+
home: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isFile = (file: string) => {
|
|
18
|
+
try {
|
|
19
|
+
return statSync(file).isFile();
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The user's Claude Code executable, or null when it is not installed. In order: CLAUDE_CODE_EXECUTABLE when it
|
|
27
|
+
* names a file, `claude` on the PATH, then the native installer's folders. On Windows the npm install puts a
|
|
28
|
+
* `claude.cmd` shim on the PATH, which cannot be started without a shell, so the executable behind it is used.
|
|
29
|
+
*/
|
|
30
|
+
export function findClaude(
|
|
31
|
+
{ env, platform, home }: Where = { env: process.env, platform: process.platform, home: os.homedir() },
|
|
32
|
+
): string | null {
|
|
33
|
+
const explicit = env.CLAUDE_CODE_EXECUTABLE;
|
|
34
|
+
if (explicit && isFile(explicit)) return explicit;
|
|
35
|
+
|
|
36
|
+
const windows = platform === "win32";
|
|
37
|
+
// Paths are on this machine's file system; `platform` only decides the names and the PATH separator.
|
|
38
|
+
const join = path.join;
|
|
39
|
+
const pathVar = env.PATH ?? env.Path ?? "";
|
|
40
|
+
const dirs = pathVar.split(windows ? ";" : ":").filter(Boolean);
|
|
41
|
+
for (const dir of dirs) {
|
|
42
|
+
if (windows) {
|
|
43
|
+
const exe = join(dir, "claude.exe");
|
|
44
|
+
if (isFile(exe)) return exe;
|
|
45
|
+
// The npm shims (claude, claude.cmd) start this executable.
|
|
46
|
+
const npm = join(dir, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
|
|
47
|
+
if ((isFile(join(dir, "claude.cmd")) || isFile(join(dir, "claude"))) && isFile(npm)) return npm;
|
|
48
|
+
} else {
|
|
49
|
+
const bin = join(dir, "claude");
|
|
50
|
+
if (isFile(bin)) return realpath(bin);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const installed = windows
|
|
55
|
+
? [join(home, ".local", "bin", "claude.exe")]
|
|
56
|
+
: [join(home, ".local", "bin", "claude"), join(home, ".claude", "local", "claude")];
|
|
57
|
+
for (const file of installed) if (isFile(file)) return windows ? file : realpath(file);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Symlinks (the native installer links to a versioned binary) resolved, so the agent starts the binary itself. */
|
|
62
|
+
function realpath(file: string): string {
|
|
63
|
+
try {
|
|
64
|
+
return realpathSync(file);
|
|
65
|
+
} catch {
|
|
66
|
+
return file;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the user is told when Basis cannot find Claude Code. */
|
|
71
|
+
export const CLAUDE_MISSING =
|
|
72
|
+
"Basis runs your own Claude Code, and it is not installed. Install it (https://claude.com/claude-code), log in once with `claude` in a terminal, then pick Claude again. If it is installed, set CLAUDE_CODE_EXECUTABLE to its path.";
|