@protoboardai/views 0.0.0-stage → 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.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +46 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The Project Parts page (PB-945, decision D3 of beta test 1): the project's
3
+ * parts in the editor area, looking like the library: the same cards with
4
+ * their pictures (PartCard), grouped by tier (from libraries, researched,
5
+ * designed), each carrying its sync status with the library (in sync, update
6
+ * available with the revision, diverged, local only) and its part status
7
+ * (PB-932). The "Project parts" breadcrumb of a part's detail returns here,
8
+ * and the side bar's heading opens it. The same page in VS Code (an editor
9
+ * tab) and in the web viewer.
10
+ */
11
+ import { useMemo, useState } from "react";
12
+ import { EmptyState, Icon, IconButton, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
13
+ import { partSync, projectCard, tierGroups, type PartSync, type PartSyncKind } from "./library-data.js";
14
+ import type { ProjectPartModel, ProjectPartsActions, ProjectPartsModel } from "./model.js";
15
+ import { CARD_HEIGHT, PartCard } from "./PartCard.js";
16
+ import { LinkPagesToggle, PagerProvider, PagesProvider, useNewPagerStore } from "./carousel.js";
17
+ import { Windowed } from "./Windowed.js";
18
+ import { PartStatusDot } from "./PartStatus.js";
19
+ import { PART_TRUST_STATES, PART_TRUST_TEXT } from "./part-status.js";
20
+ import { UPDATE_EXPLAINED, partIcon } from "./ProjectParts.js";
21
+ import { rev } from "./revision.js";
22
+ import { PartsSurface } from "./Surface.js";
23
+ import { BODY, Button, FoldHeading, RowAction, SearchField, SectionLabel, useFolded, type FoldedGroups } from "./ui.js";
24
+
25
+ export interface ProjectPartsPageProps {
26
+ /** The groups folded away, kept by the host (PB-1039). */
27
+ folded?: FoldedGroups;
28
+ model: ProjectPartsModel;
29
+ actions: ProjectPartsActions;
30
+ /** The project's name, beside the heading. */
31
+ project?: string;
32
+ /** The part whose detail is open (its card is outlined). */
33
+ selected?: string;
34
+ }
35
+
36
+ const SYNC_TEXT: Record<PartSync["tone"], string> = { ok: "text-ok", info: "text-info", warning: "text-warning", neutral: "text-fg-muted" };
37
+
38
+ /** A part's sync status as a pill: its icon and words in the state's tone, on the card's own surface so it reads over a picture. */
39
+ export function SyncPill({ sync, onClick, disabled }: { sync: PartSync; /** An update's pill updates the part. */ onClick?: () => void; disabled?: boolean }) {
40
+ const tip = `${sync.label}. ${sync.meaning}`;
41
+ const pill = (
42
+ <span
43
+ {...(onClick ? {} : { role: "img", "aria-label": tip, "data-tooltip": tip })}
44
+ className={cx(
45
+ "inline-flex h-5 shrink-0 items-center gap-1 rounded-full border border-current bg-[var(--pb-card)] px-2 text-[11px] leading-none whitespace-nowrap outline-1 -outline-offset-1 outline-contrast",
46
+ SYNC_TEXT[sync.tone],
47
+ )}
48
+ >
49
+ <Icon name={sync.icon} size={11} strokeWidth={2} aria-hidden />
50
+ {sync.label}
51
+ </span>
52
+ );
53
+ if (!onClick) return pill;
54
+ return (
55
+ <button
56
+ type="button"
57
+ aria-label={tip}
58
+ data-tooltip={tip}
59
+ disabled={disabled}
60
+ onClick={(e) => {
61
+ e.stopPropagation();
62
+ onClick();
63
+ }}
64
+ className="cursor-pointer rounded-full focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-focus disabled:cursor-default"
65
+ >
66
+ {pill}
67
+ </button>
68
+ );
69
+ }
70
+
71
+ type Filter = "all" | PartSyncKind;
72
+
73
+ function CardActions({ part, actions }: { part: ProjectPartModel; actions: ProjectPartsActions }) {
74
+ const busy = !!part.busy;
75
+ return (
76
+ <div className="flex shrink-0 items-center gap-0.5">
77
+ {part.status === "update-available" && actions.update && <RowAction icon="download" label={`Update to ${rev(part.recommended)}`} disabled={busy} onClick={() => actions.update!(part.id)} />}
78
+ {part.status === "diverged" && actions.restore && <RowAction icon="undo-2" label={`Restore ${rev(part.revision)}`} disabled={busy} onClick={() => actions.restore!(part.id)} />}
79
+ {part.tier === "catalog" && actions.compare && <RowAction icon="git-compare-arrows" label="Compare with the library" onClick={() => actions.compare!(part.id)} />}
80
+ {part.tier === "project-researched" && actions.publish && <RowAction icon="upload" label="Submit for verification" onClick={() => actions.publish!(part.id)} />}
81
+ {actions.openSource && <RowAction icon="file-code" label="Open source" onClick={() => actions.openSource!(part.id)} />}
82
+ </div>
83
+ );
84
+ }
85
+
86
+ export function ProjectPartsPage({ model, actions, project, selected, folded }: ProjectPartsPageProps) {
87
+ const [layout, setLayout] = useState<"grid" | "list">("grid");
88
+ const [text, setText] = useState("");
89
+ const [filter, setFilter] = useState<Filter>("all");
90
+ const pager = useNewPagerStore();
91
+ const synced = useMemo(() => model.parts.map((p) => ({ part: p, sync: partSync(p) })), [model.parts]);
92
+ const count = (k: PartSyncKind) => synced.filter((x) => x.sync.kind === k).length;
93
+ const shown = useMemo(() => {
94
+ const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
95
+ return synced.filter(({ part, sync }) => (filter === "all" || sync.kind === filter) && words.every((w) => `${part.name} ${part.id} ${part.manufacturer ?? ""} ${part.partNumber ?? ""}`.toLowerCase().includes(w)));
96
+ }, [synced, text, filter]);
97
+ const syncOf = useMemo(() => new Map(shown.map((x) => [x.part.id, x.sync])), [shown]);
98
+ const groups = useMemo(() => tierGroups(shown.map((x) => x.part)), [shown]);
99
+ const { closed, toggle, setAll } = useFolded(folded);
100
+ const allClosed = groups.length > 0 && groups.every((g) => closed.has(g.id));
101
+ const c = model.counts;
102
+ // the filters offered: every sync state the project has parts in (never one that would show nothing)
103
+ const every: { id: Filter; label: string; icon: string; n: number; tone: PartSync["tone"] }[] = [
104
+ { id: "all", label: "All", icon: "layout-grid", n: c.total, tone: "neutral" },
105
+ { id: "update", label: "Updates", icon: "download", n: count("update"), tone: "info" },
106
+ { id: "diverged", label: "Diverged", icon: "git-compare-arrows", n: count("diverged"), tone: "warning" },
107
+ { id: "in-sync", label: "In sync", icon: "check", n: count("in-sync"), tone: "ok" },
108
+ { id: "pinned", label: "Not checked", icon: "pin", n: count("pinned"), tone: "neutral" },
109
+ { id: "local", label: "Local only", icon: "hard-drive", n: count("local"), tone: "neutral" },
110
+ ];
111
+ const filters = every.filter((f) => f.id === "all" || f.n > 0);
112
+ const checked = model.checked ? `Checked ${model.checked.at.slice(0, 16).replace("T", " ")} UTC` : undefined;
113
+ return (
114
+ <PartsSurface place="editor">
115
+ <div className="flex size-full min-w-[320px] flex-col">
116
+ <header className={cx(BODY, "flex shrink-0 flex-col gap-3 px-6 pt-5 pb-3 max-[640px]:px-4")}>
117
+ <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
118
+ <span className="flex min-w-0 flex-1 items-baseline gap-2">
119
+ <Icon name="package" size={17} strokeWidth={1.75} className="shrink-0 self-center text-fg" aria-hidden />
120
+ <h1 className="shrink-0 text-[17px] font-semibold text-fg">Project parts</h1>
121
+ <span className="min-w-0 truncate text-[12.5px] text-fg-muted">
122
+ {project ? `${project} · ` : ""}
123
+ {c.total} part{c.total === 1 ? "" : "s"}: {c.library} from libraries, {c.researched} researched, {c.designed} designed
124
+ </span>
125
+ </span>
126
+ <span className="flex shrink-0 items-center gap-2">
127
+ {actions.check && (
128
+ <Button icon={model.loading ? "loader" : "refresh-cw"} disabled={model.loading} onClick={actions.check} data-tooltip={checked ?? "Ask the libraries which parts have a newer recommended revision"}>
129
+ {model.loading ? "Checking…" : "Check for updates"}
130
+ </Button>
131
+ )}
132
+ {actions.openLibrary && (
133
+ <Button icon="library-big" onClick={actions.openLibrary}>
134
+ Library
135
+ </Button>
136
+ )}
137
+ </span>
138
+ </div>
139
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
140
+ <SearchField className="min-w-[160px] flex-1" value={text} placeholder="Filter the project's parts by name, id or MPN…" onChange={setText} />
141
+ {actions.pages && <LinkPagesToggle store={pager} />}
142
+ <span className="flex items-center gap-1">
143
+ {groups.length > 1 && <IconButton icon={allClosed ? "chevrons-up-down" : "chevrons-down-up"} label={allClosed ? "Expand all groups" : "Collapse all groups"} onClick={() => setAll(allClosed ? [] : groups.map((g) => g.id))} />}
144
+ <IconButton icon="layout-grid" label="Grid" pressed={layout === "grid"} onClick={() => setLayout("grid")} />
145
+ <IconButton icon="list" label="List" pressed={layout === "list"} onClick={() => setLayout("list")} />
146
+ </span>
147
+ </div>
148
+ <ScrollArea axis="horizontal" className="-mx-1 shrink-0" contentClassName="flex gap-1.5 px-1 py-0.5">
149
+ <div role="radiogroup" aria-label="Show parts by sync status" className="flex gap-1.5">
150
+ {filters.map((f) => (
151
+ <button
152
+ key={f.id}
153
+ type="button"
154
+ role="radio"
155
+ aria-checked={filter === f.id}
156
+ onClick={() => setFilter(f.id)}
157
+ className={cx(
158
+ "inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-[4px] border px-2.5 text-[12px] whitespace-nowrap transition-colors",
159
+ filter === f.id ? "border-focus bg-active text-fg outline outline-1 -outline-offset-1 outline-contrast-active" : "border-[var(--pb-control-border)] text-fg hover:bg-hover",
160
+ "focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-focus",
161
+ )}
162
+ >
163
+ <Icon name={f.icon} size={13} strokeWidth={1.75} className={SYNC_TEXT[f.tone]} aria-hidden />
164
+ {f.label}
165
+ <span className="font-ui text-[11px] text-fg-muted tabular-nums">{f.n}</span>
166
+ </button>
167
+ ))}
168
+ </div>
169
+ </ScrollArea>
170
+ {model.error && (
171
+ <p role="status" className="flex items-start gap-2 rounded-[4px] border border-line px-2.5 py-2 text-[12px] text-fg">
172
+ <Icon name="triangle-alert" size={14} className="mt-px shrink-0 text-warning" aria-hidden />
173
+ {model.error}
174
+ </p>
175
+ )}
176
+ {(model.checked?.problems ?? []).map((m) => (
177
+ <p key={m} role="status" className="flex items-start gap-2 text-[12px] text-fg-muted">
178
+ <Icon name="triangle-alert" size={14} className="mt-px shrink-0 text-warning" aria-hidden />
179
+ Not checked: {m}
180
+ </p>
181
+ ))}
182
+ </header>
183
+ <ScrollArea className="min-h-0 flex-1" contentClassName="flex flex-col gap-7 px-6 pt-1 pb-8 max-[640px]:px-4">
184
+ <PagesProvider loader={actions.pages}>
185
+ <PagerProvider store={pager}>
186
+ {groups.map((g, gi) => (
187
+ <section key={g.id} aria-label={g.title} data-group={g.id}>
188
+ <FoldHeading open={!closed.has(g.id)} onToggle={() => toggle(g.id)} className="-ml-1.5 h-7 px-1.5" right={<span className="font-ui text-[11px] text-fg-muted">{g.parts.length}</span>}>
189
+ {g.title}
190
+ </FoldHeading>
191
+ {!closed.has(g.id) && <p className={cx(BODY, "pt-0.5 pb-2.5 text-[12px] leading-snug text-fg-muted")}>{g.hint}</p>}
192
+ {!closed.has(g.id) && (
193
+ <div className={layout === "grid" ? "grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4" : "flex flex-col gap-2"}>
194
+ {g.parts.map((p, i) => {
195
+ const sync = syncOf.get(p.id)!;
196
+ return (
197
+ <Windowed key={p.id} height={CARD_HEIGHT[layout === "grid" ? "tile" : "row"]} eager={gi === 0 && i < 24}>
198
+ <PartCard
199
+ item={projectCard(p)}
200
+ variant={layout === "grid" ? "tile" : "row"}
201
+ selected={p.id === selected}
202
+ icon={partIcon(p)}
203
+ onOpen={() => actions.open(p.id)}
204
+ onAdd={() => undefined}
205
+ onUpdate={() => undefined}
206
+ pill={p.busy ? undefined : <SyncPill sync={sync} {...(sync.kind === "update" && actions.update ? { onClick: () => actions.update!(p.id) } : {})} />}
207
+ status={p.trust ? <PartStatusDot trust={p.trust} basis={p.trustBasis} label /> : undefined}
208
+ actions={<CardActions part={p} actions={actions} />}
209
+ />
210
+ </Windowed>
211
+ );
212
+ })}
213
+ </div>
214
+ )}
215
+ </section>
216
+ ))}
217
+ </PagerProvider>
218
+ </PagesProvider>
219
+ {model.loading && !model.parts.length && (
220
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4" aria-hidden>
221
+ {[0, 1, 2, 3].map((i) => (
222
+ <div key={i} className="h-[300px] animate-pulse rounded-[6px] border border-line bg-[var(--pb-card)]" />
223
+ ))}
224
+ </div>
225
+ )}
226
+ {!groups.length && !model.loading && (
227
+ <div className="py-16">
228
+ {model.parts.length ? (
229
+ <EmptyState icon={<Icon name="search-x" size={28} strokeWidth={1.25} />} title="No parts match">
230
+ Try other words, or show every sync status.
231
+ </EmptyState>
232
+ ) : (
233
+ <EmptyState icon={<Icon name="package" size={28} strokeWidth={1.25} />} title="No parts yet">
234
+ Parts you add from the library, research or design in the project are listed here.
235
+ </EmptyState>
236
+ )}
237
+ </div>
238
+ )}
239
+ {model.parts.length > 0 && (
240
+ <footer className={cx(BODY, "flex flex-col gap-3 border-t border-line-subtle pt-4 text-[12px] leading-snug text-fg-muted")}>
241
+ <div>
242
+ <SectionLabel>Part status</SectionLabel>
243
+ <dl className="mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1.5">
244
+ {PART_TRUST_STATES.map((t) => (
245
+ <div key={t} className="contents">
246
+ <dt className="flex items-center">
247
+ <PartStatusDot trust={t} label />
248
+ </dt>
249
+ <dd>{PART_TRUST_TEXT[t].meaning}</dd>
250
+ </div>
251
+ ))}
252
+ </dl>
253
+ </div>
254
+ <div>
255
+ <SectionLabel>Updates</SectionLabel>
256
+ <p className="mt-1 max-w-[80ch]">{UPDATE_EXPLAINED}</p>
257
+ </div>
258
+ </footer>
259
+ )}
260
+ </ScrollArea>
261
+ </div>
262
+ </PartsSurface>
263
+ );
264
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Protoboard's settings, as a page in its own tab (PB-930): the account, the
3
+ * version in use, the agents on this machine with Connect and Disconnect, the
4
+ * entry for any other MCP client, and a way into VS Code's own settings (they
5
+ * are linked, not repeated). A list of sections, so it can grow.
6
+ */
7
+ import { useState, type ReactNode } from "react";
8
+ import { Icon, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
9
+ import { AccountCard } from "./LibrariesManager.js";
10
+ import type { AgentRowModel, SettingsActions, SettingsModel } from "./model.js";
11
+ import { PartsSurface } from "./Surface.js";
12
+ import { BODY, Button, Card, RowAction, StatusText, type Tone } from "./ui.js";
13
+
14
+ const STATUS: Record<AgentRowModel["status"], { tone: Tone; label: string }> = {
15
+ connected: { tone: "ok", label: "Connected" },
16
+ outdated: { tone: "warning", label: "Update needed" },
17
+ "not-connected": { tone: "neutral", label: "Not connected" },
18
+ unreadable: { tone: "error", label: "Cannot read its file" },
19
+ };
20
+
21
+ const DETAIL: Record<NonNullable<AgentRowModel["detailTone"]>, string> = { neutral: "text-fg-muted", ok: "text-ok", warning: "text-warning", error: "text-error" };
22
+
23
+ /** A section: its heading, a line about it, then its content. */
24
+ function Section({ title, note, right, children }: { title: string; note?: ReactNode; right?: ReactNode; children: ReactNode }) {
25
+ return (
26
+ <section className="flex flex-col gap-2" aria-label={title}>
27
+ <div className={cx(BODY, "flex items-end gap-3")}>
28
+ <div className="min-w-0 flex-1">
29
+ <h2 className="text-[15px] font-semibold text-fg">{title}</h2>
30
+ {note && <p className="mt-0.5 text-[12.5px] leading-snug text-fg-muted">{note}</p>}
31
+ </div>
32
+ {right}
33
+ </div>
34
+ {children}
35
+ </section>
36
+ );
37
+ }
38
+
39
+ const ROW = "grid grid-cols-[minmax(150px,1.2fr)_minmax(150px,0.9fr)_minmax(160px,2fr)_184px] items-center gap-3 max-[720px]:grid-cols-[minmax(0,1fr)_auto]";
40
+
41
+ function AgentRow({ agent, actions }: { agent: AgentRowModel; actions: SettingsActions }) {
42
+ const s = STATUS[agent.status];
43
+ const connected = agent.status === "connected";
44
+ return (
45
+ <div className={cx(BODY, "border-t border-line-subtle px-4 py-2 text-[13px]")} data-agent={agent.id} data-status={agent.status}>
46
+ <div className={cx(ROW, "min-h-8")}>
47
+ <div className="flex min-w-0 flex-col gap-0.5">
48
+ <span className="flex min-w-0 items-center gap-2">
49
+ <Icon name="bot" size={15} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
50
+ <span className="truncate text-fg">{agent.name}</span>
51
+ </span>
52
+ <span className="hidden max-[720px]:block">
53
+ <StatusText tone={s.tone} plain>
54
+ {s.label}
55
+ </StatusText>
56
+ </span>
57
+ <span className="hidden min-w-0 items-center gap-1 max-[720px]:flex">
58
+ <span className="truncate font-ui text-[11.5px] text-fg-muted" title={agent.file}>
59
+ {agent.file}
60
+ </span>
61
+ <RowAction icon="file-braces" label="Open the configuration file" onClick={() => actions.openFile(agent.id)} />
62
+ </span>
63
+ </div>
64
+ <span className="min-w-0 truncate max-[720px]:hidden">
65
+ <StatusText tone={s.tone} plain>
66
+ {s.label}
67
+ </StatusText>
68
+ </span>
69
+ <span className="flex min-w-0 items-center gap-1 max-[720px]:hidden">
70
+ <span className="truncate font-ui text-[12px] text-fg-muted" title={agent.file}>
71
+ {agent.file}
72
+ </span>
73
+ <RowAction icon="file-braces" label="Open the configuration file" onClick={() => actions.openFile(agent.id)} />
74
+ </span>
75
+ <span className="flex items-center justify-end gap-1.5">
76
+ {agent.status === "outdated" && (
77
+ <Button size="sm" kind="primary" icon={agent.working ? "loader" : "refresh-cw"} disabled={agent.working} onClick={() => actions.connect(agent.id)}>
78
+ Update
79
+ </Button>
80
+ )}
81
+ {agent.status === "not-connected" && agent.manual && (
82
+ <Button size="sm" icon="copy" onClick={() => actions.copy(agent.manual!, "command")}>
83
+ Copy command
84
+ </Button>
85
+ )}
86
+ {agent.status === "not-connected" && !agent.manual && (
87
+ <Button size="sm" kind="primary" icon={agent.working ? "loader" : "plug"} disabled={agent.working} onClick={() => actions.connect(agent.id)}>
88
+ Connect
89
+ </Button>
90
+ )}
91
+ {(connected || agent.status === "outdated") && (
92
+ <Button size="sm" icon={agent.working && connected ? "loader" : undefined} disabled={agent.working} onClick={() => actions.disconnect(agent.id)}>
93
+ Disconnect
94
+ </Button>
95
+ )}
96
+ {agent.status === "unreadable" && (
97
+ <Button size="sm" icon="file-braces" onClick={() => actions.openFile(agent.id)}>
98
+ Open file
99
+ </Button>
100
+ )}
101
+ </span>
102
+ </div>
103
+ {agent.detail && <p className={cx("pt-0.5 pb-1 pl-[23px] text-[12px] leading-snug break-words", DETAIL[agent.detailTone ?? "neutral"])}>{agent.detail}</p>}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ /** The entry for another client: the form picked, as text to copy. */
109
+ function OtherClients({ model, actions }: { model: NonNullable<SettingsModel["other"]>; actions: SettingsActions }) {
110
+ const [form, setForm] = useState(model.forms[0]?.id);
111
+ const shown = model.forms.find((f) => f.id === form) ?? model.forms[0];
112
+ return (
113
+ <Card className={cx(BODY, "flex flex-col gap-3 px-4 py-3 text-[13px] text-fg")}>
114
+ <ol className="flex flex-col gap-1 text-[12.5px] leading-snug text-fg-muted">
115
+ {model.steps.map((s, i) => (
116
+ <li key={s} className="flex gap-2">
117
+ <span className="shrink-0 font-ui text-fg-faint">{i + 1}</span>
118
+ <span>{s}</span>
119
+ </li>
120
+ ))}
121
+ </ol>
122
+ {shown && (
123
+ <div className="flex flex-col gap-1.5">
124
+ <div className="flex flex-wrap items-center gap-1.5">
125
+ <div role="tablist" aria-label="Format" className="flex flex-wrap items-center gap-1">
126
+ {model.forms.map((f) => (
127
+ <button
128
+ key={f.id}
129
+ type="button"
130
+ role="tab"
131
+ aria-selected={f.id === shown.id}
132
+ onClick={() => setForm(f.id)}
133
+ className={cx(
134
+ "h-6 cursor-pointer rounded-[4px] border px-2 text-[12px] whitespace-nowrap",
135
+ f.id === shown.id ? "border-[var(--pb-control-border)] bg-active text-fg" : "border-transparent text-fg-muted hover:bg-hover hover:text-fg",
136
+ "outline-1 -outline-offset-1 outline-contrast focus-visible:outline focus-visible:outline-focus",
137
+ )}
138
+ >
139
+ {f.label}
140
+ </button>
141
+ ))}
142
+ </div>
143
+ <span className="ml-auto">
144
+ <RowAction icon="copy" label={`Copy the ${shown.label}`} onClick={() => actions.copy(shown.text, shown.label)} />
145
+ </span>
146
+ </div>
147
+ <ScrollArea className="max-h-[260px] rounded-[4px] border border-line bg-sunken" contentClassName="p-3">
148
+ <pre className="m-0 font-ui text-[12px] leading-relaxed whitespace-pre text-fg" data-form={shown.id}>
149
+ {shown.text}
150
+ </pre>
151
+ </ScrollArea>
152
+ </div>
153
+ )}
154
+ </Card>
155
+ );
156
+ }
157
+
158
+ export function SettingsPage({ model, actions }: { model: SettingsModel; actions: SettingsActions }) {
159
+ const a = model.agents;
160
+ return (
161
+ <PartsSurface place="editor">
162
+ <ScrollArea className="size-full" contentClassName="mx-auto flex max-w-[980px] min-w-[300px] flex-col gap-7 px-10 pt-7 pb-12 max-[640px]:px-4">
163
+ <header className={BODY}>
164
+ <h1 className="text-[22px] font-semibold text-fg">Protoboard settings</h1>
165
+ </header>
166
+
167
+ <Section title="Account">
168
+ <AccountCard account={model.account} actions={actions} />
169
+ </Section>
170
+
171
+ <Section title="Protoboard">
172
+ <Card className={cx(BODY, "flex flex-col text-[13px] text-fg")}>
173
+ <div className="flex min-h-10 items-center gap-3 px-4 py-2">
174
+ <span className="w-[88px] shrink-0 text-fg-muted">Version</span>
175
+ <span className="min-w-0 flex-1">
176
+ <span className="font-ui">{model.about.version}</span>
177
+ </span>
178
+ </div>
179
+ <div className="flex min-h-10 items-center gap-3 border-t border-line-subtle px-4 py-2">
180
+ <span className="w-[88px] shrink-0 text-fg-muted">CLI</span>
181
+ <span className="min-w-0 flex-1">
182
+ {model.about.problem ? (
183
+ <span className="text-warning">{model.about.problem}</span>
184
+ ) : (
185
+ <>
186
+ <span className="font-ui text-[12px] break-all">{model.about.cli ?? "—"}</span>
187
+ {model.about.cliFrom && <span className="text-fg-muted"> · {model.about.cliFrom}</span>}
188
+ </>
189
+ )}
190
+ </span>
191
+ {actions.showLog && <RowAction icon="scroll-text" label="Show the Protoboard log" onClick={actions.showLog} />}
192
+ </div>
193
+ </Card>
194
+ </Section>
195
+
196
+ <Section
197
+ title="Agents"
198
+ note="Connect an agent to give it Protoboard's tools."
199
+ right={<RowAction icon="refresh-cw" label="Look for agents again" onClick={actions.refresh} />}
200
+ >
201
+ <Card className="overflow-hidden">
202
+ <div className={cx(ROW, BODY, "h-9 px-4 text-[12px] font-medium text-fg-muted")}>
203
+ <span>Agent</span>
204
+ <span className="max-[720px]:hidden">Protoboard</span>
205
+ <span className="max-[720px]:hidden">Configuration</span>
206
+ <span />
207
+ </div>
208
+ {a.rows.map((r) => (
209
+ <AgentRow key={r.id} agent={r} actions={actions} />
210
+ ))}
211
+ {!a.rows.length && (
212
+ <p className={cx(BODY, "border-t border-line-subtle px-4 py-3 text-[13px]", a.state === "failed" ? "text-warning" : "text-fg-muted")}>
213
+ {a.state === "loading" ? "Looking for agents…" : a.state === "failed" ? (a.problem ?? "The agents could not be listed.") : "No agents found. Use the entry below."}
214
+ </p>
215
+ )}
216
+ </Card>
217
+ {a.notFound.length > 0 && <p className={cx(BODY, "text-[12px] leading-snug text-fg-muted")}>Not found on this machine: {a.notFound.join(", ")}.</p>}
218
+ </Section>
219
+
220
+ {model.other && (
221
+ <Section title="Other MCP clients" note="Add this entry to any other agent's MCP configuration.">
222
+ <OtherClients model={model.other} actions={actions} />
223
+ </Section>
224
+ )}
225
+
226
+ <Section title="More settings">
227
+ <Card className={cx(BODY, "flex flex-col text-[13px] text-fg")}>
228
+ <div className="flex min-h-11 items-center gap-3 px-4 py-2">
229
+ <Icon name="settings" size={15} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
230
+ <span className="min-w-0 flex-1">
231
+ VS Code settings
232
+ <span className="block text-[12px] text-fg-muted">CLI and Node.js paths, dev server port, check on save</span>
233
+ </span>
234
+ <Button size="sm" icon="external-link" onClick={actions.openVsCodeSettings}>
235
+ Open
236
+ </Button>
237
+ </div>
238
+ {actions.openLibraries && (
239
+ <div className="flex min-h-11 items-center gap-3 border-t border-line-subtle px-4 py-2">
240
+ <Icon name="library-big" size={15} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
241
+ <span className="min-w-0 flex-1">
242
+ Libraries
243
+ <span className="block text-[12px] text-fg-muted">Where parts come from, in search order</span>
244
+ </span>
245
+ <Button size="sm" icon="external-link" onClick={actions.openLibraries}>
246
+ Open
247
+ </Button>
248
+ </div>
249
+ )}
250
+ {actions.run &&
251
+ (model.links ?? []).map((l) => (
252
+ <div key={l.id} data-settings-link={l.id} className="flex min-h-11 items-center gap-3 border-t border-line-subtle px-4 py-2">
253
+ <Icon name={l.icon} size={15} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
254
+ <span className="min-w-0 flex-1">
255
+ {l.label}
256
+ <span className="block text-[12px] break-words text-fg-muted">{l.note}</span>
257
+ </span>
258
+ <Button size="sm" icon="external-link" className="shrink-0" onClick={() => actions.run!(l.id)}>
259
+ {l.action}
260
+ </Button>
261
+ </div>
262
+ ))}
263
+ </Card>
264
+ </Section>
265
+ </ScrollArea>
266
+ </PartsSurface>
267
+ );
268
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The page a parts view draws on: its background (the sidebar's or the
3
+ * editor's), the card surface, the body face and the picture matte, all from
4
+ * the theme's tokens. High contrast draws no washes: cards are the page with
5
+ * an outline.
6
+ */
7
+ import type { CSSProperties, ReactNode } from "react";
8
+ import { cx, useTheme } from "@deltarobotics/uhd-viewer/components";
9
+ import { CarouselStyles } from "./carousel.js";
10
+ import { PartStatusStyles } from "./PartStatus.js";
11
+
12
+ export type Place = "sidebar" | "editor";
13
+
14
+ export function PartsSurface({ place, children, className }: { place: Place; children: ReactNode; className?: string }) {
15
+ const { theme } = useTheme();
16
+ const hc = theme.scheme.startsWith("high-contrast");
17
+ const light = theme.scheme.includes("light");
18
+ // Protoboard's themes have their own card and sidebar shades (Alpha's); a VS Code theme gives the sidebar's colour,
19
+ // and cards are a light wash of the text over the editor's background
20
+ const own = theme.id.startsWith("protoboard");
21
+ const sidebar = own ? "color-mix(in oklab, var(--uhd-text) 2.5%, var(--uhd-background))" : "var(--uhd-surface)";
22
+ const bg = place === "sidebar" ? sidebar : "var(--uhd-background)";
23
+ const card = own ? "var(--uhd-surface)" : "color-mix(in oklab, var(--uhd-text) 7%, var(--uhd-background))";
24
+ const style = {
25
+ "--pb-bg": bg,
26
+ "--pb-card": hc ? bg : card,
27
+ "--pb-row-head": hc ? bg : `color-mix(in oklab, var(--uhd-text) 5%, ${card})`,
28
+ // the outline of buttons, chips and inputs: a quiet line in every theme, the contrast border in high contrast
29
+ "--pb-control-border": hc ? "var(--uhd-contrast-border)" : "color-mix(in oklab, var(--uhd-text) 22%, transparent)",
30
+ // pictures (PB-917): a render with a transparent background on the page's own surface, a little lifted; a photo
31
+ // shot on white on a light tile it melts into (multiply), white in light themes, a soft grey in dark ones;
32
+ // high contrast keeps white tiles, outlined by the card
33
+ "--pb-thumb-clear": hc ? bg : `color-mix(in oklab, var(--uhd-text) ${light ? 3 : 6}%, ${card})`,
34
+ "--pb-thumb-tile": hc || light ? "#ffffff" : "color-mix(in srgb, #ffffff 86%, var(--uhd-background))",
35
+ "--pb-thumb-blend": hc ? "normal" : "multiply",
36
+ "--pb-thumb-fg": "#8a8a8a",
37
+ "--pb-font-body": `var(--vscode-font-family, "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif)`,
38
+ // the severity hues are bright for dark pages; as text on a light page they need the deeper shade of the same hue (WCAG AA on white)
39
+ ...(light ? { "--uhd-severity-warning": "#a16207", "--uhd-severity-info": "#0369a1", "--uhd-severity-ok": "#15803d", "--uhd-severity-error": "#dc2626" } : {}),
40
+ background: bg,
41
+ } as CSSProperties;
42
+ return (
43
+ <div className={cx("pb-parts relative size-full min-h-0 min-w-0 overflow-hidden text-fg", className)} style={style}>
44
+ {/* the part status colours (PB-932), for every dot drawn on this page */}
45
+ <PartStatusStyles />
46
+ {/* the carousels' fade and hover-revealed controls (PB-918) */}
47
+ <CarouselStyles />
48
+ {children}
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A list's window (PB-918): a card far from what is on screen is kept as an
3
+ * empty box of its own size, and drawn again when the list scrolls near it,
4
+ * so a list of several hundred parts scrolls like a short one. It works in a
5
+ * grid, a column and a grouped page alike, since each card decides for
6
+ * itself; the list's scroll position and height never change, because the
7
+ * box keeps the size the card had.
8
+ *
9
+ * Where there is no IntersectionObserver (a test's DOM), every card is drawn.
10
+ */
11
+ import { useEffect, useRef, useState, type ReactNode } from "react";
12
+ import { cx } from "@deltarobotics/uhd-viewer/components";
13
+
14
+ /** How far beyond the visible part of the list cards stay drawn. */
15
+ const MARGIN = "700px 0px";
16
+
17
+ type Seen = (visible: boolean) => void;
18
+ const observers = new WeakMap<Element | Document, { io: IntersectionObserver; seen: WeakMap<Element, Seen> }>();
19
+
20
+ /** One observer per scrolling list, shared by its cards. */
21
+ function observe(el: Element, fn: Seen): () => void {
22
+ // the list scrolls inside a Radix viewport: the margin has to be measured against it, not the page
23
+ const root = el.closest("[data-radix-scroll-area-viewport]");
24
+ const key = root ?? document;
25
+ let o = observers.get(key);
26
+ if (!o) {
27
+ const seen = new WeakMap<Element, Seen>();
28
+ const io = new IntersectionObserver((entries) => entries.forEach((e) => seen.get(e.target)?.(e.isIntersecting)), { root, rootMargin: MARGIN });
29
+ o = { io, seen };
30
+ observers.set(key, o);
31
+ }
32
+ o.seen.set(el, fn);
33
+ o.io.observe(el);
34
+ return () => {
35
+ o.io.unobserve(el);
36
+ o.seen.delete(el);
37
+ };
38
+ }
39
+
40
+ export interface WindowedProps {
41
+ /** The card's height before it has been drawn once. */
42
+ height: number;
43
+ /** Drawn from the start (the first screenful), so the list does not open on empty boxes. */
44
+ eager?: boolean;
45
+ className?: string;
46
+ children: ReactNode;
47
+ }
48
+
49
+ export function Windowed({ height, eager, className, children }: WindowedProps) {
50
+ const can = typeof IntersectionObserver !== "undefined";
51
+ const ref = useRef<HTMLDivElement>(null);
52
+ const [shown, setShown] = useState(!can || !!eager);
53
+ const size = useRef(height);
54
+ useEffect(() => {
55
+ const el = ref.current;
56
+ if (!el || !can) return;
57
+ return observe(el, (visible) => {
58
+ // a card that holds the keyboard focus stays drawn, wherever the list is scrolled to
59
+ if (!visible && el.contains(document.activeElement)) return;
60
+ // remember the card's size as it leaves, so its box holds the same room
61
+ if (!visible && el.firstElementChild) size.current = el.offsetHeight || size.current;
62
+ setShown(visible);
63
+ });
64
+ }, [can]);
65
+ return (
66
+ <div ref={ref} data-windowed={shown ? "shown" : "away"} className={cx("min-w-0", className)} style={shown ? undefined : { height: size.current }}>
67
+ {shown ? children : null}
68
+ </div>
69
+ );
70
+ }