@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,317 @@
1
+ /**
2
+ * A card's carousels (PB-918, designs 06 and 07): the picture carousel on
3
+ * top (dots always, arrows and the "2 / 4" index on hover or focus) and the
4
+ * pages of the text carousel under the title: overview, specs, dimensions,
5
+ * interfaces, CAD and artifacts, each drawn to fit a fixed box so the card
6
+ * never changes size. A list shows a still of the CAD, never the model.
7
+ */
8
+ import { useEffect, useRef, type ReactNode } from "react";
9
+ import { Icon, cx } from "@deltarobotics/uhd-viewer/components";
10
+ import { StepButton, useSwipe, type PagesState } from "./carousel.js";
11
+ import type { CardPageId, PartCardModel, PartFact, PartImage, PartOutline, PartPages } from "./model.js";
12
+ import { BODY, Tag, Thumb } from "./ui.js";
13
+
14
+ // ---------------------------------------------------------------- pictures
15
+
16
+ export interface ImageCarouselProps {
17
+ images: PartImage[];
18
+ /** The part's name, for the pictures' accessible names. */
19
+ alt: string;
20
+ index: number;
21
+ onIndex(index: number): void;
22
+ className?: string;
23
+ /** The placeholder icon when the part has no picture. */
24
+ icon?: string;
25
+ iconSize?: number;
26
+ /** The arrows' size (24 on a tile, 18 on a row, 36 in the detail). */
27
+ arrow?: number;
28
+ /** Arrows and dots take part in the tab order (the detail); on a card the card's own keys turn the pictures. */
29
+ tabbable?: boolean;
30
+ /** Over the picture, above the carousel's own controls (the state pill, the expand button). */
31
+ children?: ReactNode;
32
+ }
33
+
34
+ /** A part's pictures, one at a time. With one picture there is nothing to turn: no dots, no arrows. */
35
+ export function ImageCarousel({ images, alt, index, onIndex, className, icon, iconSize, arrow = 24, tabbable, children }: ImageCarouselProps) {
36
+ const n = images.length;
37
+ const i = n ? Math.min(Math.max(index, 0), n - 1) : 0;
38
+ const step = (d: number) => n > 1 && onIndex((i + d + n) % n);
39
+ const swipe = useSwipe(step);
40
+ const tab = tabbable ? 0 : -1;
41
+ const round = cx(
42
+ "pb-reveal absolute top-1/2 z-[1] -translate-y-1/2 !rounded-full bg-[color-mix(in_oklab,black_64%,transparent)] !text-white hover:bg-[color-mix(in_oklab,black_82%,transparent)]",
43
+ "outline-1 outline-contrast",
44
+ );
45
+ return (
46
+ <div role={n > 1 ? "group" : undefined} aria-roledescription={n > 1 ? "carousel" : undefined} aria-label={n > 1 ? `Pictures of ${alt}` : undefined} className={cx("relative shrink-0 touch-pan-y overflow-hidden", className)} {...(n > 1 ? swipe : {})}>
47
+ <Thumb src={images[i]?.url} alt={n > 1 ? `${alt}: ${images[i].label} (${i + 1} of ${n})` : alt} className="size-full" icon={icon} {...(iconSize ? { iconSize } : {})} />
48
+ {n > 1 && (
49
+ <>
50
+ <span aria-hidden className="pb-reveal absolute top-2 left-2 z-[1] rounded-full bg-[color-mix(in_oklab,black_64%,transparent)] px-1.5 py-0.5 font-ui text-[10px] leading-none text-white tabular-nums outline-1 outline-contrast">
51
+ {i + 1} / {n}
52
+ </span>
53
+ <StepButton dir="prev" label="Previous picture" onClick={() => step(-1)} size={arrow} tabIndex={tab} className={cx(round, arrow > 20 ? "left-2" : "left-1")} />
54
+ <StepButton dir="next" label="Next picture" onClick={() => step(1)} size={arrow} tabIndex={tab} className={cx(round, arrow > 20 ? "right-2" : "right-1")} />
55
+ <span className="absolute inset-x-0 bottom-1.5 z-[1] flex justify-center">
56
+ <span className="flex items-center gap-1 rounded-full bg-[color-mix(in_oklab,black_48%,transparent)] px-1.5 py-1 outline-1 outline-contrast">
57
+ {images.map((img, k) => (
58
+ <button
59
+ key={k}
60
+ type="button"
61
+ tabIndex={tab}
62
+ aria-label={`Picture ${k + 1} of ${n}: ${img.label}`}
63
+ aria-current={k === i || undefined}
64
+ data-tooltip={img.label}
65
+ onClick={(e) => {
66
+ e.stopPropagation();
67
+ onIndex(k);
68
+ }}
69
+ onKeyDown={(e) => e.stopPropagation()}
70
+ className={cx("h-[5px] cursor-pointer rounded-full transition-[width] motion-reduce:transition-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-white", k === i ? "w-3 bg-white" : "w-[5px] bg-white/55 hover:bg-white/80")}
71
+ />
72
+ ))}
73
+ </span>
74
+ </span>
75
+ </>
76
+ )}
77
+ {children}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ // ---------------------------------------------------------------- the pages of a card
83
+
84
+ /** Label and value on one line: the label gives way first, the value keeps to the right in the mono face. */
85
+ function Row({ fact, tall }: { fact: PartFact; tall?: boolean }) {
86
+ return (
87
+ <div className={cx("flex min-w-0 items-baseline justify-between gap-2", tall ? "h-[17px]" : "h-[15px]")} title={`${fact.label}: ${fact.value}`}>
88
+ <span className="min-w-0 shrink truncate text-fg-muted">{fact.label}</span>
89
+ <span className="max-w-[62%] shrink-0 truncate font-ui text-[11px] text-fg">{fact.value}</span>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /**
95
+ * The part's outline with its two measures, drawn to scale: a board from
96
+ * above (length by width), a round part from the side (diameter by height).
97
+ */
98
+ export function OutlineDrawing({ outline, width, height, className }: { outline: PartOutline; width: number; height: number; className?: string }) {
99
+ const round = outline.diameter !== undefined && (outline.length === undefined || outline.length === outline.width);
100
+ const a = round ? outline.diameter! : (outline.length ?? outline.width ?? outline.height ?? 1);
101
+ const b = round ? (outline.height ?? outline.diameter!) : (outline.width ?? outline.height ?? a);
102
+ // room for the two labels: below (the first measure) and to the right (the second)
103
+ const pad = { right: Math.max(26, width * 0.34), bottom: 12 };
104
+ const boxW = width - pad.right - 2;
105
+ const boxH = height - pad.bottom - 2;
106
+ const k = Math.min(boxW / a, boxH / b);
107
+ const w = Math.max(6, a * k);
108
+ const h = Math.max(6, b * k);
109
+ const x = 1 + (boxW - w) / 2;
110
+ const y = 1 + (boxH - h) / 2;
111
+ const n = (v: number) => String(Math.round(v * 10) / 10);
112
+ const font = Math.max(7.5, Math.min(10, width / 8));
113
+ return (
114
+ <svg role="img" aria-label={`Outline: ${round ? `diameter ${n(a)}` : n(a)} by ${n(b)} mm`} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cx("shrink-0 text-fg-muted", className)}>
115
+ <rect x={x} y={y} width={w} height={h} rx={round ? 1.5 : 2} fill="none" stroke="currentColor" strokeWidth={1.25} className="text-fg" />
116
+ <line x1={x} y1={y + h + 4} x2={x + w} y2={y + h + 4} stroke="currentColor" strokeWidth={0.75} />
117
+ <line x1={x + w + 4} y1={y} x2={x + w + 4} y2={y + h} stroke="currentColor" strokeWidth={0.75} />
118
+ <text x={x + w / 2} y={height - 1} textAnchor="middle" fontSize={font} fill="currentColor" className="font-ui">
119
+ {round ? `Ø${n(a)}` : n(a)}
120
+ </text>
121
+ <text x={x + w + 7} y={y + h / 2 + font / 3} fontSize={font} fill="currentColor" className="font-ui">
122
+ {n(b)}
123
+ </text>
124
+ </svg>
125
+ );
126
+ }
127
+
128
+ /** The rows beside a card's outline: the height the drawing does not show, then the rest (the size is the drawing). */
129
+ export function dimensionRows(d: NonNullable<PartPages["dimensions"]>): PartFact[] {
130
+ if (!d.outline) return d.rows;
131
+ const round = d.outline.diameter !== undefined && (d.outline.length === undefined || d.outline.length === d.outline.width);
132
+ const rest = d.rows.filter((r) => r.label !== "Size" && !(round && /^(diameter|overall height)$/i.test(r.label)));
133
+ return [...(!round && d.outline.height !== undefined ? [{ label: "Height", value: `${d.outline.height} mm` }] : []), ...rest];
134
+ }
135
+
136
+ /** As many chips as fit a budget of characters, and how many are left. */
137
+ export function fitChips(chips: string[], budget: number): { shown: string[]; more: number } {
138
+ const shown: string[] = [];
139
+ let used = 0;
140
+ for (const c of chips) {
141
+ const cost = c.length + 3;
142
+ if (shown.length && used + cost > budget) break;
143
+ shown.push(c);
144
+ used += cost;
145
+ }
146
+ return { shown, more: chips.length - shown.length };
147
+ }
148
+
149
+ function Skeleton({ lines }: { lines: number }) {
150
+ return (
151
+ <div role="status" aria-label="Loading" className="flex h-full flex-col justify-start gap-[7px] pt-1">
152
+ {Array.from({ length: lines }, (_, i) => (
153
+ <span key={i} aria-hidden className="h-[9px] animate-pulse rounded-[2px] bg-muted motion-reduce:animate-none" style={{ width: `${[92, 70, 84, 58][i % 4]}%` }} />
154
+ ))}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ export interface CardPageProps {
160
+ page: CardPageId;
161
+ item: PartCardModel;
162
+ state: PagesState;
163
+ /** `tile`: four lines (68 px); `row`: three (46 px). */
164
+ variant: "tile" | "row";
165
+ /** Before the overview's first line (the Project Parts page: the part's status dot). */
166
+ status?: ReactNode;
167
+ onRetry(): void;
168
+ /** -1 or 1: the side the page came in from. */
169
+ from?: number;
170
+ }
171
+
172
+ /** One page of a card's text carousel, in its fixed box. */
173
+ export function CardPage({ page, item, state, variant, status, onRetry, from = 1 }: CardPageProps) {
174
+ const tile = variant === "tile";
175
+ const lines = tile ? 4 : 3;
176
+ const box = cx(BODY, "pb-page-in min-w-0 overflow-hidden text-[11.5px] leading-[1.3]", tile ? "h-[68px]" : "h-[46px]");
177
+ const style = { "--pb-page-from": `${from * 6}px` } as React.CSSProperties;
178
+ if (page === "overview") {
179
+ const who = [item.manufacturer, item.mpn && item.mpn !== item.name ? item.mpn : undefined].filter(Boolean).join(" · ");
180
+ const first = tile ? [who, item.category?.split(" · ").pop()].filter(Boolean).join(" · ") : item.category;
181
+ return (
182
+ <div key="overview" className={box} style={style}>
183
+ {(first || status) && (
184
+ <div className="flex h-[15px] min-w-0 items-center gap-1.5 text-fg-muted">
185
+ {status}
186
+ {first && (
187
+ <span className="flex min-w-0 items-center gap-1">
188
+ {!tile && <Icon name="tag" size={11} strokeWidth={1.75} className="shrink-0" aria-hidden />}
189
+ <span className="truncate">{first}</span>
190
+ </span>
191
+ )}
192
+ </div>
193
+ )}
194
+ {item.description && <p className={cx("line-clamp-2 text-fg", tile ? "text-[12px]" : "text-[11.5px]")}>{item.description}</p>}
195
+ {tile && item.tags.length > 0 && (
196
+ <div className="mt-1 flex h-[18px] gap-1 overflow-hidden">
197
+ {item.tags.slice(0, 4).map((t) => (
198
+ <Tag key={t}>{t}</Tag>
199
+ ))}
200
+ </div>
201
+ )}
202
+ </div>
203
+ );
204
+ }
205
+ if (state.status === "error")
206
+ return (
207
+ <div key="error" role="status" className={cx(box, "flex flex-col items-start gap-1 text-fg-muted")}>
208
+ <span className="line-clamp-2">The library did not give this part's details: {state.error}</span>
209
+ <button
210
+ type="button"
211
+ onClick={(e) => {
212
+ e.stopPropagation();
213
+ onRetry();
214
+ }}
215
+ className="cursor-pointer rounded-[3px] text-link underline-offset-2 hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus"
216
+ >
217
+ Try again
218
+ </button>
219
+ </div>
220
+ );
221
+ if (state.status !== "ready")
222
+ return (
223
+ <div key="loading" className={box}>
224
+ <Skeleton lines={lines} />
225
+ </div>
226
+ );
227
+ const p = state.pages;
228
+ switch (page) {
229
+ case "specs":
230
+ return (
231
+ <div key={page} className={box} style={style}>
232
+ {p.specs.slice(0, lines).map((f) => (
233
+ <Row key={f.label} fact={f} tall={tile} />
234
+ ))}
235
+ </div>
236
+ );
237
+ case "dimensions": {
238
+ const d = p.dimensions!;
239
+ const rows = dimensionRows(d).slice(0, d.outline ? 3 : lines);
240
+ return (
241
+ <div key={page} className={cx(box, "flex items-center gap-2.5")} style={style}>
242
+ {d.outline && <OutlineDrawing outline={d.outline} width={tile ? 78 : 62} height={tile ? 62 : 44} />}
243
+ <div className="min-w-0 flex-1">
244
+ {rows.map((f) => (
245
+ <Row key={f.label} fact={f} tall={tile} />
246
+ ))}
247
+ </div>
248
+ </div>
249
+ );
250
+ }
251
+ case "interfaces": {
252
+ const { shown, more } = fitChips(p.interfaces.chips, tile ? 74 : 44);
253
+ return (
254
+ <div key={page} className={cx(box, "flex flex-wrap content-start gap-1")} style={style} aria-label={`${p.interfaces.count} interfaces`}>
255
+ {shown.map((c) => (
256
+ <Tag key={c}>{c}</Tag>
257
+ ))}
258
+ {more > 0 && (
259
+ <span data-tooltip={p.interfaces.chips.slice(shown.length).join(", ")}>
260
+ <Tag>+{more}</Tag>
261
+ </span>
262
+ )}
263
+ </div>
264
+ );
265
+ }
266
+ case "cad": {
267
+ const c = p.cad!;
268
+ const texts = [c.file, c.origin, c.features ? `${c.features} mate feature${c.features === 1 ? "" : "s"}` : undefined].filter((x): x is string => !!x);
269
+ return (
270
+ <div key={page} className={cx(box, "flex items-center gap-2.5")} style={style}>
271
+ <div className={cx("relative shrink-0 overflow-hidden rounded-[3px] outline-1 -outline-offset-1 outline-contrast", tile ? "h-[60px] w-[84px]" : "h-[44px] w-[60px]")}>
272
+ <Thumb src={c.preview} alt="" className="size-full" icon="box" iconSize={20} />
273
+ <Icon name="rotate-3d" size={12} strokeWidth={1.75} className="absolute right-1 bottom-1 text-[var(--pb-thumb-fg)]" aria-hidden />
274
+ </div>
275
+ <div className="min-w-0 flex-1">
276
+ {texts.slice(0, tile ? 3 : 2).map((t, i) => (
277
+ <div key={t} className={cx("h-[15px] truncate", i === 0 ? "font-ui text-[11px] text-fg" : "text-fg-muted")}>
278
+ {t}
279
+ </div>
280
+ ))}
281
+ <div className="h-[15px] truncate text-fg-faint">{c.mesh ? "Open the part to orbit" : "No 3D mesh"}</div>
282
+ </div>
283
+ </div>
284
+ );
285
+ }
286
+ case "artifacts": {
287
+ const over = p.artifacts.length > lines;
288
+ const rows = over ? p.artifacts.slice(0, lines - 1) : p.artifacts;
289
+ return (
290
+ <div key={page} className={box} style={style}>
291
+ {rows.map((g) => (
292
+ <div key={g.kind} className={cx("flex min-w-0 items-center gap-1.5", tile ? "h-[17px]" : "h-[15px]")} title={`${g.label}: ${g.items.map((x) => x.name).join(", ")}`}>
293
+ <Icon name={g.icon} size={12} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
294
+ <span className="min-w-0 flex-1 truncate text-fg">{g.summary}</span>
295
+ <span className="shrink-0 font-ui text-[10px] text-fg-muted">{g.note}</span>
296
+ </div>
297
+ ))}
298
+ {over && (
299
+ <div className={cx("flex items-center gap-1.5 text-fg-muted", tile ? "h-[17px]" : "h-[15px]")}>
300
+ <Icon name="ellipsis" size={12} strokeWidth={1.75} className="shrink-0" aria-hidden />
301
+ <span className="truncate">{p.artifacts.length - rows.length} more kinds</span>
302
+ </div>
303
+ )}
304
+ </div>
305
+ );
306
+ }
307
+ }
308
+ }
309
+
310
+ /** Says a page change to a screen reader, once, when the reader made it. */
311
+ export function Announce({ text }: { text: string | undefined }) {
312
+ const ref = useRef<HTMLSpanElement>(null);
313
+ useEffect(() => {
314
+ if (ref.current && text) ref.current.textContent = text;
315
+ }, [text]);
316
+ return <span ref={ref} aria-live="polite" className="sr-only" />;
317
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The library's categories as a list that drills in and out (PB-934), the
3
+ * way the alpha's TaxonomyFilter does: one level of the taxonomy at a time.
4
+ * A row narrows the results to that category and, when it has categories of
5
+ * its own, the list moves down into it; the row at the top goes back up, and
6
+ * "All in …" is the level itself. A category with nothing below it stays
7
+ * listed among its siblings, selected. Every row carries its count; the
8
+ * breadcrumb above (Crumbs) says where the list is and jumps to any level.
9
+ */
10
+ import { Icon, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
11
+ import type { LibraryModel } from "./model.js";
12
+ import { BODY, categoryIcon } from "./ui.js";
13
+
14
+ export interface CategoryListProps {
15
+ model: Pick<LibraryModel, "crumbs" | "categories" | "level" | "query" | "total" | "catalogTotal" | "loading">;
16
+ onPick: (path: string) => void;
17
+ /** Scroll the list inside its own area (give it a height limit in `className`): the side bar keeps room for the parts. */
18
+ scroll?: boolean;
19
+ className?: string;
20
+ }
21
+
22
+ function Row({ name, icon, count, active, more, title, onClick, back }: { name: string; icon: string; count?: number; active?: boolean; more?: boolean; title?: string; onClick: () => void; back?: boolean }) {
23
+ return (
24
+ <button
25
+ type="button"
26
+ role="treeitem"
27
+ aria-selected={active || undefined}
28
+ aria-current={active ? "true" : undefined}
29
+ aria-label={back ? `Back to ${name}` : more ? `${name}, ${count ?? 0} parts, has sub-categories` : count !== undefined ? `${name}, ${count} parts` : name}
30
+ title={title}
31
+ onClick={onClick}
32
+ className={cx(
33
+ BODY,
34
+ "group flex h-7 w-full min-w-0 cursor-pointer items-center gap-2 rounded-[4px] px-2 text-left text-[12.5px] transition-colors",
35
+ active ? "bg-active font-medium text-fg outline outline-1 -outline-offset-1 outline-contrast-active" : back ? "text-fg-muted hover:bg-hover hover:text-fg" : "text-fg hover:bg-hover",
36
+ "focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus",
37
+ )}
38
+ >
39
+ <Icon name={icon} size={14} strokeWidth={1.75} className={cx("shrink-0", active ? "text-fg" : "text-fg-muted")} aria-hidden />
40
+ <span className="min-w-0 flex-1 truncate">{name}</span>
41
+ {count !== undefined && <span className="shrink-0 font-ui text-[11px] text-fg-muted tabular-nums">{count}</span>}
42
+ {/* the chevron's place is kept on every row, so the counts line up */}
43
+ {!back && <Icon name="chevron-right" size={13} className={cx("shrink-0 text-fg-muted", !more && "invisible")} aria-hidden />}
44
+ </button>
45
+ );
46
+ }
47
+
48
+ export function CategoryList({ model, onPick, scroll, className }: CategoryListProps) {
49
+ const level = model.level;
50
+ const active = model.query.category;
51
+ // the level's parent: the crumb before it (the top for a first-level category)
52
+ const at = model.crumbs.findIndex((c) => c.path === level.path);
53
+ const parent = at > 0 ? model.crumbs[at - 1] : undefined;
54
+ const root = !level.path;
55
+ // at the top, "All parts" counts the catalog; below it, the level's own count
56
+ const allCount = root ? (model.catalogTotal ?? (active ? undefined : model.loading ? undefined : model.total)) : level.count;
57
+ const list = (
58
+ <div role="tree" aria-label={root ? "Categories" : `Categories in ${level.name}`} className="flex flex-col gap-px">
59
+ {parent && <Row back name={parent.path ? parent.name : "All categories"} icon="arrow-left" onClick={() => onPick(parent.path)} />}
60
+ <Row name={root ? "All parts" : `All in ${level.name}`} icon={root ? "layout-grid" : "layers"} count={allCount} active={active === level.path} onClick={() => onPick(level.path)} />
61
+ {model.categories.map((c) => (
62
+ <Row key={c.path} name={c.name} icon={c.icon ?? categoryIcon(c.path)} count={c.count} more={c.more} active={active === c.path} title={c.description} onClick={() => onPick(c.path)} />
63
+ ))}
64
+ </div>
65
+ );
66
+ if (!scroll) return <div className={className}>{list}</div>;
67
+ return (
68
+ <ScrollArea className={className} contentClassName="pr-1.5">
69
+ {list}
70
+ </ScrollArea>
71
+ );
72
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The Cloud panel (PB-904, design 04): cloud information only, in its own
3
+ * container. The account (the real sign-in), the project (name, what it is,
4
+ * its libraries and parts, whether it is published), the releases and the
5
+ * Manufacturing Package action, the cost estimate as a preview (the BOM
6
+ * opens in its own tab), and quotes.
7
+ */
8
+ import { Icon, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
9
+ import type { CloudActions, CloudModel } from "./model.js";
10
+ import { PartsSurface } from "./Surface.js";
11
+ import { Avatar, BODY, Button, Card, SectionLabel, StatusText } from "./ui.js";
12
+
13
+ function Account({ model, actions }: { model: CloudModel; actions: CloudActions }) {
14
+ const a = model.account;
15
+ const signedIn = a.state === "signed-in";
16
+ return (
17
+ <div className={cx(BODY, "flex items-center gap-2.5")}>
18
+ <Avatar email={signedIn ? a.email : undefined} size={28} />
19
+ <div className="min-w-0 flex-1">
20
+ <p className="truncate text-[13px] text-fg">
21
+ {signedIn ? (a.email ?? "Signed in") : a.state === "open" ? "No account needed" : a.state === "expired" ? "Sign in again" : a.state === "unknown" ? "Checking the sign-in…" : "Not signed in"}
22
+ </p>
23
+ <p className="truncate text-[11.5px] text-fg-muted">
24
+ {signedIn
25
+ ? (a.detail ?? (a.publisher ? "Publisher" : "Protoboard Cloud"))
26
+ : a.state === "open"
27
+ ? `${a.service ?? "The library"} signs no one in`
28
+ : a.state === "expired"
29
+ ? (a.problem ?? "The sign-in no longer works")
30
+ : "Prices, quotes and publishing need an account"}
31
+ </p>
32
+ </div>
33
+ {!signedIn && a.state !== "open" && a.state !== "unknown" && actions.signIn && (
34
+ <Button size="sm" kind="primary" icon="log-in" onClick={actions.signIn}>
35
+ Sign in
36
+ </Button>
37
+ )}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export function CloudPanel({ model, actions }: { model: CloudModel; actions: CloudActions }) {
43
+ const p = model.project;
44
+ const c = model.cost;
45
+ const running = model.release.state === "running";
46
+ const latest = model.releases[0];
47
+ const pct = c && c.bought ? Math.round((c.priced / c.bought) * 100) : 0;
48
+ return (
49
+ <PartsSurface place="sidebar">
50
+ <ScrollArea className="size-full" contentClassName={cx(BODY, "flex min-w-[220px] flex-col gap-2 px-3 pt-1 pb-4")}>
51
+ <SectionLabel icon="user">Account</SectionLabel>
52
+ <Account model={model} actions={actions} />
53
+
54
+ <SectionLabel icon="folder-kanban">Project</SectionLabel>
55
+ <Card className="flex flex-col gap-1 px-3 py-2.5">
56
+ <h2 className="truncate text-[14px] font-semibold text-fg">{p.name}</h2>
57
+ {p.description && <p className="line-clamp-2 text-[12px] leading-snug text-fg-muted">{p.description}</p>}
58
+ <p className="text-[12px] text-fg-muted">
59
+ {[p.author, `${p.libraries} librar${p.libraries === 1 ? "y" : "ies"}`, p.parts !== undefined ? `${p.parts} parts` : ""].filter(Boolean).join(" · ")}
60
+ </p>
61
+ <div className="flex items-center gap-2 pt-1">
62
+ <span className="flex-1">{p.published ? <StatusText tone="ok">Published {p.published.at}</StatusText> : <StatusText tone="warning">Not published</StatusText>}</span>
63
+ {actions.publish && (
64
+ <Button size="sm" icon="cloud-upload" onClick={actions.publish} title="Publishing a project to Protoboard Cloud is not available yet">
65
+ Publish
66
+ </Button>
67
+ )}
68
+ </div>
69
+ </Card>
70
+
71
+ <SectionLabel icon="package">Releases</SectionLabel>
72
+ {model.releases.length ? (
73
+ model.releases.slice(0, 3).map((r) => (
74
+ <div key={r.version} className="flex items-center gap-2.5 px-1">
75
+ <Icon name="tag" size={14} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
76
+ <div className="min-w-0 flex-1">
77
+ <p className="font-ui text-[13px] text-fg">{r.version}</p>
78
+ <p className="truncate text-[11.5px] text-fg-muted">{[r.date, r.units !== undefined ? `${r.units} unit${r.units === 1 ? "" : "s"}` : "", r.files !== undefined ? `${r.files} files` : ""].filter(Boolean).join(" · ")}</p>
79
+ </div>
80
+ {actions.openRelease && (
81
+ <button type="button" aria-label={`Open release ${r.version}`} data-tooltip="Open the Manufacturing Package" onClick={() => actions.openRelease!(r.version)} className="grid size-6 cursor-pointer place-items-center rounded-[4px] text-fg-muted hover:bg-active hover:text-fg">
82
+ <Icon name="external-link" size={14} aria-hidden />
83
+ </button>
84
+ )}
85
+ </div>
86
+ ))
87
+ ) : (
88
+ <p className="px-1 text-[12px] text-fg-muted">No release yet.</p>
89
+ )}
90
+ {actions.generate && (
91
+ <Button kind="primary" icon={running ? "loader" : "package-check"} className="w-full" disabled={running} onClick={actions.generate}>
92
+ {running ? (model.release.step ?? "Generating…") : "Generate Manufacturing Package"}
93
+ </Button>
94
+ )}
95
+ {model.release.state === "failed" || model.release.state === "refused" ? (
96
+ <div className="flex flex-col gap-0.5 px-1 text-[12px]">
97
+ <p className="text-error">{model.release.message}</p>
98
+ {(model.release.problems ?? []).slice(0, 4).map((x) => (
99
+ <p key={x.code + x.message} className="text-fg-muted">
100
+ {x.message}
101
+ </p>
102
+ ))}
103
+ </div>
104
+ ) : model.release.state === "done" && model.release.message ? (
105
+ <p className="px-1 text-[12px] text-ok">{model.release.message}</p>
106
+ ) : null}
107
+
108
+ <SectionLabel icon="calculator">Cost estimate</SectionLabel>
109
+ <Card className="flex flex-col gap-1.5 px-3 py-2.5">
110
+ {c ? (
111
+ <>
112
+ <p className="flex flex-wrap items-baseline gap-x-2">
113
+ <span className="font-ui text-[22px] leading-tight text-fg">{c.total}</span>
114
+ <span className="text-[11.5px] text-fg-muted">per unit</span>
115
+ </p>
116
+ {c.order && <p className="font-ui text-[12px] text-fg">{c.order}</p>}
117
+ {c.others.length > 0 && <p className="text-[11.5px] leading-snug text-fg-muted">{c.others.join(" · ")}</p>}
118
+ <div role="progressbar" aria-label="Bought lines priced" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct} className="h-1 overflow-hidden rounded-full bg-muted">
119
+ <div className="h-full rounded-full bg-ok" style={{ width: `${pct}%` }} />
120
+ </div>
121
+ <p className="text-[11.5px] leading-snug text-fg-muted">
122
+ {c.priced} of {c.bought} bought lines priced · {c.unpriced} unpriced · {c.made} made (need a quote)
123
+ </p>
124
+ <div className="flex flex-col">
125
+ {c.top.map((l) => (
126
+ <div key={l.item} className="flex items-center gap-2 py-0.5 text-[12px]">
127
+ <span className="min-w-0 flex-1 truncate text-fg">{l.item}</span>
128
+ <span className="font-ui text-fg">{l.price}</span>
129
+ </div>
130
+ ))}
131
+ </div>
132
+ {actions.openBom && (
133
+ <Button icon="sheet" className="w-full" onClick={actions.openBom}>
134
+ Open bill of materials
135
+ </Button>
136
+ )}
137
+ </>
138
+ ) : (
139
+ <p className="text-[12px] text-fg-muted">{model.costMissing ?? "Check the project to estimate its cost."}</p>
140
+ )}
141
+ </Card>
142
+
143
+ <SectionLabel icon="handshake">Quotes</SectionLabel>
144
+ <Card className="flex items-center gap-2 px-3 py-2">
145
+ <span className="flex-1 text-[12.5px] text-fg-muted">{model.quotes.length ? `${model.quotes.length} quote(s)` : "No quotes yet"}</span>
146
+ {actions.requestQuote && (
147
+ <Button size="sm" onClick={actions.requestQuote} title="Quotes from Protoboard Cloud are not available yet">
148
+ Request quote
149
+ </Button>
150
+ )}
151
+ </Card>
152
+ {latest === undefined && actions.fetchOffers && c && !c.priced && (
153
+ <button type="button" onClick={actions.fetchOffers} className="px-1 text-left text-[12px] text-link hover:underline">
154
+ Get catalog offers to price the lines
155
+ </button>
156
+ )}
157
+ </ScrollArea>
158
+ </PartsSurface>
159
+ );
160
+ }
@@ -0,0 +1,116 @@
1
+ /// <reference path="./three.d.ts" />
2
+ /**
3
+ * A part's body model in a small orbiting scene (the part detail's CAD
4
+ * section): three.js (the viewer's own copy, already in every shell's
5
+ * bundle); the scene's background is the page's, its light neutral.
6
+ */
7
+ import { useEffect, useRef, useState } from "react";
8
+ import * as THREE from "three";
9
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
10
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
11
+ import { EmptyState, Icon } from "@deltarobotics/uhd-viewer/components";
12
+
13
+ export function GlbPreview({ url, label }: { url: string; label: string }) {
14
+ const host = useRef<HTMLDivElement>(null);
15
+ const [state, setState] = useState<{ kind: "loading" | "ready" } | { kind: "error"; message: string }>({ kind: "loading" });
16
+ useEffect(() => {
17
+ const el = host.current;
18
+ if (!el) return;
19
+ let disposed = false;
20
+ let cleanup = () => {};
21
+ setState({ kind: "loading" });
22
+ void (async () => {
23
+ try {
24
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
25
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
26
+ // the 3D view's look (uhd-viewer src/cad/scene.ts): filmic tone, a sky/ground fill, a key and a warm rim light (PB-899)
27
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
28
+ renderer.toneMappingExposure = 1.05;
29
+ renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
30
+ renderer.setClearColor(0x000000, 0);
31
+ el.replaceChildren(renderer.domElement);
32
+ const scene = new THREE.Scene();
33
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.0001, 100);
34
+ scene.add(new THREE.HemisphereLight(0xf5f5f4, 0x1c1917, 1.4));
35
+ // positions in glTF's Y-up: the 3D view's key (200, -150, 400) and rim (-300, 250, 120) in its Z-up
36
+ const key = new THREE.DirectionalLight(0xffffff, 2.2);
37
+ key.position.set(200, 400, 150);
38
+ const rim = new THREE.DirectionalLight(0xffedd5, 0.8);
39
+ rim.position.set(-300, 120, -250);
40
+ scene.add(key, rim);
41
+ const controls = new OrbitControls(camera, renderer.domElement);
42
+ controls.enableDamping = true;
43
+ controls.autoRotate = true;
44
+ controls.autoRotateSpeed = 1.2;
45
+ controls.addEventListener("start", () => (controls.autoRotate = false));
46
+ const resize = () => {
47
+ const w = el.clientWidth || 300;
48
+ const h = el.clientHeight || 220;
49
+ renderer.setSize(w, h, false);
50
+ renderer.domElement.style.width = "100%";
51
+ renderer.domElement.style.height = "100%";
52
+ camera.aspect = w / h;
53
+ camera.updateProjectionMatrix();
54
+ };
55
+ const ro = new ResizeObserver(resize);
56
+ ro.observe(el);
57
+ resize();
58
+ let frame = 0;
59
+ const tick = () => {
60
+ frame = requestAnimationFrame(tick);
61
+ controls.update();
62
+ renderer.render(scene, camera);
63
+ };
64
+ tick();
65
+ cleanup = () => {
66
+ cancelAnimationFrame(frame);
67
+ ro.disconnect();
68
+ controls.dispose();
69
+ renderer.dispose();
70
+ el.replaceChildren();
71
+ };
72
+ const gltf = await new GLTFLoader().loadAsync(url);
73
+ if (disposed) return cleanup();
74
+ // a mesh without a material of its own (glTF's default white) gets the 3D view's neutral satin, not a glossy white
75
+ gltf.scene.traverse((mesh: { isMesh?: boolean; material?: { map?: unknown; name?: string; color?: { r: number; g: number; b: number } } | unknown[] }) => {
76
+ const m = mesh.material;
77
+ if (mesh.isMesh && m && !Array.isArray(m) && !m.map && m.color && m.color.r > 0.98 && m.color.g > 0.98 && m.color.b > 0.98 && !m.name)
78
+ mesh.material = new THREE.MeshStandardMaterial({ color: 0x8b8b8b, roughness: 0.55, metalness: 0.1 });
79
+ });
80
+ const box = new THREE.Box3().setFromObject(gltf.scene);
81
+ const size = box.getSize(new THREE.Vector3()).length() || 1;
82
+ const center = box.getCenter(new THREE.Vector3());
83
+ gltf.scene.position.sub(center);
84
+ scene.add(gltf.scene);
85
+ camera.near = size / 100;
86
+ camera.far = size * 100;
87
+ camera.position.set(size * 0.9, size * 0.7, size * 0.9);
88
+ camera.updateProjectionMatrix();
89
+ controls.target.set(0, 0, 0);
90
+ setState({ kind: "ready" });
91
+ } catch (e) {
92
+ if (!disposed) setState({ kind: "error", message: (e as Error).message || "WebGL is not available here" });
93
+ }
94
+ })();
95
+ return () => {
96
+ disposed = true;
97
+ cleanup();
98
+ };
99
+ }, [url]);
100
+ return (
101
+ <div className="relative size-full">
102
+ <div ref={host} role="img" aria-label={`${label}: 3D model`} className="size-full" />
103
+ {state.kind !== "ready" && (
104
+ <div className="absolute inset-0 grid place-items-center">
105
+ {state.kind !== "error" ? (
106
+ <Icon name="loader" size={20} className="animate-spin text-fg-muted" aria-label="Loading the model" />
107
+ ) : (
108
+ <EmptyState icon={<Icon name="box" size={24} strokeWidth={1.25} />} title="The model does not show here">
109
+ {state.message}
110
+ </EmptyState>
111
+ )}
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }