@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,335 @@
1
+ /**
2
+ * Small pieces the parts views share: buttons, pills, section labels, the
3
+ * search field, thumbnails, the account avatar. Everything is drawn with the
4
+ * theme's tokens (Tailwind utilities from the viewer's tailwind-theme.css),
5
+ * so the views follow VS Code's theme, high contrast included, or
6
+ * Protoboard's own.
7
+ */
8
+ import { forwardRef, useCallback, useEffect, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
9
+ import { Icon, cx } from "@deltarobotics/uhd-viewer/components";
10
+ import { knownMatte, readMatte, type Matte } from "./matte.js";
11
+
12
+ /** The body face: VS Code's in a webview, a neutral sans elsewhere; Kode Mono (font-ui) stays for values and labels. */
13
+ export const BODY = "font-[family-name:var(--pb-font-body)]";
14
+
15
+ export type Tone = "neutral" | "ok" | "info" | "warning" | "error" | "accent";
16
+
17
+ const TONE_TEXT: Record<Tone, string> = {
18
+ neutral: "text-fg-muted",
19
+ ok: "text-ok",
20
+ info: "text-info",
21
+ warning: "text-warning",
22
+ error: "text-error",
23
+ accent: "text-accent",
24
+ };
25
+
26
+ const TONE_DOT: Record<Tone, string> = { neutral: "bg-fg-muted", ok: "bg-ok", info: "bg-info", warning: "bg-warning", error: "bg-error", accent: "bg-accent" };
27
+
28
+ /** A small outlined pill: `used v2`, `update v3`, `v2 · recommended`; `icon` puts a symbol before the words. */
29
+ export function Pill({ tone = "neutral", children, title, solid, icon }: { tone?: Tone; children: ReactNode; title?: string; solid?: boolean; icon?: string }) {
30
+ return (
31
+ <span
32
+ title={title}
33
+ className={cx(
34
+ "inline-flex h-[18px] shrink-0 items-center gap-1 rounded-full border px-1.5 text-[11px] leading-none whitespace-nowrap",
35
+ solid ? "border-transparent text-on-accent" : "border-current bg-transparent",
36
+ solid && tone === "ok" && "bg-ok",
37
+ solid && tone === "info" && "bg-info",
38
+ solid && tone === "accent" && "bg-accent",
39
+ solid && tone === "warning" && "bg-warning",
40
+ solid && tone === "neutral" && "bg-muted text-fg",
41
+ !solid && TONE_TEXT[tone],
42
+ "outline-1 -outline-offset-1 outline-contrast",
43
+ )}
44
+ >
45
+ {icon && <Icon name={icon} size={11} strokeWidth={2} aria-hidden />}
46
+ {children}
47
+ </span>
48
+ );
49
+ }
50
+
51
+ /** A mono tag: `I2C`, `QFN-32`. */
52
+ export function Tag({ children }: { children: ReactNode }) {
53
+ return <span className="inline-flex h-[18px] shrink-0 items-center rounded-[3px] bg-muted px-1.5 font-ui text-[10.5px] leading-none text-fg-muted outline-1 -outline-offset-1 outline-contrast">{children}</span>;
54
+ }
55
+
56
+ /** Groups the user folded away, by id: kept by the host when it gives `closed` and `onClosed`, else for as long as the view is open. */
57
+ export interface FoldedGroups {
58
+ /** The groups folded away when the view opens (the host's saved state). */
59
+ closed?: readonly string[];
60
+ /** Every change, for the host to keep. */
61
+ onClosed?(ids: string[]): void;
62
+ }
63
+
64
+ /**
65
+ * Which groups are folded away (PB-1039, as in the alpha): one toggles; all
66
+ * fold or open together.
67
+ */
68
+ export function useFolded({ closed: given, onClosed }: FoldedGroups = {}): { closed: ReadonlySet<string>; toggle(id: string): void; setAll(ids: readonly string[]): void } {
69
+ const [closed, setClosed] = useState<ReadonlySet<string>>(() => new Set(given ?? []));
70
+ const set = useCallback(
71
+ (next: ReadonlySet<string>) => {
72
+ setClosed(next);
73
+ onClosed?.([...next]);
74
+ },
75
+ [onClosed],
76
+ );
77
+ const toggle = useCallback((id: string) => set(closed.has(id) ? new Set([...closed].filter((x) => x !== id)) : new Set([...closed, id])), [closed, set]);
78
+ const setAll = useCallback((ids: readonly string[]) => set(new Set(ids)), [set]);
79
+ return { closed, toggle, setAll };
80
+ }
81
+
82
+ /** A group's heading that folds it away and opens it again: a chevron, the title, and what is on its right (a count). */
83
+ export function FoldHeading({ open, onToggle, children, right, className, titleClassName }: { open: boolean; onToggle: () => void; children: ReactNode; right?: ReactNode; className?: string; titleClassName?: string }) {
84
+ return (
85
+ <button
86
+ type="button"
87
+ aria-expanded={open}
88
+ onClick={onToggle}
89
+ className={cx(
90
+ "flex w-full cursor-pointer items-center gap-1.5 rounded-[4px] text-left hover:bg-hover focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus",
91
+ className,
92
+ )}
93
+ >
94
+ <Icon name={open ? "chevron-down" : "chevron-right"} size={14} className="shrink-0 text-fg-muted" aria-hidden />
95
+ <span className={cx("min-w-0 flex-1 truncate font-ui tracking-[0.12em] uppercase", titleClassName ?? "text-[10.5px] text-fg-muted")}>{children}</span>
96
+ {right}
97
+ </button>
98
+ );
99
+ }
100
+
101
+ /** A section heading in the label face: CATEGORIES, ACCOUNT. */
102
+ export function SectionLabel({ icon, children, right }: { icon?: string; children: ReactNode; right?: ReactNode }) {
103
+ return (
104
+ <div className="flex h-6 items-center gap-1.5 text-fg-muted">
105
+ {icon && <Icon name={icon} size={13} strokeWidth={1.75} aria-hidden />}
106
+ <span className="font-ui text-[10.5px] tracking-[0.14em] uppercase">{children}</span>
107
+ {right && <span className="ml-auto">{right}</span>}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ type ButtonKind = "primary" | "secondary" | "ghost";
113
+
114
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
115
+ icon?: string;
116
+ kind?: ButtonKind;
117
+ size?: "sm" | "md";
118
+ }
119
+
120
+ /** A text button (content actions; toolbars use icon buttons). */
121
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({ icon, kind = "secondary", size = "md", className, children, ...rest }, ref) {
122
+ return (
123
+ <button
124
+ ref={ref}
125
+ type="button"
126
+ className={cx(
127
+ BODY,
128
+ "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[4px] border whitespace-nowrap transition-colors",
129
+ size === "sm" ? "h-6 px-2 text-[12px]" : "h-8 px-3 text-[13px]",
130
+ kind === "primary" && "border-transparent bg-accent font-medium text-on-accent hover:bg-accent-hover",
131
+ kind === "secondary" && "border-[var(--pb-control-border)] bg-transparent text-fg hover:bg-hover",
132
+ kind === "ghost" && "border-transparent bg-transparent text-fg-muted hover:bg-hover hover:text-fg",
133
+ "outline-1 -outline-offset-1 outline-contrast hover:outline-dashed hover:outline-contrast-active",
134
+ "focus-visible:outline focus-visible:outline-focus focus-visible:outline-solid",
135
+ "disabled:cursor-default disabled:opacity-45 disabled:hover:bg-transparent",
136
+ kind === "primary" && "disabled:hover:bg-accent",
137
+ className,
138
+ )}
139
+ {...rest}
140
+ >
141
+ {icon && <Icon name={icon} size={size === "sm" ? 13 : 15} strokeWidth={1.75} aria-hidden />}
142
+ {children}
143
+ </button>
144
+ );
145
+ });
146
+
147
+ /** A small square icon action inside a card or row, naming itself in a tooltip. */
148
+ export function RowAction({ icon, label, onClick, tone, disabled }: { icon: string; label: string; onClick: () => void; tone?: Tone; disabled?: boolean }) {
149
+ return (
150
+ <button
151
+ type="button"
152
+ aria-label={label}
153
+ data-tooltip={label}
154
+ disabled={disabled}
155
+ onClick={(e) => {
156
+ e.stopPropagation();
157
+ onClick();
158
+ }}
159
+ className={cx(
160
+ "grid size-6 shrink-0 cursor-pointer place-items-center rounded-[4px] border border-transparent transition-colors",
161
+ tone ? TONE_TEXT[tone] : "text-fg-muted",
162
+ "hover:bg-active hover:text-fg hover:outline hover:outline-1 hover:outline-dashed hover:-outline-offset-1 hover:outline-contrast-active",
163
+ "focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus",
164
+ "disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent",
165
+ )}
166
+ >
167
+ <Icon name={icon} size={15} strokeWidth={1.75} aria-hidden />
168
+ </button>
169
+ );
170
+ }
171
+
172
+ /** The search field: a VS Code input in a webview, the theme's sunken field elsewhere. Debounced. */
173
+ export function SearchField({ value, placeholder, onChange, className, autoFocus }: { value: string; placeholder: string; onChange: (v: string) => void; className?: string; autoFocus?: boolean }) {
174
+ const [text, setText] = useState(value);
175
+ useEffect(() => setText(value), [value]);
176
+ useEffect(() => {
177
+ if (text === value) return;
178
+ const t = setTimeout(() => onChange(text), 250);
179
+ return () => clearTimeout(t);
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [text]);
182
+ return (
183
+ <label
184
+ className={cx(
185
+ BODY,
186
+ "flex h-8 min-w-0 items-center gap-2 rounded-[4px] border border-line bg-sunken px-2.5 text-fg-muted",
187
+ "focus-within:border-focus focus-within:outline focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-focus",
188
+ className,
189
+ )}
190
+ >
191
+ <Icon name="search" size={14} strokeWidth={1.75} className="shrink-0" aria-hidden />
192
+ <input
193
+ value={text}
194
+ placeholder={placeholder}
195
+ autoFocus={autoFocus}
196
+ aria-label={placeholder}
197
+ spellCheck={false}
198
+ onChange={(e) => setText(e.target.value)}
199
+ onKeyDown={(e) => {
200
+ if (e.key === "Enter") onChange(text);
201
+ if (e.key === "Escape" && text) setText("");
202
+ }}
203
+ className="h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] text-fg outline-none placeholder:text-fg-faint"
204
+ />
205
+ {text && (
206
+ <button type="button" aria-label="Clear the search" data-tooltip="Clear" onClick={() => (setText(""), onChange(""))} className="grid size-5 cursor-pointer place-items-center rounded-[3px] text-fg-muted hover:bg-hover hover:text-fg">
207
+ <Icon name="x" size={13} aria-hidden />
208
+ </button>
209
+ )}
210
+ </label>
211
+ );
212
+ }
213
+
214
+ /** A part's picture on its matte (product photos and renders are shot on white), or the part's icon. */
215
+ export function Thumb({ src, alt, className, iconSize = 28, icon = "cpu" }: { src?: string; alt: string; className?: string; iconSize?: number; /** The placeholder when there is no picture. */ icon?: string }) {
216
+ const [failed, setFailed] = useState(false);
217
+ // first ask for pixels the page may read (CORS); a picture that refuses is shown without
218
+ const [cors, setCors] = useState(true);
219
+ const [matte, setMatte] = useState<Matte | undefined>(() => knownMatte(src));
220
+ useEffect(() => {
221
+ setFailed(false);
222
+ setCors(true);
223
+ setMatte(knownMatte(src));
224
+ }, [src]);
225
+ const shown = src && !failed;
226
+ return (
227
+ <div
228
+ data-matte={shown ? (matte ?? "pending") : "none"}
229
+ className={cx(
230
+ "pb-thumb relative grid place-items-center overflow-hidden bg-[var(--pb-thumb-clear)]",
231
+ "data-[matte=photo]:bg-[var(--pb-thumb-tile)] data-[matte=white]:bg-[var(--pb-thumb-tile)] [&[data-matte=white]>img]:[mix-blend-mode:var(--pb-thumb-blend)]",
232
+ className,
233
+ )}
234
+ >
235
+ {shown ? (
236
+ // absolutely placed: a percentage height inside a grid cell does not resolve, so a plain img overflows its box and is cropped
237
+ <img
238
+ key={cors ? "cors" : "plain"}
239
+ src={src}
240
+ alt={alt}
241
+ draggable={false}
242
+ crossOrigin={cors ? "anonymous" : undefined}
243
+ onLoad={(e) => setMatte(readMatte(e.currentTarget) ?? "white")}
244
+ onError={() => (cors ? setCors(false) : setFailed(true))}
245
+ className="absolute inset-0 box-border size-full object-contain p-[5%]"
246
+ />
247
+ ) : (
248
+ <Icon name={icon} size={iconSize} strokeWidth={1.25} className="text-[var(--pb-thumb-fg)]" aria-hidden />
249
+ )}
250
+ </div>
251
+ );
252
+ }
253
+
254
+ /** The signed-in person's initial, in the accent. */
255
+ export function Avatar({ email, size = 28 }: { email?: string; size?: number }) {
256
+ const letter = (email ?? "?").trim().charAt(0).toUpperCase() || "?";
257
+ return (
258
+ <span aria-hidden style={{ width: size, height: size, fontSize: size * 0.46 }} className="grid shrink-0 place-items-center rounded-full bg-accent font-semibold text-on-accent outline-1 -outline-offset-1 outline-contrast">
259
+ {email ? letter : <Icon name="user" size={size * 0.55} />}
260
+ </span>
261
+ );
262
+ }
263
+
264
+ /** A floating card: the surface, a hairline border, the theme's shadow (none in high contrast). */
265
+ export function Card({ children, className, as: As = "div", ...rest }: { children: ReactNode; className?: string; as?: "div" | "section" | "article" } & Record<string, unknown>) {
266
+ return (
267
+ <As className={cx("rounded-[6px] border border-line bg-[var(--pb-card)] outline-1 -outline-offset-1 outline-contrast", className)} {...rest}>
268
+ {children}
269
+ </As>
270
+ );
271
+ }
272
+
273
+ /** A status with its dot: Priced, Unpriced, Needs quote. */
274
+ export function StatusText({ tone, children, plain }: { tone: Tone; children: ReactNode; /** Only the dot takes the tone. */ plain?: boolean }) {
275
+ return (
276
+ <span className={cx("inline-flex items-center gap-1.5 text-[12px] whitespace-nowrap", plain ? "text-fg" : TONE_TEXT[tone])}>
277
+ <span aria-hidden className={cx("size-1.5 shrink-0 rounded-full", plain ? TONE_DOT[tone] : "bg-current")} />
278
+ {children}
279
+ </span>
280
+ );
281
+ }
282
+
283
+ /** A Lucide icon for a category, by its name or path. */
284
+ export function categoryIcon(pathOrName: string): string {
285
+ const s = pathOrName.toLowerCase();
286
+ const table: [RegExp, string][] = [
287
+ [/micro|mcu|controller|processor/, "cpu"],
288
+ [/sensor/, "radar"],
289
+ [/humid/, "droplets"],
290
+ [/environment|temperature/, "thermometer"],
291
+ [/motion|imu|accel|gyro/, "move-3d"],
292
+ [/distance|tof|range|proximity/, "ruler"],
293
+ [/light|optical|led|display/, "sun"],
294
+ [/pressure|baro/, "gauge"],
295
+ [/gnss|gps|position/, "navigation"],
296
+ [/encoder/, "scan"],
297
+ [/actuator|motor|servo/, "cog"],
298
+ [/power|battery|regulator|charger|converter/, "battery-charging"],
299
+ [/connector|cable|harness/, "plug"],
300
+ [/connectivity|wireless|radio|wifi|bluetooth|network/, "wifi"],
301
+ [/expansion|board|shield|breakout|module/, "circuit-board"],
302
+ [/mechanical|structure|frame|bracket|fastener|hardware/, "wrench"],
303
+ [/robot/, "bot"],
304
+ [/passive|resistor|capacitor|inductor|component/, "component"],
305
+ [/gas/, "wind"],
306
+ ];
307
+ // the most specific segment first ("sensor.environmental.gas" is a gas sensor before it is a sensor)
308
+ const segments = s.split(/[./›>]+/).filter(Boolean).reverse();
309
+ for (const segment of [...segments, s]) {
310
+ const hit = table.find(([re]) => re.test(segment));
311
+ if (hit) return hit[1];
312
+ }
313
+ return "box";
314
+ }
315
+
316
+ /** A Lucide icon for a fact, by its label. */
317
+ export function factIcon(label: string): string {
318
+ const s = label.toLowerCase();
319
+ const table: [RegExp, string][] = [
320
+ [/temperature/, "thermometer"],
321
+ [/supply|voltage|vin|vcc|power/, "zap"],
322
+ [/pressure/, "gauge"],
323
+ [/humid/, "droplets"],
324
+ [/gas/, "wind"],
325
+ [/interface|bus|protocol/, "network"],
326
+ [/size|board|dimension/, "ruler"],
327
+ [/mass|weight/, "weight"],
328
+ [/current/, "activity"],
329
+ [/package/, "package"],
330
+ [/pin/, "pin"],
331
+ [/mount/, "anchor"],
332
+ [/version/, "git-commit-horizontal"],
333
+ ];
334
+ return table.find(([re]) => re.test(s))?.[1] ?? "info";
335
+ }