@protoboardai/views 0.0.0-stage → 0.2.1
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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +21 -2
- package/package.json +57 -4
- package/src/applies.ts +54 -0
- package/src/builtins.tsx +89 -0
- package/src/drawing/BoardDrawingView.tsx +515 -0
- package/src/drawing/CircuitPcbView.tsx +494 -0
- package/src/drawing/DrawingControls.tsx +39 -0
- package/src/drawing/boards.ts +82 -0
- package/src/drawing/circuit.ts +413 -0
- package/src/drawing/kicadSchematic.ts +277 -0
- package/src/drawing/palettes.ts +150 -0
- package/src/drawing/svg.ts +445 -0
- package/src/drawing/usePanZoom.ts +400 -0
- package/src/icons.tsx +69 -0
- package/src/index.ts +33 -0
- package/src/inspector/DrcPanel.tsx +302 -0
- package/src/inspector/SelectionInspector.tsx +392 -0
- package/src/inspector/index.ts +9 -0
- package/src/inspector/model.ts +224 -0
- package/src/pair/PairView.tsx +314 -0
- package/src/pair/model.ts +206 -0
- package/src/parts/BomEditor.tsx +564 -0
- package/src/parts/CardPages.tsx +317 -0
- package/src/parts/CategoryList.tsx +72 -0
- package/src/parts/CloudPanel.tsx +160 -0
- package/src/parts/GlbPreview.tsx +116 -0
- package/src/parts/LibrariesManager.tsx +251 -0
- package/src/parts/LibraryBrowser.tsx +191 -0
- package/src/parts/LibraryPage.tsx +168 -0
- package/src/parts/PartCard.tsx +274 -0
- package/src/parts/PartDetail.tsx +613 -0
- package/src/parts/PartStatus.tsx +54 -0
- package/src/parts/Prices.tsx +241 -0
- package/src/parts/ProjectParts.tsx +174 -0
- package/src/parts/ProjectPartsPage.tsx +264 -0
- package/src/parts/SettingsPage.tsx +268 -0
- package/src/parts/Surface.tsx +51 -0
- package/src/parts/Windowed.tsx +70 -0
- package/src/parts/carousel.tsx +344 -0
- package/src/parts/index.ts +32 -0
- package/src/parts/library-data.ts +726 -0
- package/src/parts/matte.ts +69 -0
- package/src/parts/model.ts +590 -0
- package/src/parts/part-pages.ts +379 -0
- package/src/parts/part-status.ts +88 -0
- package/src/parts/revision.ts +11 -0
- package/src/parts/three.d.ts +5 -0
- package/src/parts/ui.tsx +335 -0
- package/src/parts/views.tsx +433 -0
- package/src/registry.ts +107 -0
- package/src/tabs.ts +118 -0
- package/src/theme.ts +225 -0
- package/src/types.ts +93 -0
- package/src/ui/Select.tsx +99 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DRC panel (PB-901): the build's findings in severity lanes (Blocking,
|
|
3
|
+
* Warnings, and Information on request), grouped by module, rule or source.
|
|
4
|
+
* The filters are segmented icon choices with tooltips, never the system's
|
|
5
|
+
* dropdown; a row opens to its reasons, what the code means and what to do;
|
|
6
|
+
* its actions (locate, open source, explain) are icon buttons with tooltips,
|
|
7
|
+
* the same icons as the Inspector's. The shell computes the model and does
|
|
8
|
+
* what the actions ask. Finding text is drawn as text: the only markup read
|
|
9
|
+
* from it is `code` and **bold**.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
12
|
+
import { Icon, IconButton, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
|
|
13
|
+
import { PartsSurface } from "../parts/Surface.js";
|
|
14
|
+
import { BODY, Button, RowAction } from "../parts/ui.js";
|
|
15
|
+
import type { DrcLaneModel, DrcPanelActions, DrcPanelModel, DrcQueryModel, DrcRowModel } from "./model.js";
|
|
16
|
+
|
|
17
|
+
export interface DrcPanelProps {
|
|
18
|
+
model: DrcPanelModel | undefined;
|
|
19
|
+
actions: DrcPanelActions;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const LANE: Record<DrcLaneModel["id"], { icon: string; tone: string; empty: string }> = {
|
|
23
|
+
blocking: { icon: "circle-x", tone: "text-error", empty: "Nothing blocking." },
|
|
24
|
+
warnings: { icon: "triangle-alert", tone: "text-warning", empty: "None." },
|
|
25
|
+
info: { icon: "info", tone: "text-info", empty: "None." },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SEVERITY = [
|
|
29
|
+
{ id: "all", icon: "list", label: "Blocking and warnings" },
|
|
30
|
+
{ id: "error", icon: "circle-x", label: "Blocking only" },
|
|
31
|
+
{ id: "warning", icon: "triangle-alert", label: "Warnings only" },
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
const GROUP = [
|
|
35
|
+
{ id: "module", icon: "box", label: "Group by module" },
|
|
36
|
+
{ id: "rule", icon: "scale", label: "Group by rule" },
|
|
37
|
+
{ id: "source", icon: "blocks", label: "Group by source" },
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** `code` and **bold** from the references' Markdown; everything else as text. */
|
|
41
|
+
function Inline({ text }: { text: string }) {
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{text.split(/(`[^`]+`|\*\*[^*]+\*\*)/).map((part, i) =>
|
|
45
|
+
!part ? null : part.startsWith("`") ? (
|
|
46
|
+
<code key={i} className="font-code text-[0.92em]">
|
|
47
|
+
{part.slice(1, -1)}
|
|
48
|
+
</code>
|
|
49
|
+
) : part.startsWith("**") ? (
|
|
50
|
+
<b key={i}>{part.slice(2, -2)}</b>
|
|
51
|
+
) : (
|
|
52
|
+
part
|
|
53
|
+
),
|
|
54
|
+
)}
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A themed segmented choice of icons, each naming itself in a tooltip; a count may follow an icon. */
|
|
60
|
+
function Segmented<T extends string>({ label, options, value, onChange, counts }: { label: string; options: readonly { id: T; icon: string; label: string }[]; value: T; onChange: (v: T) => void; counts?: Partial<Record<T, number>> }) {
|
|
61
|
+
return (
|
|
62
|
+
<div role="group" aria-label={label} className="inline-flex shrink-0 items-center gap-px rounded-[6px] border border-[var(--pb-control-border)] bg-sunken p-px">
|
|
63
|
+
{options.map((o) => (
|
|
64
|
+
<button
|
|
65
|
+
key={o.id}
|
|
66
|
+
type="button"
|
|
67
|
+
aria-label={o.label}
|
|
68
|
+
aria-pressed={o.id === value}
|
|
69
|
+
data-tooltip={o.label}
|
|
70
|
+
data-value={o.id}
|
|
71
|
+
onClick={() => onChange(o.id)}
|
|
72
|
+
className={cx(
|
|
73
|
+
BODY,
|
|
74
|
+
"inline-flex h-[22px] cursor-pointer items-center gap-1 rounded-[4px] border border-transparent px-1.5 text-[12px] whitespace-nowrap text-fg-muted transition-colors",
|
|
75
|
+
"hover:bg-hover hover:text-fg aria-pressed:border-accent/45 aria-pressed:bg-accent-tint aria-pressed:text-fg",
|
|
76
|
+
"outline-1 -outline-offset-1 outline-transparent aria-pressed:outline-contrast-active focus-visible:outline-focus",
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
<Icon name={o.icon} size={13} strokeWidth={1.75} aria-hidden />
|
|
80
|
+
{counts?.[o.id] !== undefined && <span className="tabular-nums opacity-85">{counts[o.id]}</span>}
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Chevron({ open }: { open: boolean }) {
|
|
88
|
+
return <Icon name="chevron-right" size={12} strokeWidth={2} className={cx("shrink-0 text-fg-muted transition-transform duration-100", open && "rotate-90")} aria-hidden />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function FindingRow({ f, lane, flat, open, onToggle, actions }: { f: DrcRowModel; lane: DrcLaneModel["id"]; flat: boolean; open: boolean; onToggle: () => void; actions: DrcPanelActions }) {
|
|
92
|
+
return (
|
|
93
|
+
<div data-finding={f.key} className="group/item">
|
|
94
|
+
<div
|
|
95
|
+
role="button"
|
|
96
|
+
tabIndex={0}
|
|
97
|
+
aria-expanded={open}
|
|
98
|
+
onClick={onToggle}
|
|
99
|
+
onKeyDown={(e) => {
|
|
100
|
+
if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
onToggle();
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
className={cx(
|
|
106
|
+
BODY,
|
|
107
|
+
"group/row flex min-h-[24px] cursor-pointer items-center gap-2 rounded-[3px] pr-0.5 text-[12.5px] text-fg",
|
|
108
|
+
flat ? "pl-0" : "pl-3",
|
|
109
|
+
"hover:bg-hover hover:outline hover:outline-1 hover:outline-dashed hover:-outline-offset-1 hover:outline-contrast-active",
|
|
110
|
+
"focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus",
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
<Chevron open={open} />
|
|
114
|
+
<span className={cx("shrink-0 font-code text-[11.5px]", LANE[lane].tone)}>{f.code}</span>
|
|
115
|
+
<span title={f.message} className="min-w-0 flex-1 truncate">
|
|
116
|
+
{f.summary}
|
|
117
|
+
</span>
|
|
118
|
+
<span className="max-w-[35%] min-w-0 shrink truncate text-fg-muted">{f.module}</span>
|
|
119
|
+
<span className={cx("flex shrink-0 gap-px", !open && "invisible group-focus-within/row:visible group-hover/row:visible")}>
|
|
120
|
+
{f.fix && actions.fix && <RowAction icon={f.fix.icon ?? "download"} label={f.fix.label} onClick={() => actions.fix!(f.key)} />}
|
|
121
|
+
<RowAction icon="crosshair" label="Locate in the viewer" onClick={() => actions.locate(f.key)} />
|
|
122
|
+
<RowAction icon="file-code" label={f.hasSource ? `Open source · ${f.sourceLabel}` : "No source location"} disabled={!f.hasSource} onClick={() => actions.openSource(f.key)} />
|
|
123
|
+
<RowAction icon="circle-help" label={`Explain ${f.code}`} onClick={() => actions.explain(f.key)} />
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
{open && (
|
|
127
|
+
<div className={cx(BODY, "mb-1.5 border-l-2 border-line-strong py-1 pl-2.5 text-[12.5px] leading-[1.45] text-fg", flat ? "ml-[18px]" : "ml-[30px]")}>
|
|
128
|
+
{/* the row's line is cut to fit; here it is whole, with the reasons under it */}
|
|
129
|
+
<p>{f.summary}</p>
|
|
130
|
+
{f.reasons.length > 0 && (
|
|
131
|
+
<ul className="list-disc pl-4">
|
|
132
|
+
{f.reasons.map((r, i) => (
|
|
133
|
+
<li key={i}>{r}</li>
|
|
134
|
+
))}
|
|
135
|
+
</ul>
|
|
136
|
+
)}
|
|
137
|
+
{f.means && (
|
|
138
|
+
<p className="pt-0.5">
|
|
139
|
+
<Inline text={f.means} />
|
|
140
|
+
</p>
|
|
141
|
+
)}
|
|
142
|
+
{f.todo && (
|
|
143
|
+
<p className="pt-0.5">
|
|
144
|
+
<span className="text-fg-muted">What to do: </span>
|
|
145
|
+
<Inline text={f.todo} />
|
|
146
|
+
</p>
|
|
147
|
+
)}
|
|
148
|
+
{f.fix && actions.fix && (
|
|
149
|
+
<p className="pt-1">
|
|
150
|
+
<Button size="sm" kind="primary" icon={f.fix.icon ?? "download"} onClick={() => actions.fix!(f.key)}>
|
|
151
|
+
{f.fix.label}
|
|
152
|
+
</Button>
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
<p className="pt-0.5 text-[11.5px] text-fg-muted">{[f.sourceKind, f.validator, f.sourceLabel].filter(Boolean).join(" · ")}</p>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Notice({ children }: { children: ReactNode }) {
|
|
163
|
+
return <div className={cx(BODY, "py-2 text-[12.5px] text-fg-muted")}>{children}</div>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function DrcPanel({ model, actions }: DrcPanelProps) {
|
|
167
|
+
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(actions.saved?.expanded ?? []));
|
|
168
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(actions.saved?.collapsed ?? []));
|
|
169
|
+
const query: DrcQueryModel = model?.query ?? { groupBy: "module", text: "", severity: "all", info: false };
|
|
170
|
+
// the text field is the user's while they type; a query from the shell replaces it otherwise
|
|
171
|
+
const [text, setText] = useState(query.text);
|
|
172
|
+
const [typing, setTyping] = useState(false);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!typing) setText(query.text);
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
}, [query.text]);
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!typing) return;
|
|
179
|
+
const t = setTimeout(() => {
|
|
180
|
+
actions.query({ ...query, text });
|
|
181
|
+
setTyping(false);
|
|
182
|
+
}, 150);
|
|
183
|
+
return () => clearTimeout(t);
|
|
184
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
185
|
+
}, [text, typing]);
|
|
186
|
+
const flip = (set: Set<string>, id: string) => {
|
|
187
|
+
const next = new Set(set);
|
|
188
|
+
if (next.has(id)) next.delete(id);
|
|
189
|
+
else next.add(id);
|
|
190
|
+
return next;
|
|
191
|
+
};
|
|
192
|
+
const keep = (e: Set<string>, c: Set<string>) => actions.save?.({ expanded: [...e], collapsed: [...c] });
|
|
193
|
+
const send = (patch: Partial<DrcQueryModel>) => actions.query({ ...query, text, ...patch });
|
|
194
|
+
const m = model?.model;
|
|
195
|
+
const c = m?.counts;
|
|
196
|
+
const head = model && model.state !== "none" ? [model.project, model.state === "running" ? "checking…" : model.state === "failed" ? "the last check failed (see the Protoboard log)" : "", model.at ? `checked ${model.at}` : ""].filter(Boolean).join(" · ") : "";
|
|
197
|
+
return (
|
|
198
|
+
<PartsSurface place="editor" className="bg-transparent!">
|
|
199
|
+
<div className="flex size-full min-w-[260px] flex-col">
|
|
200
|
+
<div role="search" className="flex shrink-0 flex-wrap items-center gap-1.5 px-3 pt-1 pb-1.5">
|
|
201
|
+
<label className={cx(BODY, "flex h-6 min-w-[120px] flex-[1_1_180px] items-center gap-1.5 rounded-[4px] border border-line bg-sunken px-1.5 text-fg-muted focus-within:border-focus focus-within:outline focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-focus")}>
|
|
202
|
+
<Icon name="list-filter" size={13} strokeWidth={1.75} className="shrink-0" aria-hidden />
|
|
203
|
+
<input
|
|
204
|
+
type="search"
|
|
205
|
+
value={text}
|
|
206
|
+
placeholder="Filter: code, message, module"
|
|
207
|
+
aria-label="Filter findings"
|
|
208
|
+
spellCheck={false}
|
|
209
|
+
onChange={(e) => {
|
|
210
|
+
setText(e.target.value);
|
|
211
|
+
setTyping(true);
|
|
212
|
+
}}
|
|
213
|
+
className="h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-[12.5px] text-fg outline-none placeholder:text-fg-faint [&::-webkit-search-cancel-button]:hidden"
|
|
214
|
+
/>
|
|
215
|
+
</label>
|
|
216
|
+
<Segmented label="Severity" options={SEVERITY} value={query.severity} onChange={(severity) => send({ severity })} counts={c ? { all: c.error + c.warning, error: c.error, warning: c.warning } : undefined} />
|
|
217
|
+
<IconButton size="sm" icon="info" label={`${query.info ? "Hide" : "Show"} information findings${c ? ` (${c.info})` : ""}`} pressed={query.info} disabled={query.severity !== "all"} onClick={() => send({ info: !query.info })} />
|
|
218
|
+
<Segmented label="Group by" options={GROUP} value={query.groupBy} onChange={(groupBy) => send({ groupBy })} />
|
|
219
|
+
<IconButton size="sm" icon="refresh-cw" label="Check again (protoboard build)" onClick={actions.refresh} />
|
|
220
|
+
</div>
|
|
221
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName="px-3 pb-3">
|
|
222
|
+
{head && (
|
|
223
|
+
<div className={cx(BODY, "truncate pb-1.5 text-[12px] text-fg-muted")}>
|
|
224
|
+
{head}
|
|
225
|
+
{m && (
|
|
226
|
+
<>
|
|
227
|
+
{" · "}
|
|
228
|
+
<b className="font-semibold text-fg">{m.counts.error}</b> blocking · <b className="font-semibold text-fg">{m.counts.warning}</b> warnings · {m.counts.info} info · configured: {m.configured.links}/{m.configured.linksTotal} links, {m.configured.checksRan} checks ran
|
|
229
|
+
{m.configured.checksSkipped ? `, ${m.configured.checksSkipped} skipped` : ""}
|
|
230
|
+
</>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
{!model || model.state === "none" ? (
|
|
235
|
+
<Notice>Open a folder with a protoboard.config.ts to see its design-rule findings.</Notice>
|
|
236
|
+
) : !m ? (
|
|
237
|
+
<Notice>{model.state === "running" ? "Checking the project…" : "No findings yet: run Protoboard: Check."}</Notice>
|
|
238
|
+
) : (
|
|
239
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(340px,100%),1fr))] items-start gap-3">
|
|
240
|
+
{m.lanes.map((lane) => {
|
|
241
|
+
const flat = lane.groups.length === 1 && query.groupBy !== "rule";
|
|
242
|
+
const row = (f: DrcRowModel) => {
|
|
243
|
+
const id = `${f.code}|${f.message}`;
|
|
244
|
+
return (
|
|
245
|
+
<FindingRow
|
|
246
|
+
key={f.key}
|
|
247
|
+
f={f}
|
|
248
|
+
lane={lane.id}
|
|
249
|
+
flat={flat}
|
|
250
|
+
open={expanded.has(id)}
|
|
251
|
+
actions={actions}
|
|
252
|
+
onToggle={() => {
|
|
253
|
+
const next = flip(expanded, id);
|
|
254
|
+
setExpanded(next);
|
|
255
|
+
keep(next, collapsed);
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
return (
|
|
261
|
+
<section key={lane.id} data-lane={lane.id} className="min-w-0">
|
|
262
|
+
<h2 className={cx("mb-0.5 flex items-center gap-1.5 border-b border-line py-0.5 font-label text-[11px] font-normal tracking-[0.06em] uppercase", LANE[lane.id].tone)}>
|
|
263
|
+
<Icon name={LANE[lane.id].icon} size={13} strokeWidth={1.75} aria-hidden />
|
|
264
|
+
<span>{lane.title}</span>
|
|
265
|
+
<span className="text-fg-muted">{lane.shown === lane.total ? lane.total : `${lane.shown} of ${lane.total}`}</span>
|
|
266
|
+
</h2>
|
|
267
|
+
{!lane.groups.length && <div className={cx(BODY, "py-1 text-[12.5px] text-fg-muted italic")}>{lane.total ? "No match." : LANE[lane.id].empty}</div>}
|
|
268
|
+
{flat
|
|
269
|
+
? lane.groups[0].items.map(row)
|
|
270
|
+
: lane.groups.map((g) => {
|
|
271
|
+
const gid = `${lane.id}|${g.key}`;
|
|
272
|
+
const open = !collapsed.has(gid);
|
|
273
|
+
return (
|
|
274
|
+
<div key={g.key}>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
aria-expanded={open}
|
|
278
|
+
onClick={() => {
|
|
279
|
+
const next = flip(collapsed, gid);
|
|
280
|
+
setCollapsed(next);
|
|
281
|
+
keep(expanded, next);
|
|
282
|
+
}}
|
|
283
|
+
className={cx(BODY, "flex h-6 w-full cursor-pointer items-center gap-1.5 rounded-[3px] text-left text-[12.5px] text-fg-muted select-none hover:text-fg focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus")}
|
|
284
|
+
>
|
|
285
|
+
<Chevron open={open} />
|
|
286
|
+
<span className="min-w-0 truncate">{g.label}</span>
|
|
287
|
+
<span className="tabular-nums">{g.items.length}</span>
|
|
288
|
+
</button>
|
|
289
|
+
{open && g.items.map(row)}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
})}
|
|
293
|
+
</section>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</ScrollArea>
|
|
299
|
+
</div>
|
|
300
|
+
</PartsSurface>
|
|
301
|
+
);
|
|
302
|
+
}
|