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.
@@ -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="C:\Code\my-app\src\design-system"
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({ design, workspace, problems, manifest, result, send, onClose }: ExportDialogProps) {
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 === "kit" ? (
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 lives in Basis' workspaces folder."
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
- <label htmlFor="project-repo">Repo folder</label>
311
- <input
312
- id="project-repo"
313
- value={dir}
314
- onChange={(event) => setDir(event.target.value)}
315
- placeholder="C:\\Code\\my-app"
316
- spellCheck={false}
317
- autoComplete="off"
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
- <label htmlFor="project-dir">{mode === "repo" ? "Repo folder" : "Folder"}</label>
325
- <input
358
+ <FolderField
326
359
  id="project-dir"
360
+ label={mode === "repo" ? "Repo folder" : "Folder"}
327
361
  value={dir}
328
- onChange={(event) => setDir(event.target.value)}
329
- placeholder={mode === "repo" ? "C:\\Code\\my-app" : "C:\\Code\\my-app\\design-system"}
330
- spellCheck={false}
331
- autoComplete="off"
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)} />