@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,344 @@
1
+ /**
2
+ * What the part carousels share (PB-918):
3
+ *
4
+ * - `PagerStore`: where every card of a list is (its text page and its
5
+ * picture), kept by the list so a card that scrolls away and back, or a
6
+ * part that is opened and closed, comes back where it was; and the "turn
7
+ * all cards together" switch, with which one card's pager moves them all.
8
+ * - `PagesSource`: the cards' pages, fetched from the host when a card first
9
+ * shows a page other than its overview, a few at a time, and kept.
10
+ * - The controls: arrows, the "n/N" pager, the page segments, the picture
11
+ * dots; and swiping (touch, pen, and a trackpad's sideways scroll).
12
+ *
13
+ * Motion is one short fade of the page that came in, and none at all when
14
+ * the reader asked for reduced motion.
15
+ */
16
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent, type ReactNode, type WheelEvent } from "react";
17
+ import { Icon, IconButton, cx } from "@deltarobotics/uhd-viewer/components";
18
+ import { CARD_PAGES, type CardPageId, type PartPages, type PartPagesRef } from "./model.js";
19
+
20
+ // ---------------------------------------------------------------- where the cards are
21
+
22
+ export class PagerStore {
23
+ private readonly pages = new Map<string, CardPageId>();
24
+ private readonly images = new Map<string, number>();
25
+ private linked = false;
26
+ private shared: CardPageId = "overview";
27
+ /** The page a card was last turned to. */
28
+ private last: CardPageId | undefined;
29
+ private readonly listeners = new Set<() => void>();
30
+ private version = 0;
31
+
32
+ subscribe = (fn: () => void) => {
33
+ this.listeners.add(fn);
34
+ return () => void this.listeners.delete(fn);
35
+ };
36
+ /** Changes with every change: what `useSyncExternalStore` compares. */
37
+ snapshot = () => this.version;
38
+ private changed() {
39
+ this.version++;
40
+ for (const fn of this.listeners) fn();
41
+ }
42
+
43
+ get isLinked() {
44
+ return this.linked;
45
+ }
46
+ /** The page every card shows while they turn together. */
47
+ get sharedPage() {
48
+ return this.shared;
49
+ }
50
+ /** The page a card asks for: its own, or everyone's while the cards turn together. */
51
+ page(key: string): CardPageId {
52
+ return this.linked ? this.shared : (this.pages.get(key) ?? "overview");
53
+ }
54
+ setPage(key: string, page: CardPageId) {
55
+ if (this.linked) this.shared = page;
56
+ this.pages.set(key, page);
57
+ this.last = page;
58
+ this.changed();
59
+ }
60
+ /** Turn every card together (they go to `page`: by default the page a card was last turned to, else the specs), or each on its own again. */
61
+ setLinked(on: boolean, page?: CardPageId) {
62
+ this.linked = on;
63
+ if (on) this.shared = page ?? this.last ?? "specs";
64
+ this.changed();
65
+ }
66
+ /** Turn every card to `page` (while they turn together). */
67
+ setShared(page: CardPageId) {
68
+ this.shared = page;
69
+ this.last = page;
70
+ this.changed();
71
+ }
72
+ image(key: string): number {
73
+ return this.images.get(key) ?? 0;
74
+ }
75
+ setImage(key: string, index: number) {
76
+ this.images.set(key, index);
77
+ this.changed();
78
+ }
79
+ }
80
+
81
+ const PagerContext = createContext<PagerStore | null>(null);
82
+
83
+ /** A list's pager store: every card under it remembers its place there. */
84
+ export function PagerProvider({ store, children }: { store: PagerStore; children: ReactNode }) {
85
+ return <PagerContext.Provider value={store}>{children}</PagerContext.Provider>;
86
+ }
87
+
88
+ /** The store of the list a card is in; a card outside any list keeps its own. */
89
+ export function usePagerStore(): PagerStore {
90
+ const given = useContext(PagerContext);
91
+ const [own] = useState(() => new PagerStore());
92
+ const store = given ?? own;
93
+ useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
94
+ return store;
95
+ }
96
+
97
+ /** A list's own store, made once. */
98
+ export function useNewPagerStore(): PagerStore {
99
+ return useState(() => new PagerStore())[0];
100
+ }
101
+
102
+ // ---------------------------------------------------------------- the cards' pages, from the host
103
+
104
+ export type PagesState = { status: "idle" | "loading" } | { status: "ready"; pages: PartPages } | { status: "error"; error: string };
105
+
106
+ /** A host's way to a part's pages. */
107
+ export type PagesLoader = (ref: PartPagesRef) => Promise<PartPages>;
108
+
109
+ /** The pages of a list's cards: asked for once each, four at a time, kept while the page lives. */
110
+ export class PagesSource {
111
+ private readonly states = new Map<string, PagesState>();
112
+ private readonly queue: PartPagesRef[] = [];
113
+ private running = 0;
114
+ private readonly listeners = new Set<() => void>();
115
+ private version = 0;
116
+
117
+ constructor(
118
+ private readonly loader: PagesLoader,
119
+ private readonly concurrency = 4,
120
+ ) {}
121
+
122
+ static id = (ref: PartPagesRef) => `${ref.key}@${ref.revision ?? ""}`;
123
+
124
+ subscribe = (fn: () => void) => {
125
+ this.listeners.add(fn);
126
+ return () => void this.listeners.delete(fn);
127
+ };
128
+ snapshot = () => this.version;
129
+ private set(ref: PartPagesRef, state: PagesState) {
130
+ this.states.set(PagesSource.id(ref), state);
131
+ this.version++;
132
+ for (const fn of this.listeners) fn();
133
+ }
134
+
135
+ state(ref: PartPagesRef | undefined): PagesState {
136
+ return (ref && this.states.get(PagesSource.id(ref))) || { status: "idle" };
137
+ }
138
+
139
+ /** Ask for a part's pages (again after an error); nothing happens while they are loading or known. */
140
+ want(ref: PartPagesRef, retry = false) {
141
+ const s = this.state(ref);
142
+ if (s.status === "loading" || s.status === "ready" || (s.status === "error" && !retry)) return;
143
+ this.set(ref, { status: "loading" });
144
+ this.queue.push(ref);
145
+ this.pump();
146
+ }
147
+
148
+ private pump() {
149
+ while (this.running < this.concurrency && this.queue.length) {
150
+ const ref = this.queue.shift()!;
151
+ this.running++;
152
+ this.loader(ref)
153
+ .then(
154
+ (pages) => this.set(ref, { status: "ready", pages }),
155
+ (e: unknown) => this.set(ref, { status: "error", error: e instanceof Error ? e.message : String(e) }),
156
+ )
157
+ .finally(() => {
158
+ this.running--;
159
+ this.pump();
160
+ });
161
+ }
162
+ }
163
+ }
164
+
165
+ const PagesContext = createContext<PagesSource | null>(null);
166
+
167
+ /** The cards under it can show their pages: `loader` is the host's (absent: the cards keep to their overview). */
168
+ export function PagesProvider({ loader, children }: { loader: PagesLoader | undefined; children: ReactNode }) {
169
+ const ref = useRef(loader);
170
+ ref.current = loader;
171
+ const has = !!loader;
172
+ // one source for the life of the list: a host that hands a new function each render does not empty it
173
+ const source = useMemo(() => (has ? new PagesSource((r) => ref.current!(r)) : null), [has]);
174
+ return <PagesContext.Provider value={source}>{children}</PagesContext.Provider>;
175
+ }
176
+
177
+ /** A card's pages: fetched once `wanted` is true. `available` is false when no host offers pages here. */
178
+ export function usePartPages(ref: PartPagesRef | undefined, wanted: boolean): { state: PagesState; available: boolean; retry(): void } {
179
+ const source = useContext(PagesContext);
180
+ const subscribe = useCallback((fn: () => void) => (source ? source.subscribe(fn) : () => undefined), [source]);
181
+ useSyncExternalStore(subscribe, () => source?.snapshot() ?? 0, () => 0);
182
+ const key = ref ? PagesSource.id(ref) : "";
183
+ useEffect(() => {
184
+ if (source && ref && wanted) source.want(ref);
185
+ // eslint-disable-next-line react-hooks/exhaustive-deps
186
+ }, [source, key, wanted]);
187
+ return {
188
+ state: source ? source.state(ref) : { status: "idle" },
189
+ available: !!source && !!ref,
190
+ retry: () => ref && source?.want(ref, true),
191
+ };
192
+ }
193
+
194
+ // ---------------------------------------------------------------- stepping
195
+
196
+ /** The neighbour of `current` in `list`, going round at the ends. */
197
+ export function stepIn<T>(list: readonly T[], current: T, delta: number): T {
198
+ if (!list.length) return current;
199
+ const i = list.indexOf(current);
200
+ return list[(((i < 0 ? 0 : i) + delta) % list.length + list.length) % list.length];
201
+ }
202
+
203
+ /**
204
+ * Swiping sideways turns a carousel: a touch or pen drag past 36 px that is
205
+ * more sideways than up or down, and a trackpad's two-finger sideways scroll
206
+ * (one turn per gesture). A mouse drag does nothing, so text stays selectable.
207
+ */
208
+ export function useSwipe(onStep: (delta: number) => void) {
209
+ const start = useRef<{ x: number; y: number; id: number } | null>(null);
210
+ const wheel = useRef({ sum: 0, at: 0, fired: 0 });
211
+ return {
212
+ onPointerDown: (e: PointerEvent) => {
213
+ if (e.pointerType === "mouse") return;
214
+ start.current = { x: e.clientX, y: e.clientY, id: e.pointerId };
215
+ },
216
+ onPointerUp: (e: PointerEvent) => {
217
+ const s = start.current;
218
+ start.current = null;
219
+ if (!s || s.id !== e.pointerId) return;
220
+ const dx = e.clientX - s.x;
221
+ const dy = e.clientY - s.y;
222
+ if (Math.abs(dx) < 36 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
223
+ // the click that follows a swipe must not open the part
224
+ e.currentTarget.addEventListener("click", (c) => (c.stopPropagation(), c.preventDefault()), { capture: true, once: true });
225
+ onStep(dx < 0 ? 1 : -1);
226
+ },
227
+ onPointerCancel: () => {
228
+ start.current = null;
229
+ },
230
+ onWheel: (e: WheelEvent) => {
231
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) * 1.5 || e.ctrlKey) return;
232
+ const w = wheel.current;
233
+ const now = e.timeStamp;
234
+ if (now - w.at > 180) w.sum = 0;
235
+ w.at = now;
236
+ w.sum += e.deltaX;
237
+ if (Math.abs(w.sum) > 60 && now - w.fired > 420) {
238
+ w.fired = now;
239
+ onStep(w.sum > 0 ? 1 : -1);
240
+ w.sum = 0;
241
+ }
242
+ },
243
+ };
244
+ }
245
+
246
+ // ---------------------------------------------------------------- controls
247
+
248
+ /** The stylesheet of the carousels: the page's fade (none with reduced motion), and controls that show on hover or focus (always where there is no hover). */
249
+ export const CAROUSEL_CSS = `
250
+ @keyframes pb-page-in { from { opacity: 0; transform: translateX(var(--pb-page-from, 6px)); } to { opacity: 1; transform: none; } }
251
+ .pb-page-in { animation: pb-page-in 140ms ease-out; }
252
+ .pb-reveal { opacity: 0; transition: opacity 120ms ease-out; }
253
+ .pb-reveal-host:hover .pb-reveal, .pb-reveal-host:focus-within .pb-reveal, .pb-reveal-host:focus-visible .pb-reveal { opacity: 1; }
254
+ @media (hover: none) { .pb-reveal { opacity: 1; } }
255
+ @media (prefers-reduced-motion: reduce) { .pb-page-in { animation: none; } .pb-reveal { transition: none; } }
256
+ `;
257
+
258
+ export function CarouselStyles() {
259
+ return <style data-pb="carousel">{CAROUSEL_CSS}</style>;
260
+ }
261
+
262
+ /** A small arrow of a carousel: named, with a tooltip, and never opening the card it is on. */
263
+ export function StepButton({ dir, label, onClick, size = 16, className, tabIndex, disabled }: { dir: "prev" | "next"; label: string; onClick: () => void; size?: number; className?: string; tabIndex?: number; disabled?: boolean }) {
264
+ return (
265
+ <button
266
+ type="button"
267
+ aria-label={label}
268
+ data-tooltip={label}
269
+ tabIndex={tabIndex}
270
+ disabled={disabled}
271
+ onClick={(e) => {
272
+ e.stopPropagation();
273
+ onClick();
274
+ }}
275
+ onKeyDown={(e) => e.stopPropagation()}
276
+ className={cx(
277
+ "grid shrink-0 cursor-pointer place-items-center rounded-[3px] text-fg-muted hover:bg-active hover:text-fg",
278
+ "focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus disabled:cursor-default disabled:opacity-40",
279
+ className,
280
+ )}
281
+ style={{ width: size, height: size }}
282
+ >
283
+ <Icon name={dir === "prev" ? "chevron-left" : "chevron-right"} size={Math.round(size * 0.8)} strokeWidth={2} aria-hidden />
284
+ </button>
285
+ );
286
+ }
287
+
288
+ /** "‹ 2/6 ›": the text carousel's pager. */
289
+ export function Pager({ index, count, what, onStep, tabIndex, size = 16 }: { index: number; count: number; /** "page" or "section", for the arrows' names. */ what: string; onStep: (delta: number) => void; tabIndex?: number; size?: number }) {
290
+ return (
291
+ <span className="flex shrink-0 items-center gap-0.5">
292
+ <StepButton dir="prev" label={`Previous ${what}`} onClick={() => onStep(-1)} tabIndex={tabIndex} size={size} />
293
+ <span aria-hidden className="min-w-[24px] text-center font-ui text-[10.5px] leading-none text-fg-muted tabular-nums">
294
+ {index + 1}/{count}
295
+ </span>
296
+ <StepButton dir="next" label={`Next ${what}`} onClick={() => onStep(1)} tabIndex={tabIndex} size={size} />
297
+ </span>
298
+ );
299
+ }
300
+
301
+ /** Where the carousel is among its pages: one segment each, the current one in the accent. Decoration: the pager says the same in words. */
302
+ export function Segments({ index, count, className }: { index: number; count: number; className?: string }) {
303
+ if (count < 2) return null;
304
+ return (
305
+ <span aria-hidden className={cx("flex shrink-0 items-center gap-[3px]", className)}>
306
+ {Array.from({ length: count }, (_, i) => (
307
+ <span key={i} className={cx("h-[2px] rounded-full outline-1 outline-contrast", i === index ? "w-4 bg-accent outline-contrast-active" : "w-2 bg-line-strong")} />
308
+ ))}
309
+ </span>
310
+ );
311
+ }
312
+
313
+ const PAGE_NAME: Record<CardPageId, string> = { overview: "Overview", specs: "Specs", dimensions: "Dimensions", interfaces: "Interfaces", cad: "CAD", artifacts: "Artifacts" };
314
+
315
+ /**
316
+ * The list's "turn all cards together" switch (design 07's first open
317
+ * question): on, one pager turns every card to the same page, so one page
318
+ * (the specs, say) reads across the parts; beside it the page they are on,
319
+ * with its own arrows. A part without that page keeps to its overview.
320
+ */
321
+ export function LinkPagesToggle({ store, compact }: { store: PagerStore; compact?: boolean }) {
322
+ useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
323
+ const on = store.isLinked;
324
+ return (
325
+ <span className="flex shrink-0 items-center gap-1">
326
+ <IconButton
327
+ icon={on ? "link-2" : "unlink-2"}
328
+ size={compact ? "sm" : undefined}
329
+ label={on ? "The cards turn together: turn each card on its own" : "Turn all cards together, to compare one page across the parts"}
330
+ pressed={on}
331
+ onClick={() => store.setLinked(!on)}
332
+ />
333
+ {on && (
334
+ <span role="group" aria-label="The page every card shows" className="flex items-center gap-0.5">
335
+ <StepButton dir="prev" label="All cards: previous page" onClick={() => store.setShared(stepIn(CARD_PAGES, store.sharedPage, -1))} size={compact ? 18 : 22} />
336
+ <span aria-live="polite" className={cx("text-center font-ui text-[10.5px] tracking-[0.06em] text-fg uppercase", compact ? "min-w-[62px]" : "min-w-[78px]")}>
337
+ {PAGE_NAME[store.sharedPage]}
338
+ </span>
339
+ <StepButton dir="next" label="All cards: next page" onClick={() => store.setShared(stepIn(CARD_PAGES, store.sharedPage, 1))} size={compact ? 18 : 22} />
340
+ </span>
341
+ )}
342
+ </span>
343
+ );
344
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The parts views (PB-902, PB-903, PB-904, PB-912): the library browser and
3
+ * page, a part's detail, the project's parts, the libraries manager, the
4
+ * bill of materials and the Cloud panel. Data in (model.ts), actions out;
5
+ * the shells place them (VS Code: native views and editor tabs; the web
6
+ * shell: the `library` and `bom` tabs).
7
+ */
8
+ export type * from "./model.js";
9
+ export { PartsSurface, type Place } from "./Surface.js";
10
+ export { CARD_PAGES } from "./model.js";
11
+ export { CARD_HEIGHT, PartCard, UsePill, type PartCardProps } from "./PartCard.js";
12
+ export { CardPage, ImageCarousel, OutlineDrawing, type ImageCarouselProps } from "./CardPages.js";
13
+ export { LinkPagesToggle, PagerProvider, PagerStore, PagesProvider, PagesSource, useNewPagerStore, usePagerStore, type PagesLoader, type PagesState } from "./carousel.js";
14
+ export { PricesPanel, money, usdFirst, usePrices, type PricesState } from "./Prices.js";
15
+ export { Windowed } from "./Windowed.js";
16
+ export { PAGE_ICON, PAGE_LABEL, availablePages } from "./part-pages.js";
17
+ export { LibraryBrowser, Crumbs, SortMenu, type LibraryBrowserProps } from "./LibraryBrowser.js";
18
+ export { CategoryList, type CategoryListProps } from "./CategoryList.js";
19
+ export { PartStatusDot, PartStatusLegend, PartStatusStyles, type PartStatusDotProps } from "./PartStatus.js";
20
+ export { PART_STATUS_COLORS, PART_STATUS_CSS, PART_STATUS_TONE, PART_TRUST_STATES, PART_TRUST_TEXT, nodeMarks, partStatusVar, partTrustTooltip, type PartStatusScheme } from "./part-status.js";
21
+ export { rev, revChange } from "./revision.js";
22
+ export { ProjectPartsPage, SyncPill, type ProjectPartsPageProps } from "./ProjectPartsPage.js";
23
+ export { LibraryPage, type LibraryPageProps } from "./LibraryPage.js";
24
+ export { PartDetail, DETAIL_TABS, detailTabs, type PartDetailProps } from "./PartDetail.js";
25
+ export { ProjectParts, partGroups, partNote, partSync, tierGroups, type PartGroup, type PartSync } from "./ProjectParts.js";
26
+ export { LibrariesManager, AccountCard } from "./LibrariesManager.js";
27
+ export { SettingsPage } from "./SettingsPage.js";
28
+ export { BomEditor, formatMoney, offersAge } from "./BomEditor.js";
29
+ export { CloudPanel } from "./CloudPanel.js";
30
+ export { GlbPreview } from "./GlbPreview.js";
31
+ export { Avatar, Button, Card, FoldHeading, Pill, RowAction, SearchField, SectionLabel, StatusText, Tag, Thumb, categoryIcon, factIcon, useFolded, type FoldedGroups } from "./ui.js";
32
+ export { BomView, LibraryView, PartsDataProvider, usePartsData, useLibrary, type LibraryPageResult, type PartsData } from "./views.js";