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
|
@@ -2,8 +2,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import type { Design } from "../../shared/design.ts";
|
|
3
3
|
import type { ClientMessage, ExportWriteResult } from "../../shared/protocol.ts";
|
|
4
4
|
import { exportCss, exportJson, exportTailwind } from "../../shared/tokens.ts";
|
|
5
|
+
import { BrowseButton } from "./FolderField.tsx";
|
|
5
6
|
import { readPref, writePref } from "./prefs.ts";
|
|
6
7
|
import styles from "./Shell.module.css";
|
|
8
|
+
import { VerifyKit, type VerifyKitProps } from "./VerifyKit.tsx";
|
|
7
9
|
|
|
8
10
|
const FORMATS = [
|
|
9
11
|
{ id: "css", label: "CSS variables", file: "tokens.css", type: "text/css", build: exportCss },
|
|
@@ -11,7 +13,7 @@ const FORMATS = [
|
|
|
11
13
|
{ id: "json", label: "JSON", file: "design-system.json", type: "application/json", build: exportJson },
|
|
12
14
|
] as const;
|
|
13
15
|
|
|
14
|
-
type Tab = "kit" | (typeof FORMATS)[number]["id"];
|
|
16
|
+
type Tab = "kit" | "verify" | (typeof FORMATS)[number]["id"];
|
|
15
17
|
|
|
16
18
|
interface ExportDialogProps {
|
|
17
19
|
design: Design;
|
|
@@ -22,6 +24,7 @@ interface ExportDialogProps {
|
|
|
22
24
|
result: ExportWriteResult | null;
|
|
23
25
|
send: (message: ClientMessage) => void;
|
|
24
26
|
onClose: () => void;
|
|
27
|
+
verify: Omit<VerifyKitProps, "workspace" | "send">;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/** The folder the kit was last written to, remembered per project so one never writes into another's. */
|
|
@@ -40,6 +43,7 @@ function StarterKit({
|
|
|
40
43
|
}: Pick<ExportDialogProps, "workspace" | "problems" | "manifest" | "result" | "send">) {
|
|
41
44
|
const [target, setTarget] = useState(() => readPref(targetPref(workspace)) ?? "");
|
|
42
45
|
const [pending, setPending] = useState(false);
|
|
46
|
+
const [browseError, setBrowseError] = useState<string | null>(null);
|
|
43
47
|
// Only show results of writes made while this dialog is open.
|
|
44
48
|
const [stale] = useState(latest);
|
|
45
49
|
const result = latest === stale ? null : latest;
|
|
@@ -103,13 +107,20 @@ function StarterKit({
|
|
|
103
107
|
id="export-target"
|
|
104
108
|
value={target}
|
|
105
109
|
onChange={(event) => setTarget(event.target.value)}
|
|
106
|
-
placeholder="
|
|
110
|
+
placeholder="Browse, or paste the folder's path"
|
|
107
111
|
spellCheck={false}
|
|
108
112
|
/>
|
|
113
|
+
<BrowseButton
|
|
114
|
+
title="Choose the folder to write the starter kit into"
|
|
115
|
+
start={target}
|
|
116
|
+
onPick={setTarget}
|
|
117
|
+
onError={setBrowseError}
|
|
118
|
+
/>
|
|
109
119
|
<button type="submit" className={styles.chromeButton} disabled={!target.trim() || pending}>
|
|
110
120
|
{pending ? "Writing…" : "Write files"}
|
|
111
121
|
</button>
|
|
112
122
|
</div>
|
|
123
|
+
{browseError && <p className={styles.kitError}>{browseError}</p>}
|
|
113
124
|
{result?.error && <p className={styles.kitError}>{result.error}</p>}
|
|
114
125
|
{conflicts && (
|
|
115
126
|
<div className={styles.kitConflicts}>
|
|
@@ -138,7 +149,16 @@ function StarterKit({
|
|
|
138
149
|
);
|
|
139
150
|
}
|
|
140
151
|
|
|
141
|
-
export function ExportDialog({
|
|
152
|
+
export function ExportDialog({
|
|
153
|
+
design,
|
|
154
|
+
workspace,
|
|
155
|
+
problems,
|
|
156
|
+
manifest,
|
|
157
|
+
result,
|
|
158
|
+
send,
|
|
159
|
+
onClose,
|
|
160
|
+
verify,
|
|
161
|
+
}: ExportDialogProps) {
|
|
142
162
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
143
163
|
const [tab, setTab] = useState<Tab>("kit");
|
|
144
164
|
const [copied, setCopied] = useState(false);
|
|
@@ -189,6 +209,9 @@ export function ExportDialog({ design, workspace, problems, manifest, result, se
|
|
|
189
209
|
<button type="button" role="tab" aria-selected={tab === "kit"} onClick={() => setTab("kit")}>
|
|
190
210
|
Starter kit
|
|
191
211
|
</button>
|
|
212
|
+
<button type="button" role="tab" aria-selected={tab === "verify"} onClick={() => setTab("verify")}>
|
|
213
|
+
Verification kit
|
|
214
|
+
</button>
|
|
192
215
|
{FORMATS.map((item) => (
|
|
193
216
|
<button
|
|
194
217
|
key={item.id}
|
|
@@ -203,11 +226,25 @@ export function ExportDialog({ design, workspace, problems, manifest, result, se
|
|
|
203
226
|
</div>
|
|
204
227
|
{tab === "kit" ? (
|
|
205
228
|
<StarterKit workspace={workspace} problems={problems} manifest={manifest} result={result} send={send} />
|
|
229
|
+
) : tab === "verify" ? (
|
|
230
|
+
<VerifyKit workspace={workspace} send={send} {...verify} />
|
|
206
231
|
) : (
|
|
207
232
|
<pre className={styles.code}>{code}</pre>
|
|
208
233
|
)}
|
|
209
234
|
<footer className={styles.dialogFooter}>
|
|
210
|
-
{tab === "
|
|
235
|
+
{tab === "verify" ? (
|
|
236
|
+
<>
|
|
237
|
+
<span className={styles.muted}>basis-verify/ · takes a few seconds per screen</span>
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
className={styles.primaryButton}
|
|
241
|
+
disabled={verify.progress !== null}
|
|
242
|
+
onClick={() => send({ type: "verify-kit" })}
|
|
243
|
+
>
|
|
244
|
+
{verify.progress ? "Building…" : "Build .zip"}
|
|
245
|
+
</button>
|
|
246
|
+
</>
|
|
247
|
+
) : tab === "kit" ? (
|
|
211
248
|
<>
|
|
212
249
|
<span className={styles.muted}>{manifest ? `${manifest.folder}.zip` : ""}</span>
|
|
213
250
|
<a className={styles.primaryButton} href="/export/starter-kit.zip" download>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import styles from "./Shell.module.css";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Choosing a folder without typing its path: the operating system's own folder dialog, opened by the Basis server
|
|
6
|
+
* (it runs on this computer), and the git repos it finds in the usual places for code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
async function api<T>(path: string, body: object = {}): Promise<T> {
|
|
10
|
+
const response = await fetch(path, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify(body),
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) throw new Error(`Basis answered ${response.status}`);
|
|
16
|
+
return (await response.json()) as T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface BrowseButtonProps {
|
|
20
|
+
/** The title of the system's folder dialog. */
|
|
21
|
+
title: string;
|
|
22
|
+
/** Where the dialog starts; the user's home folder when empty or missing. */
|
|
23
|
+
start: string;
|
|
24
|
+
onPick: (path: string) => void;
|
|
25
|
+
onError: (message: string | null) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Opens the system's folder dialog; the folder the user chooses fills the field. */
|
|
29
|
+
export function BrowseButton({ title, start, onPick, onError }: BrowseButtonProps) {
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
const browse = async () => {
|
|
32
|
+
setOpen(true);
|
|
33
|
+
onError(null);
|
|
34
|
+
try {
|
|
35
|
+
const answer = await api<{ path: string | null; error?: string }>("/api/pick-folder", { title, start });
|
|
36
|
+
if (answer.path) onPick(answer.path);
|
|
37
|
+
if (answer.error) onError(answer.error);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
onError((error as Error).message);
|
|
40
|
+
} finally {
|
|
41
|
+
setOpen(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return (
|
|
45
|
+
<button type="button" className={styles.chromeButton} onClick={() => void browse()} disabled={open}>
|
|
46
|
+
{open ? "Choose in the dialog…" : "Browse…"}
|
|
47
|
+
</button>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RepoSuggestion {
|
|
52
|
+
name: string;
|
|
53
|
+
dir: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface FolderFieldProps {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
value: string;
|
|
60
|
+
onChange: (path: string) => void;
|
|
61
|
+
placeholder: string;
|
|
62
|
+
/** The title of the system's folder dialog. */
|
|
63
|
+
dialogTitle: string;
|
|
64
|
+
/** Offers the git repos found on this computer, the most recently used first. */
|
|
65
|
+
suggestRepos?: boolean;
|
|
66
|
+
autoFocus?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A folder path: typed or pasted, chosen in the system's dialog, or picked from the repos Basis found. */
|
|
70
|
+
export function FolderField({
|
|
71
|
+
id,
|
|
72
|
+
label,
|
|
73
|
+
value,
|
|
74
|
+
onChange,
|
|
75
|
+
placeholder,
|
|
76
|
+
dialogTitle,
|
|
77
|
+
suggestRepos = false,
|
|
78
|
+
autoFocus,
|
|
79
|
+
}: FolderFieldProps) {
|
|
80
|
+
const [error, setError] = useState<string | null>(null);
|
|
81
|
+
const [repos, setRepos] = useState<RepoSuggestion[]>([]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!suggestRepos) return;
|
|
85
|
+
let current = true;
|
|
86
|
+
api<{ repos: RepoSuggestion[] }>("/api/repos")
|
|
87
|
+
.then((answer) => current && setRepos(answer.repos))
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
return () => {
|
|
90
|
+
current = false;
|
|
91
|
+
};
|
|
92
|
+
}, [suggestRepos]);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<label htmlFor={id}>{label}</label>
|
|
97
|
+
<div className={styles.folderRow}>
|
|
98
|
+
<input
|
|
99
|
+
id={id}
|
|
100
|
+
value={value}
|
|
101
|
+
onChange={(event) => onChange(event.target.value)}
|
|
102
|
+
placeholder={placeholder}
|
|
103
|
+
spellCheck={false}
|
|
104
|
+
autoComplete="off"
|
|
105
|
+
autoFocus={autoFocus}
|
|
106
|
+
/>
|
|
107
|
+
<BrowseButton title={dialogTitle} start={value} onPick={onChange} onError={setError} />
|
|
108
|
+
</div>
|
|
109
|
+
{error && <p className={styles.kitError}>{error}</p>}
|
|
110
|
+
{repos.length > 0 && (
|
|
111
|
+
<div className={styles.repoSuggestions} role="group" aria-label="Repos on this computer">
|
|
112
|
+
<span className={styles.muted}>Repos on this computer</span>
|
|
113
|
+
<div>
|
|
114
|
+
{repos.slice(0, 8).map((repo) => (
|
|
115
|
+
<button
|
|
116
|
+
key={repo.dir}
|
|
117
|
+
type="button"
|
|
118
|
+
className={styles.repoChip}
|
|
119
|
+
aria-pressed={value === repo.dir}
|
|
120
|
+
title={repo.dir}
|
|
121
|
+
onClick={() => onChange(repo.dir)}
|
|
122
|
+
>
|
|
123
|
+
{repo.name}
|
|
124
|
+
</button>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
-
import type { ClientMessage, ProjectEntry } from "../../shared/protocol.ts";
|
|
2
|
+
import type { ClientMessage, ProjectEntry, TemplateUpdate } from "../../shared/protocol.ts";
|
|
3
|
+
import { FolderField } from "./FolderField.tsx";
|
|
3
4
|
import styles from "./Shell.module.css";
|
|
4
5
|
|
|
5
6
|
interface ProjectMenuProps {
|
|
@@ -9,6 +10,8 @@ interface ProjectMenuProps {
|
|
|
9
10
|
agentBusy: boolean;
|
|
10
11
|
/** The server's answer to the last failed project request. */
|
|
11
12
|
error: { message: string } | null;
|
|
13
|
+
/** A newer starter template the open project can bring in. */
|
|
14
|
+
templateUpdate: TemplateUpdate | null;
|
|
12
15
|
send: (message: ClientMessage) => void;
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -18,7 +21,7 @@ type Dialog = "create" | "open" | "repo" | null;
|
|
|
18
21
|
* The open project, where the brand used to be, with the list of projects:
|
|
19
22
|
* switch to one, start a new one, or open a workspace folder from elsewhere.
|
|
20
23
|
*/
|
|
21
|
-
export function ProjectMenu({ project, projects, agentBusy, error, send }: ProjectMenuProps) {
|
|
24
|
+
export function ProjectMenu({ project, projects, agentBusy, error, templateUpdate, send }: ProjectMenuProps) {
|
|
22
25
|
const [open, setOpen] = useState(false);
|
|
23
26
|
const [dialog, setDialog] = useState<Dialog>(null);
|
|
24
27
|
/** The error when the last request went out; a different one (even with the same text) is the answer to it. */
|
|
@@ -66,6 +69,11 @@ export function ProjectMenu({ project, projects, agentBusy, error, send }: Proje
|
|
|
66
69
|
>
|
|
67
70
|
<span className={styles.brandMark} aria-hidden />
|
|
68
71
|
<span className={styles.projectName}>{project?.name ?? "Basis"}</span>
|
|
72
|
+
{templateUpdate && (
|
|
73
|
+
<span className={styles.updateDot}>
|
|
74
|
+
<span className="visually-hidden">, starter template update available</span>
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
69
77
|
<svg viewBox="0 0 16 16" aria-hidden>
|
|
70
78
|
<path d="m4.5 6.5 3.5 3.5 3.5-3.5" />
|
|
71
79
|
</svg>
|
|
@@ -154,6 +162,34 @@ export function ProjectMenu({ project, projects, agentBusy, error, send }: Proje
|
|
|
154
162
|
</div>
|
|
155
163
|
</div>
|
|
156
164
|
)}
|
|
165
|
+
{templateUpdate && (
|
|
166
|
+
<div className={styles.projectRepo}>
|
|
167
|
+
<span className={styles.projectRepoHint}>
|
|
168
|
+
Basis' starter components have improved since this project began. Files nobody changed are already up to
|
|
169
|
+
date; the agent brings the improvements into{" "}
|
|
170
|
+
{templateUpdate.files.length === 1
|
|
171
|
+
? "the one changed file"
|
|
172
|
+
: `${templateUpdate.files.length} changed files`}
|
|
173
|
+
, in a review.
|
|
174
|
+
</span>
|
|
175
|
+
<div className={styles.projectRepoActions}>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
className={styles.chromeButton}
|
|
179
|
+
disabled={agentBusy}
|
|
180
|
+
onClick={() => {
|
|
181
|
+
setOpen(false);
|
|
182
|
+
send({ type: "template-update" });
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
Bring them in
|
|
186
|
+
</button>
|
|
187
|
+
<button type="button" className={styles.chromeButton} onClick={() => send({ type: "template-skip" })}>
|
|
188
|
+
Skip
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
157
193
|
<div className={styles.projectActions}>
|
|
158
194
|
<button
|
|
159
195
|
type="button"
|
|
@@ -255,7 +291,7 @@ function ProjectDialog({ mode, current, error, onSubmit, onClose }: ProjectDialo
|
|
|
255
291
|
<h2>{mode === "create" ? "New project" : mode === "repo" ? "Link a product repo" : "Open a folder"}</h2>
|
|
256
292
|
<p>
|
|
257
293
|
{mode === "create"
|
|
258
|
-
? "A design system of its own, with its own history and chat. It
|
|
294
|
+
? "A design system of its own, with its own history and chat. It is saved in ~/.basis/workspaces (or BASIS_HOME)."
|
|
259
295
|
: mode === "repo"
|
|
260
296
|
? `The repo ${current?.name ?? "this project"} designs for. The agent reads it to match the product's look, components and terms; it never writes there.`
|
|
261
297
|
: "A Basis workspace anywhere on this computer, or an empty or new folder (inside a product repo, say) for Basis to set up."}
|
|
@@ -306,32 +342,33 @@ function ProjectDialog({ mode, current, error, onSubmit, onClose }: ProjectDialo
|
|
|
306
342
|
</label>
|
|
307
343
|
</fieldset>
|
|
308
344
|
{fromRepo && (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
/>
|
|
319
|
-
</>
|
|
345
|
+
<FolderField
|
|
346
|
+
id="project-repo"
|
|
347
|
+
label="Repo folder"
|
|
348
|
+
value={dir}
|
|
349
|
+
onChange={setDir}
|
|
350
|
+
placeholder="Browse, pick a repo below, or paste its path"
|
|
351
|
+
dialogTitle="Choose the product repo"
|
|
352
|
+
suggestRepos
|
|
353
|
+
/>
|
|
320
354
|
)}
|
|
321
355
|
</>
|
|
322
356
|
) : (
|
|
323
357
|
<>
|
|
324
|
-
<
|
|
325
|
-
<input
|
|
358
|
+
<FolderField
|
|
326
359
|
id="project-dir"
|
|
360
|
+
label={mode === "repo" ? "Repo folder" : "Folder"}
|
|
327
361
|
value={dir}
|
|
328
|
-
onChange={
|
|
329
|
-
placeholder={
|
|
330
|
-
|
|
331
|
-
|
|
362
|
+
onChange={setDir}
|
|
363
|
+
placeholder={
|
|
364
|
+
mode === "repo" ? "Browse, pick a repo below, or paste its path" : "Browse, or paste its path"
|
|
365
|
+
}
|
|
366
|
+
dialogTitle={
|
|
367
|
+
mode === "repo" ? "Choose the product repo" : "Choose a Basis workspace or an empty folder"
|
|
368
|
+
}
|
|
369
|
+
suggestRepos={mode === "repo"}
|
|
332
370
|
autoFocus
|
|
333
371
|
/>
|
|
334
|
-
<p className={styles.muted}>Use the full path.</p>
|
|
335
372
|
{mode === "repo" && (
|
|
336
373
|
<label className={styles.projectCheck}>
|
|
337
374
|
<input type="checkbox" checked={bringIn} onChange={(event) => setBringIn(event.target.checked)} />
|