@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.
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 +57 -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,613 @@
1
+ /**
2
+ * A part's detail, in its own editor tab (PB-902, PB-917, and PB-918's
3
+ * design 08): the back control and the category trail at the top left; the
4
+ * part's pictures as a carousel (arrows, dots, a strip of thumbnails, and a
5
+ * larger view); the name, the identifiers in the mono face, the state pills,
6
+ * the description and the actions; below, the text carousel of the cards,
7
+ * larger, as tabs: Overview, Specs, Dimensions, Interfaces, CAD, Artifacts,
8
+ * then Prices (by source, from the library) and Revisions. A section the
9
+ * part has nothing for is left out. The 3D model is fetched only when the
10
+ * CAD section is opened.
11
+ */
12
+ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
13
+ import { ArtifactGrid, EmptyState, HostContext, Icon, IconButton, ScrollArea, cx, type ArtifactCardModel } from "@deltarobotics/uhd-viewer/components";
14
+ import { ImageCarousel, OutlineDrawing } from "./CardPages.js";
15
+ import { Segments, StepButton, stepIn, useSwipe } from "./carousel.js";
16
+ import { GlbPreview } from "./GlbPreview.js";
17
+ import type { PartArtifactGroup, PartArtifactItem, PartDetailActions, PartDetailModel, PartDetailTab, PartImage } from "./model.js";
18
+ import { fileSize } from "./part-pages.js";
19
+ import { PartStatusDot } from "./PartStatus.js";
20
+ import { PricesPanel, usePrices } from "./Prices.js";
21
+ import { rev } from "./revision.js";
22
+ import { PartsSurface } from "./Surface.js";
23
+ import { BODY, Button, Pill, Tag, Thumb, factIcon } from "./ui.js";
24
+
25
+ export const DETAIL_TABS: { id: PartDetailTab; label: string; icon: string }[] = [
26
+ { id: "overview", label: "Overview", icon: "info" },
27
+ { id: "specs", label: "Specs", icon: "gauge" },
28
+ { id: "dimensions", label: "Dimensions", icon: "ruler" },
29
+ { id: "interfaces", label: "Interfaces", icon: "plug" },
30
+ { id: "cad", label: "CAD", icon: "box" },
31
+ { id: "artifacts", label: "Artifacts", icon: "paperclip" },
32
+ { id: "prices", label: "Prices", icon: "circle-dollar-sign" },
33
+ { id: "revisions", label: "Revisions", icon: "history" },
34
+ ];
35
+
36
+ /** The sections a part has something for, in order (the overview and the revisions always). */
37
+ export function detailTabs(model: PartDetailModel, actions: Pick<PartDetailActions, "prices">): typeof DETAIL_TABS {
38
+ const p = model.pages;
39
+ const has: Record<PartDetailTab, boolean> = {
40
+ overview: true,
41
+ specs: p ? p.specs.length > 0 : model.facts.length > 0,
42
+ dimensions: !!p?.dimensions,
43
+ interfaces: model.interfaces.length > 0,
44
+ cad: !!p?.cad || !!model.model || !!model.modelError,
45
+ artifacts: !!p?.artifacts.length || !!model.datasheet || model.links.length > 0 || model.evidence.length > 0,
46
+ prices: !!model.hasPrices && !!actions.prices,
47
+ revisions: true,
48
+ };
49
+ return DETAIL_TABS.filter((t) => has[t.id]);
50
+ }
51
+
52
+ /** A mesh larger than this is fetched when the reader asks, not when the section opens. */
53
+ const AUTO_MESH_BYTES = 25e6;
54
+
55
+ export interface PartDetailProps {
56
+ model: PartDetailModel;
57
+ actions: PartDetailActions;
58
+ tab?: PartDetailTab;
59
+ onTab?: (tab: PartDetailTab) => void;
60
+ }
61
+
62
+ function MainAction({ model, actions }: Pick<PartDetailProps, "model" | "actions">) {
63
+ const a = model.action;
64
+ if (model.busy) return <Button kind="primary" icon="loader" disabled>{model.busy}…</Button>;
65
+ if (model.noProject) return <Button kind="primary" icon="plus" disabled title="Open a Protoboard project to add parts">Add part</Button>;
66
+ switch (a.kind) {
67
+ case "add":
68
+ return <Button kind="primary" icon="plus" onClick={() => actions.act("add", a.revision)}>Add part</Button>;
69
+ case "update":
70
+ return <Button kind="primary" icon="download" onClick={() => actions.act("update", a.revision)}>Update to {rev(a.revision)}</Button>;
71
+ case "switch":
72
+ return <Button kind="primary" icon="pin" onClick={() => actions.act("switch", a.revision)}>Pin {rev(a.revision)}</Button>;
73
+ case "pinned":
74
+ return <Button kind="primary" icon="check" disabled>In this project</Button>;
75
+ default:
76
+ return <Button kind="primary" icon="plus" disabled title="The project has this id from another library">From another library</Button>;
77
+ }
78
+ }
79
+
80
+ function Fact({ icon, label, value }: { icon: string; label: string; value: string }) {
81
+ return (
82
+ <div className="flex min-w-0 flex-col gap-1.5 rounded-[4px] bg-[var(--pb-card)] px-3 py-3 outline-1 -outline-offset-1 outline-contrast">
83
+ <span className={cx(BODY, "flex items-center gap-1.5 text-[11.5px] text-fg-muted")}>
84
+ <Icon name={icon} size={13} strokeWidth={1.75} aria-hidden />
85
+ <span className="truncate">{label}</span>
86
+ </span>
87
+ <span className="font-ui text-[15px] leading-tight break-words text-fg">{value}</span>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ function Rows({ children }: { children: ReactNode }) {
93
+ return <div className={cx(BODY, "divide-y divide-line-subtle text-[13px]")}>{children}</div>;
94
+ }
95
+
96
+ /** The CAD section: a still of the model until the mesh is here; a large mesh waits for the reader to ask. */
97
+ function CadSection({ model, actions }: { model: PartDetailModel; actions: PartDetailActions }) {
98
+ const cad = model.pages?.cad;
99
+ const bytes = cad?.mesh ?? 0;
100
+ const [asked, setAsked] = useState(bytes <= AUTO_MESH_BYTES);
101
+ const want = asked && !model.model && !model.modelError && !!model.modelLazy;
102
+ useEffect(() => {
103
+ if (want) actions.loadModel?.();
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [want, model.key, model.revision]);
106
+ const lines = [cad?.file, cad?.origin, cad?.features ? `${cad.features} mate feature${cad.features === 1 ? "" : "s"}` : undefined, model.cad && !cad?.origin ? model.cad : undefined].filter((x): x is string => !!x);
107
+ return (
108
+ <div className="flex flex-col gap-3">
109
+ <div className="relative h-[320px] overflow-hidden rounded-[4px] bg-[var(--pb-row-head)] outline-1 -outline-offset-1 outline-contrast">
110
+ {model.model && asked ? (
111
+ <GlbPreview url={model.model} label={model.name} />
112
+ ) : model.modelError ? (
113
+ <div className="grid size-full place-items-center">
114
+ <EmptyState icon={<Icon name="box" size={24} strokeWidth={1.25} />} title="The model did not download">
115
+ {model.modelError}
116
+ </EmptyState>
117
+ </div>
118
+ ) : model.model || model.modelLazy ? (
119
+ <>
120
+ {cad?.preview && <Thumb src={cad.preview} alt={`${model.name}: a still of the model`} className="size-full" iconSize={44} icon="box" />}
121
+ <div className="absolute inset-0 grid place-items-center">
122
+ {asked ? (
123
+ <span role="status" className={cx(BODY, "flex items-center gap-2 rounded-full bg-[var(--pb-card)] px-3 py-1.5 text-[12px] text-fg outline-1 outline-contrast")}>
124
+ <Icon name="loader" size={14} className="animate-spin motion-reduce:animate-none" aria-hidden />
125
+ Loading the 3D model{bytes ? ` (${fileSize(bytes)})` : ""}…
126
+ </span>
127
+ ) : (
128
+ <Button kind="primary" icon="rotate-3d" onClick={() => setAsked(true)}>
129
+ Load the 3D model{bytes ? ` (${fileSize(bytes)})` : ""}
130
+ </Button>
131
+ )}
132
+ </div>
133
+ </>
134
+ ) : (
135
+ <div className="grid size-full place-items-center">
136
+ <EmptyState icon={<Icon name="box" size={24} strokeWidth={1.25} />} title="No 3D model">
137
+ This revision carries CAD files, but no mesh to show here.
138
+ </EmptyState>
139
+ </div>
140
+ )}
141
+ </div>
142
+ {lines.length > 0 && (
143
+ <p className={cx(BODY, "flex flex-wrap items-center gap-x-2 gap-y-1 text-[12.5px] text-fg-muted")}>
144
+ <Icon name="box" size={13} strokeWidth={1.75} aria-hidden />
145
+ {lines.map((l, i) => (
146
+ <span key={l} className={i === 0 ? "font-ui text-[12px] text-fg" : undefined}>
147
+ {i > 0 && <span aria-hidden className="mr-2 text-fg-faint">·</span>}
148
+ {l}
149
+ </span>
150
+ ))}
151
+ {model.model && asked && <span className="ml-auto text-fg-faint">Drag to orbit, scroll to zoom</span>}
152
+ </p>
153
+ )}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ function Section({ tab, model, actions, prices }: { tab: PartDetailTab; model: PartDetailModel; actions: PartDetailActions; prices: ReturnType<typeof usePrices> }) {
159
+ const p = model.pages;
160
+ switch (tab) {
161
+ case "overview":
162
+ return (
163
+ <div className={cx(BODY, "flex flex-col gap-4 text-[13px] leading-relaxed")}>
164
+ <p className="text-fg">{model.description ?? "The library gives no description."}</p>
165
+ {model.facts.length > 0 && (
166
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
167
+ {model.facts.slice(0, 3).map((f) => (
168
+ <Fact key={f.label} icon={f.icon ?? factIcon(f.label)} label={f.label} value={f.value} />
169
+ ))}
170
+ </div>
171
+ )}
172
+ {model.dependencies.length > 0 && (
173
+ <p className="text-fg-muted">
174
+ Comes with: <span className="font-ui text-fg">{model.dependencies.join(", ")}</span>
175
+ </p>
176
+ )}
177
+ <p className="text-fg-muted">
178
+ From <span className="text-fg">{model.library}</span>
179
+ {model.libraryUrl && <span className="font-ui"> · {model.libraryUrl}</span>} · part id <span className="font-ui text-fg">{model.partId}</span>
180
+ </p>
181
+ </div>
182
+ );
183
+ case "specs": {
184
+ const facts = p ? p.specs : model.facts;
185
+ return (
186
+ <div className="flex flex-col gap-3">
187
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-3">
188
+ {facts.map((f) => (
189
+ <Fact key={f.label} icon={f.icon ?? factIcon(f.label)} label={f.label} value={f.value} />
190
+ ))}
191
+ </div>
192
+ {(p?.specNotes ?? []).map((n) => (
193
+ <p key={n} className={cx(BODY, "flex items-start gap-1.5 text-[12.5px] leading-snug text-fg-muted")}>
194
+ <Icon name="info" size={13} strokeWidth={1.75} className="mt-0.5 shrink-0" aria-hidden />
195
+ {n}
196
+ </p>
197
+ ))}
198
+ </div>
199
+ );
200
+ }
201
+ case "dimensions": {
202
+ const d = p!.dimensions!;
203
+ return (
204
+ <div className="flex gap-4 max-[640px]:flex-col">
205
+ {d.outline && (
206
+ <div className="grid shrink-0 place-items-center rounded-[4px] bg-[var(--pb-card)] p-4 outline-1 -outline-offset-1 outline-contrast min-[641px]:w-[240px]">
207
+ <OutlineDrawing outline={d.outline} width={200} height={150} />
208
+ </div>
209
+ )}
210
+ <div className="grid min-w-0 flex-1 grid-cols-[repeat(auto-fill,minmax(220px,1fr))] content-start gap-3">
211
+ {d.rows.map((f) => (
212
+ <Fact key={f.label} icon={f.icon ?? factIcon(f.label)} label={f.label} value={f.value} />
213
+ ))}
214
+ </div>
215
+ </div>
216
+ );
217
+ }
218
+ case "interfaces":
219
+ return (
220
+ <div className="flex flex-col gap-3">
221
+ {p && p.interfaces.chips.length > 0 && (
222
+ <div className="flex flex-wrap gap-1.5" aria-label="Interfaces in short">
223
+ {p.interfaces.chips.map((c) => (
224
+ <Tag key={c}>{c}</Tag>
225
+ ))}
226
+ </div>
227
+ )}
228
+ <Rows>
229
+ {model.interfaces.map((i, n) => (
230
+ <div key={`${i.id}-${n}`} className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,1.6fr)] items-center gap-3 py-2">
231
+ <span className="truncate text-fg">{i.name}</span>
232
+ <span className="truncate text-fg-muted">{i.domain ?? "—"}</span>
233
+ <span className="truncate font-ui text-[12px] text-fg">{i.protocols || "—"}</span>
234
+ </div>
235
+ ))}
236
+ {model.interfaceCount > model.interfaces.length && <p className="py-2 text-fg-muted">{model.interfaceCount} interfaces in all; pins alike are folded.</p>}
237
+ </Rows>
238
+ </div>
239
+ );
240
+ case "cad":
241
+ return <CadSection model={model} actions={actions} />;
242
+ case "artifacts":
243
+ return p?.artifacts.length ? (
244
+ <div className={cx(BODY, "flex flex-col gap-4 text-[13px]")}>
245
+ {p.artifacts.map((g) => (
246
+ <section key={g.kind} aria-label={g.label}>
247
+ <h3 className="flex items-center gap-1.5 pb-1 text-fg-muted">
248
+ <Icon name={g.icon} size={13} strokeWidth={1.75} aria-hidden />
249
+ <span className="font-ui text-[10.5px] tracking-[0.14em] uppercase">{g.label}</span>
250
+ <span className="font-ui text-[11px] tabular-nums">{g.items.length}</span>
251
+ </h3>
252
+ {/* the same cards the Inspector draws a module's artifacts with (PB-940): one component for an artifact */}
253
+ <HostContext.Provider value={{ openLink: actions.openLink }}>
254
+ <ArtifactGrid cards={g.items.map((it, i) => artifactItemCard(g, it, i))} />
255
+ </HostContext.Provider>
256
+ </section>
257
+ ))}
258
+ </div>
259
+ ) : (
260
+ <Rows>
261
+ {model.datasheet && <LinkRow icon="file-text" label={model.datasheet.label} url={model.datasheet.url} onOpen={actions.openLink} />}
262
+ {model.links.map((l) => (
263
+ <LinkRow key={l.url} icon="link" label={l.label} url={l.url} onOpen={actions.openLink} />
264
+ ))}
265
+ {model.evidence.map((e, i) => (
266
+ <div key={`${e.kind}-${i}`} className="flex items-center gap-2 py-2 text-fg">
267
+ <Icon name="shield-check" size={14} className="text-fg-muted" aria-hidden />
268
+ <span>{e.title ?? e.kind}</span>
269
+ <span className="text-fg-muted">evidence · {e.kind}</span>
270
+ </div>
271
+ ))}
272
+ </Rows>
273
+ );
274
+ case "prices":
275
+ return <PricesPanel state={prices.state} onLoad={prices.load} onOpenLink={actions.openLink} {...(actions.signIn ? { onSignIn: actions.signIn } : {})} />;
276
+ case "revisions":
277
+ return (
278
+ <Rows>
279
+ {model.revisions.map((r) => (
280
+ <div key={r.revision} className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 py-2">
281
+ <span className="w-10 font-ui text-fg">{rev(r.revision)}</span>
282
+ <span className="text-fg-muted">{r.createdAt.slice(0, 10)}</span>
283
+ <span className="min-w-0 truncate text-fg-muted">{r.publisher}</span>
284
+ <span className="flex flex-1 flex-wrap gap-1">
285
+ {r.recommended && <Pill>recommended</Pill>}
286
+ {r.pinned && <Pill tone="ok">in this project</Pill>}
287
+ {r.current && <Pill tone="info">shown</Pill>}
288
+ {r.deprecated && (
289
+ <Pill tone="warning" title={r.deprecated}>
290
+ deprecated
291
+ </Pill>
292
+ )}
293
+ </span>
294
+ {!r.current && (
295
+ <Button size="sm" kind="ghost" icon="eye" onClick={() => actions.revision(r.revision)}>
296
+ Show
297
+ </Button>
298
+ )}
299
+ </div>
300
+ ))}
301
+ </Rows>
302
+ );
303
+ }
304
+ }
305
+
306
+ /** Where a part's artifact sits among the viewer's kinds of artifact card. */
307
+ const CARD_KIND: Record<string, ArtifactCardModel["kind"]> = {
308
+ datasheet: "datasheet",
309
+ documentation: "document",
310
+ document: "document",
311
+ schematic: "schematic",
312
+ pcb: "pcb",
313
+ cad: "model",
314
+ "cad-model": "model",
315
+ "3d_model": "model",
316
+ mesh: "model",
317
+ "cad-source": "source",
318
+ firmware: "firmware",
319
+ image: "image",
320
+ evidence: "evidence",
321
+ };
322
+
323
+ /** One artifact of a part as the viewer's artifact card: its name, its kind, its format or size, and where it leads. */
324
+ export function artifactItemCard(g: Pick<PartArtifactGroup, "kind" | "label" | "icon">, it: PartArtifactItem, i: number): ArtifactCardModel {
325
+ let host: string | undefined;
326
+ try {
327
+ host = it.url ? new URL(it.url).host.replace(/^www\./, "") : undefined;
328
+ } catch {
329
+ host = undefined;
330
+ }
331
+ const source = host ?? it.license;
332
+ return {
333
+ key: `${g.kind}#${i}#${it.name}`,
334
+ defId: "",
335
+ title: it.name,
336
+ kind: CARD_KIND[g.kind] ?? "data",
337
+ group: "data",
338
+ // the group's heading names the kind: the card says the format or size ("PDF", "web", "0.85 MB")
339
+ kindLabel: it.note ?? g.label,
340
+ ...(source ? { source } : {}),
341
+ ...(host && it.license ? { sourceNote: it.license } : {}),
342
+ ...(it.url ? { location: it.url, href: it.url } : {}),
343
+ icon: g.icon,
344
+ remote: !!it.url,
345
+ };
346
+ }
347
+
348
+ function LinkRow({ icon, label, url, note, onOpen }: { icon: string; label: string; url: string; note?: string; onOpen: (url: string) => void }) {
349
+ return (
350
+ <button type="button" onClick={() => onOpen(url)} className="flex w-full cursor-pointer items-center gap-2 py-2 text-left text-fg hover:text-link focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus">
351
+ <Icon name={icon} size={14} className="shrink-0 text-fg-muted" aria-hidden />
352
+ <span className="truncate">{label}</span>
353
+ <span className="ml-auto flex min-w-0 shrink items-center gap-2 font-ui text-[11px] text-fg-muted">
354
+ <span className="truncate">{url.replace(/^https?:\/\//, "").replace(/\/.*$/, "")}</span>
355
+ {note && <span className="shrink-0">{note}</span>}
356
+ </span>
357
+ </button>
358
+ );
359
+ }
360
+
361
+ /** The pictures, larger: over the page, closed with Escape or its button; ← and → turn them. */
362
+ function Lightbox({ images, index, onIndex, onClose, alt }: { images: PartImage[]; index: number; onIndex: (i: number) => void; onClose: () => void; alt: string }) {
363
+ const close = useRef<HTMLButtonElement>(null);
364
+ const n = images.length;
365
+ useEffect(() => {
366
+ const before = document.activeElement as HTMLElement | null;
367
+ close.current?.focus();
368
+ return () => before?.focus?.();
369
+ }, []);
370
+ const keys = (e: KeyboardEvent) => {
371
+ e.stopPropagation();
372
+ if (e.key === "Escape") onClose();
373
+ else if (e.key === "ArrowRight" && n > 1) onIndex((index + 1) % n);
374
+ else if (e.key === "ArrowLeft" && n > 1) onIndex((index - 1 + n) % n);
375
+ else if (e.key === "Tab") {
376
+ // the dialog holds the focus: its close button is its one tab stop besides the carousel's
377
+ const stops = [...e.currentTarget.querySelectorAll<HTMLElement>("button:not([tabindex='-1'])")];
378
+ const at = stops.indexOf(document.activeElement as HTMLElement);
379
+ e.preventDefault();
380
+ stops[(at + (e.shiftKey ? -1 : 1) + stops.length) % stops.length]?.focus();
381
+ }
382
+ };
383
+ return (
384
+ <div role="dialog" aria-modal="true" aria-label={`Pictures of ${alt}`} onKeyDown={keys} onClick={onClose} className="absolute inset-0 z-40 grid place-items-center bg-[color-mix(in_oklab,var(--pb-bg)_88%,transparent)] p-6 max-[640px]:p-3">
385
+ <div onClick={(e) => e.stopPropagation()} className="pb-reveal-host relative flex size-full max-h-[820px] max-w-[1100px] flex-col overflow-hidden rounded-[6px] border border-line bg-[var(--pb-card)] shadow-float outline-1 -outline-offset-1 outline-contrast">
386
+ <ImageCarousel images={images} alt={alt} index={index} onIndex={onIndex} className="min-h-0 w-full flex-1" arrow={36} tabbable iconSize={56} />
387
+ <span className="absolute top-2 right-2 z-[2]">
388
+ <IconButton ref={close} icon="x" label="Close" shortcut="Esc" onClick={onClose} />
389
+ </span>
390
+ </div>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ export function PartDetail({ model, actions, tab: given, onTab }: PartDetailProps) {
396
+ const tabs = detailTabs(model, actions);
397
+ const [own, setOwn] = useState<PartDetailTab>(given ?? "specs");
398
+ useEffect(() => {
399
+ if (given) setOwn(given);
400
+ }, [given]);
401
+ // a section the part has nothing for is not offered: the part opens on its overview instead
402
+ const tab = tabs.some((t) => t.id === own) ? own : "overview";
403
+ const dir = useRef(1);
404
+ const set = (t: PartDetailTab, focus = false) => {
405
+ dir.current = tabs.findIndex((x) => x.id === t) >= tabs.findIndex((x) => x.id === tab) ? 1 : -1;
406
+ setOwn(t);
407
+ onTab?.(t);
408
+ if (focus) requestAnimationFrame(() => document.getElementById(`pb-tab-${t}`)?.focus());
409
+ };
410
+ const i = tabs.findIndex((t) => t.id === tab);
411
+ const step = (d: number, focus = false) => set(stepIn(tabs, tabs[i], d).id, focus);
412
+ const swipe = useSwipe((d) => step(d));
413
+ const prices = usePrices([model.key, model.revision, model.pricesAs ?? ""].join(" "), actions.prices);
414
+
415
+ const images: PartImage[] = model.pages?.images.length ? model.pages.images : model.thumbnail ? [{ url: model.thumbnail, label: "Catalog picture" }] : [];
416
+ const [picture, setPicture] = useState(0);
417
+ const [large, setLarge] = useState(false);
418
+ useEffect(() => setPicture(0), [model.key, model.revision]);
419
+ const shown = Math.min(picture, Math.max(images.length - 1, 0));
420
+
421
+ // the host's own shortcut for the back control (the web shell: Alt+←; VS Code binds its own)
422
+ useEffect(() => {
423
+ if (!model.backShortcut || !model.back || !actions.back) return;
424
+ const on = (e: globalThis.KeyboardEvent) => {
425
+ if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === "ArrowLeft") {
426
+ e.preventDefault();
427
+ actions.back!();
428
+ }
429
+ };
430
+ window.addEventListener("keydown", on);
431
+ return () => window.removeEventListener("keydown", on);
432
+ // eslint-disable-next-line react-hooks/exhaustive-deps
433
+ }, [model.backShortcut, model.back, !!actions.back]);
434
+
435
+ const used = model.used && !model.used.elsewhere ? model.used.revision : undefined;
436
+ const audited = model.evidence.some((e) => /verif|audit/i.test(e.kind));
437
+ const sub = [[model.manufacturer, model.mpn].filter(Boolean).join(" "), ...model.identifiers].filter(Boolean);
438
+ const dims = model.pages?.dimensions?.rows ?? [];
439
+ const chips = [...(model.pages?.interfaces.chips.slice(0, 3) ?? []), ...dims.filter((f) => f.label === "Size" || f.label === "Mass").map((f) => f.value)];
440
+ const backTip = model.back === "Project parts" ? "Back to the Project Parts page" : `Back to ${model.back === "Library" ? "the library" : model.back}`;
441
+ const product = model.links[0];
442
+ return (
443
+ <PartsSurface place="editor">
444
+ <ScrollArea className="size-full" contentClassName="mx-auto max-w-[1180px] px-8 pt-4 pb-10 max-[640px]:px-4">
445
+ {/* back, top left (the 2026-10-05 review, design 08): to the list the part was opened from, where it was scrolled to; then where the part is in the catalog */}
446
+ {(model.back || model.crumbs.length > 0) && (
447
+ <nav aria-label={model.back && actions.back ? "Back" : "Category"} className={cx(BODY, "flex min-h-8 min-w-0 items-center gap-2.5 pb-3 text-[13px]")}>
448
+ {model.back && actions.back && (
449
+ <button
450
+ type="button"
451
+ onClick={actions.back}
452
+ aria-label={backTip}
453
+ data-tooltip={backTip}
454
+ {...((model.backShortcut ?? model.backShortcutLabel) ? { "data-tooltip-shortcut": model.backShortcut ?? model.backShortcutLabel } : {})}
455
+ className={cx(
456
+ "grid size-7 shrink-0 cursor-pointer place-items-center rounded-[4px] border border-[var(--pb-control-border)] text-fg hover:bg-hover",
457
+ "outline-1 -outline-offset-1 outline-contrast focus-visible:outline focus-visible:outline-focus",
458
+ )}
459
+ >
460
+ <Icon name="arrow-left" size={15} strokeWidth={1.75} aria-hidden />
461
+ </button>
462
+ )}
463
+ <ol className="flex min-w-0 items-center gap-1.5 text-fg-muted">
464
+ {[...(model.back ? [model.back] : []), ...model.crumbs].map((c, n) => (
465
+ <li key={`${c}-${n}`} className={cx("flex items-center gap-1.5", n === 0 && model.back ? "shrink-0" : "min-w-0 shrink max-[640px]:hidden")}>
466
+ {n > 0 && <Icon name="chevron-right" size={12} className="shrink-0 text-fg-faint" aria-hidden />}
467
+ <span className="truncate">{c}</span>
468
+ </li>
469
+ ))}
470
+ <li className="flex min-w-0 shrink-[0.5] items-center gap-1.5">
471
+ <Icon name="chevron-right" size={12} className="shrink-0 text-fg-faint" aria-hidden />
472
+ <span className="truncate text-fg" aria-current="page">
473
+ {model.name}
474
+ </span>
475
+ </li>
476
+ </ol>
477
+ </nav>
478
+ )}
479
+ <header className="flex gap-7 max-[760px]:flex-col max-[760px]:gap-4">
480
+ <div className="flex w-[440px] max-w-full shrink-0 flex-col gap-2 max-[760px]:w-full">
481
+ <div className="pb-reveal-host">
482
+ <ImageCarousel images={images} alt={model.name} index={shown} onIndex={setPicture} className="h-[264px] w-full rounded-[6px] border border-line outline-1 -outline-offset-1 outline-contrast max-[520px]:h-[200px]" arrow={36} tabbable iconSize={44}>
483
+ {images.length > 0 && (
484
+ <span className="absolute top-2 right-2 z-[1]">
485
+ <IconButton icon="expand" label="Show the picture larger" onClick={() => setLarge(true)} className="bg-[color-mix(in_oklab,black_64%,transparent)] !text-white" />
486
+ </span>
487
+ )}
488
+ </ImageCarousel>
489
+ </div>
490
+ {images.length > 1 && (
491
+ <div className={cx(BODY, "flex min-w-0 items-center gap-2")}>
492
+ <ScrollArea axis="horizontal" className="min-w-0" contentClassName="flex gap-2 p-px">
493
+ {images.map((img, k) => (
494
+ <button
495
+ key={k}
496
+ type="button"
497
+ aria-label={`Picture ${k + 1} of ${images.length}: ${img.label}`}
498
+ aria-current={k === shown || undefined}
499
+ data-tooltip={img.label}
500
+ onClick={() => setPicture(k)}
501
+ className={cx(
502
+ "h-12 w-16 shrink-0 cursor-pointer overflow-hidden rounded-[4px] border outline-1 -outline-offset-1 outline-contrast",
503
+ k === shown ? "border-focus outline-contrast-active" : "border-line hover:border-[var(--pb-control-border)]",
504
+ "focus-visible:outline focus-visible:outline-focus",
505
+ )}
506
+ >
507
+ <Thumb src={img.url} alt="" className="size-full" iconSize={16} />
508
+ </button>
509
+ ))}
510
+ </ScrollArea>
511
+ <span className="shrink-0 text-[12px] text-fg-muted">{images.length} pictures</span>
512
+ </div>
513
+ )}
514
+ </div>
515
+ <div className={cx(BODY, "flex min-w-0 flex-1 flex-col gap-1.5")}>
516
+ <h1 className="text-[20px] leading-tight font-semibold text-fg">{model.name}</h1>
517
+ {sub.length > 0 && <p className="truncate font-ui text-[12.5px] tracking-[0.02em] text-fg-muted">{sub.join(" · ")}</p>}
518
+ <div className="flex flex-wrap gap-1.5 pt-0.5">
519
+ <Pill>
520
+ {rev(model.revision)}
521
+ {model.recommended === model.revision ? " · recommended" : model.latest === model.revision ? " · latest" : ""}
522
+ </Pill>
523
+ {model.trust && (
524
+ <span className="inline-flex h-[18px] items-center rounded-full border border-[var(--pb-control-border)] px-1.5">
525
+ <PartStatusDot trust={model.trust} basis={model.trustBasis} label size={7} />
526
+ </span>
527
+ )}
528
+ {used !== undefined && <Pill tone="ok">used {rev(used)} in this project</Pill>}
529
+ {model.used?.elsewhere && <Pill tone="warning">in the project from another library</Pill>}
530
+ {!model.trust && audited && <Pill>verification record</Pill>}
531
+ {model.pages?.cad?.origin ? <Pill tone="info">CAD: {model.pages.cad.origin.replace(/^(Vendor CAD|Generated)/, (m) => (m === "Vendor CAD" ? "vendor" : "generated"))}</Pill> : model.cad ? <Pill tone="info">CAD: {model.cad}</Pill> : model.model ? <Pill tone="info">3D model</Pill> : null}
532
+ </div>
533
+ {model.description && <p className="line-clamp-4 pt-1 text-[13px] leading-relaxed text-fg">{model.description}</p>}
534
+ {chips.length > 0 && (
535
+ <div className="flex flex-wrap gap-1.5 pt-1">
536
+ {chips.map((c) => (
537
+ <Tag key={c}>{c}</Tag>
538
+ ))}
539
+ </div>
540
+ )}
541
+ <div className="flex flex-wrap gap-2 pt-2.5">
542
+ <MainAction model={model} actions={actions} />
543
+ <Button icon="git-compare-arrows" aria-label="Compare revisions" data-tooltip="Compare revisions" className="w-8 !px-0" onClick={() => (actions.compare ? actions.compare() : set("revisions"))} />
544
+ {actions.openSource && <Button icon="file-code" aria-label="Open the definition's source" data-tooltip="Open the definition's source" className="w-8 !px-0" onClick={actions.openSource} />}
545
+ {model.datasheet && <Button icon="file-text" aria-label={`Datasheet: ${model.datasheet.label}`} data-tooltip={`Datasheet: ${model.datasheet.label}`} className="w-8 !px-0" onClick={() => actions.openLink(model.datasheet!.url)} />}
546
+ {product && <Button icon="external-link" aria-label={product.label} data-tooltip={product.label} className="w-8 !px-0" onClick={() => actions.openLink(product.url)} />}
547
+ {actions.openInViewer && used !== undefined && <Button icon="scan-eye" aria-label="Show in the viewer" data-tooltip="Show in the viewer" className="w-8 !px-0" onClick={actions.openInViewer} />}
548
+ {actions.copy && <Button icon="copy" aria-label="Copy the part id" data-tooltip={`Copy the part id (${model.partId})`} className="w-8 !px-0" onClick={() => actions.copy!(model.partId)} />}
549
+ </div>
550
+ </div>
551
+ </header>
552
+
553
+ <section aria-label="Part sections" aria-roledescription="carousel" className="mt-6 rounded-[6px] border border-line outline-1 -outline-offset-1 outline-contrast">
554
+ <div className="flex items-center gap-2 border-b border-line-subtle pr-2 pl-3">
555
+ <ScrollArea axis="horizontal" className="min-w-0 flex-1" contentClassName="flex">
556
+ <div
557
+ role="tablist"
558
+ aria-label="Sections"
559
+ className="flex"
560
+ onKeyDown={(e) => {
561
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
562
+ e.preventDefault();
563
+ step(e.key === "ArrowRight" ? 1 : -1, true);
564
+ } else if (e.key === "Home" || e.key === "End") {
565
+ e.preventDefault();
566
+ set(tabs[e.key === "Home" ? 0 : tabs.length - 1].id, true);
567
+ }
568
+ }}
569
+ >
570
+ {tabs.map((t) => (
571
+ <button
572
+ key={t.id}
573
+ id={`pb-tab-${t.id}`}
574
+ type="button"
575
+ role="tab"
576
+ aria-selected={t.id === tab}
577
+ aria-controls="pb-tabpanel"
578
+ tabIndex={t.id === tab ? 0 : -1}
579
+ onClick={() => set(t.id)}
580
+ className={cx(
581
+ BODY,
582
+ "relative flex h-10 shrink-0 cursor-pointer items-center gap-1.5 px-2.5 text-[13px] transition-colors",
583
+ t.id === tab ? "font-semibold text-fg" : "text-fg-muted hover:text-fg",
584
+ "focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-2 focus-visible:outline-focus",
585
+ )}
586
+ >
587
+ <Icon name={t.icon} size={14} strokeWidth={1.75} className={t.id === tab ? "text-accent" : undefined} aria-hidden />
588
+ {t.label}
589
+ {t.id === tab && <span aria-hidden className="absolute right-2.5 bottom-0 left-2.5 h-0.5 rounded-full bg-accent outline-1 outline-contrast-active" />}
590
+ </button>
591
+ ))}
592
+ </div>
593
+ </ScrollArea>
594
+ <span aria-hidden className="shrink-0 font-ui text-[11px] text-fg-muted tabular-nums max-[640px]:hidden">
595
+ {i + 1} / {tabs.length}
596
+ </span>
597
+ <StepButton dir="prev" label="Previous section" onClick={() => step(-1)} size={26} className="border border-[var(--pb-control-border)] !text-fg outline-1 -outline-offset-1 outline-contrast" />
598
+ <StepButton dir="next" label="Next section" onClick={() => step(1)} size={26} className="border border-[var(--pb-control-border)] !text-fg outline-1 -outline-offset-1 outline-contrast" />
599
+ </div>
600
+ <div id="pb-tabpanel" role="tabpanel" aria-labelledby={`pb-tab-${tab}`} className="touch-pan-y p-4" {...(tab === "cad" ? {} : swipe)}>
601
+ <div key={tab} className="pb-page-in" style={{ "--pb-page-from": `${dir.current * 8}px` } as React.CSSProperties}>
602
+ <Section tab={tab} model={model} actions={actions} prices={prices} />
603
+ </div>
604
+ </div>
605
+ <div className="flex justify-center pb-3">
606
+ <Segments index={i} count={tabs.length} />
607
+ </div>
608
+ </section>
609
+ </ScrollArea>
610
+ {large && images.length > 0 && <Lightbox images={images} index={shown} onIndex={setPicture} onClose={() => setLarge(false)} alt={model.name} />}
611
+ </PartsSurface>
612
+ );
613
+ }