@protoboardai/web-viewer 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.
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@protoboardai/web-viewer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Protoboard's shells over the viewer stack: the browser shell (`protoboard dev`: the Protoboard theme, view tabs from the view registry, its own inspector, hierarchy and DRC) and the VS Code shell (one viewer editor tab: VS Code's theme, no inspector or DRC of its own)",
5
+ "license": "Apache-2.0",
6
+ "author": "Delta Robotics, Inc.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Delta-Robotics-Inc/protoboard-stack.git",
10
+ "directory": "packages/web-viewer"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./dist/*": "./dist/*",
16
+ "./package.json": "./package.json",
17
+ "./vscode": "./src/vscode/index.ts",
18
+ "./vscode/protocol": "./src/vscode/protocol.ts"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE",
25
+ "NOTICE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "vite build && vite build -c vite.vscode.config.ts",
35
+ "prepack": "npm run build",
36
+ "build:vscode": "vite build -c vite.vscode.config.ts"
37
+ },
38
+ "dependencies": {
39
+ "@deltarobotics/uhd-viewer": "^0.2.0",
40
+ "@protoboardai/views": "^0.2.0",
41
+ "@radix-ui/react-scroll-area": "^1.2.18",
42
+ "@xyflow/react": "^12.12.0",
43
+ "lucide-react": "^1.48.0",
44
+ "react": "^19.2.8",
45
+ "react-dom": "^19.2.8",
46
+ "three": "^0.180.0"
47
+ },
48
+ "devDependencies": {
49
+ "@tailwindcss/vite": "^4.3.3",
50
+ "@types/react-dom": "^19.2.3",
51
+ "tailwindcss": "^4.3.3",
52
+ "vite": "^8.3.1"
53
+ }
54
+ }
package/src/Logo.tsx ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The Protoboard mark (the "0" of PR0TOBOARD, PB-916) and wordmark. Its
3
+ * colour is the theme's accent: orange in the Protoboard theme, the host's
4
+ * accent anywhere else (PB-900: the brand colour is never hard-coded, not
5
+ * even here).
6
+ */
7
+ import { MARK_PATH } from "./logo-mark.js";
8
+
9
+ export function Logo({ compact = false }: { compact?: boolean }) {
10
+ return (
11
+ <span className="flex h-8 shrink-0 items-center gap-2 px-1.5 select-none" aria-label="Protoboard">
12
+ <svg aria-hidden width="18" height="18" viewBox="0 0 24 24" className="text-accent">
13
+ <path fill="currentColor" d={MARK_PATH} />
14
+ </svg>
15
+ {!compact && <span className="font-label text-[13px] leading-none tracking-[0.08em] text-fg max-[900px]:hidden">PROTOBOARD</span>}
16
+ </span>
17
+ );
18
+ }
19
+
20
+ /** The mark as a standalone SVG in one colour (the page's favicon, in the theme's accent). */
21
+ export function markSvg(color: string): string {
22
+ const c = color.replace(/[^#a-zA-Z0-9(),.% ]/g, "");
23
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="${c}" d="${MARK_PATH}"/></svg>`;
24
+ }
package/src/Shell.tsx ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The web shell: Protoboard's browser viewer over the viewer stack. A floating
3
+ * header with the logo, the level's breadcrumb, the view tabs the registry
4
+ * offers for the focused module (the drilled level), the active view's tools
5
+ * and the panel toggles; the hierarchy at the left (PB-1042: the module tree,
6
+ * synced with the canvas, as in a VS Code viewer tab); the view; and its own
7
+ * inspector and DRC (the viewer's panels). The VS Code shell keeps those in
8
+ * native views instead (docs/specs/viewer-stack.md).
9
+ */
10
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
11
+ import {
12
+ Icon,
13
+ IconButton,
14
+ ThemeProvider,
15
+ ToolbarSeparator,
16
+ HierarchyToggle,
17
+ UhdViewer,
18
+ ViewerHierarchy,
19
+ ViewerInspector,
20
+ ViewerNodeMenu,
21
+ applyTheme,
22
+ cx,
23
+ tokenColor,
24
+ useTheme,
25
+ useViewer,
26
+ type HierarchyCommand,
27
+ type HierarchyPanelState,
28
+ type UhdViewerOptions,
29
+ } from "@deltarobotics/uhd-viewer/components";
30
+ import type { ResolvedView } from "@protoboardai/core";
31
+ import { PartStatusStyles, PartsDataProvider, PlannedView, ViewIcon, ViewRegistry, createViewRegistry, nodeMarks, registerContributedViews, themeAdapterFor, type HostAdapter, type PartTrust, type PartsData, type ViewDefinition } from "@protoboardai/views";
32
+ import { Logo, markSvg } from "./Logo.js";
33
+
34
+ export interface WebViewerOptions extends UhdViewerOptions {
35
+ /** `protoboard-dark` (default), `protoboard-light`, `protoboard-system`, `vscode`, `default-dark`, `default-light`. */
36
+ themeChoice?: string;
37
+ /** Views the project's extensions declare (core's resolveViews), added to the built-in ones. */
38
+ views?: ResolvedView[];
39
+ /** A registry of the host's own (default: the built-in views plus `views`). */
40
+ registry?: ViewRegistry;
41
+ /** The viewer's requests for the pair view (`mountWebViewer` wires them; `open` events with `{ linkId, a, b }`). */
42
+ pairRequests?: EventTarget;
43
+ /** The library and BOM tabs' data (`createDevPartsData` for `protoboard dev`); without it they say it is missing. */
44
+ partsData?: PartsData;
45
+ /**
46
+ * Every definition's part status by id (PB-932; the dev server's state):
47
+ * the dot on a module in the diagram takes its colour (gray local, yellow
48
+ * unverified, green verified, blue official) and says what it means.
49
+ */
50
+ partStatus?: Record<string, { trust: PartTrust; basis?: string; blank?: boolean }>;
51
+ }
52
+
53
+ const THEME_KEY = "protoboard-web:theme";
54
+ /** The hierarchy panel's open rows, width and fold: per browser tab (session storage), as a VS Code tab keeps them per tab. */
55
+ const HIERARCHY_KEY = "protoboard-web:hierarchy";
56
+
57
+ function keptHierarchy(): unknown {
58
+ try {
59
+ return JSON.parse(sessionStorage.getItem(HIERARCHY_KEY) ?? "null") ?? undefined;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ function keepHierarchy(state: HierarchyPanelState) {
66
+ try {
67
+ sessionStorage.setItem(HIERARCHY_KEY, JSON.stringify(state));
68
+ } catch {
69
+ /* storage unavailable: the panel's state lasts this visit */
70
+ }
71
+ }
72
+
73
+ /** Views with no module tree to follow: the library and the BOM are the project's, not a level's. */
74
+ const NO_HIERARCHY = new Set(["library", "bom"]);
75
+
76
+ function storedTheme(): string | null {
77
+ try {
78
+ return localStorage.getItem(THEME_KEY);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** The theme: `themeChoice` when the page names one, else the viewer's last pick, else Protoboard dark. */
85
+ function useThemeChoice(given?: string): [string, (next: string) => void] {
86
+ const [picked, setPicked] = useState<string | null>(() => storedTheme());
87
+ const choice = given ?? picked ?? "protoboard-dark";
88
+ const set = (next: string) => {
89
+ setPicked(next);
90
+ try {
91
+ localStorage.setItem(THEME_KEY, next);
92
+ } catch {
93
+ /* storage unavailable: the choice lasts this visit */
94
+ }
95
+ };
96
+ return [choice, set];
97
+ }
98
+
99
+ export function WebShell(props: WebViewerOptions) {
100
+ const [choice, setChoice] = useThemeChoice(props.themeChoice);
101
+ const adapter = useMemo(() => themeAdapterFor(choice), [choice]);
102
+ // the viewer knows marks, not parts: the status becomes a tone and a tooltip here
103
+ const marks = useMemo(() => nodeMarks(props.partStatus), [props.partStatus]);
104
+ return (
105
+ <ThemeProvider adapter={adapter} className="pb-web">
106
+ <DocumentTheme />
107
+ <PartStatusStyles />
108
+ <PartsDataProvider data={props.partsData ?? null}>
109
+ <UhdViewer {...props} nodeMarks={props.nodeMarks ?? marks}>
110
+ <Shell views={props.views} registry={props.registry} pairRequests={props.pairRequests} theme={choice} onTheme={props.themeChoice ? undefined : setChoice} />
111
+ </UhdViewer>
112
+ </PartsDataProvider>
113
+ </ThemeProvider>
114
+ );
115
+ }
116
+
117
+ /**
118
+ * The shell owns its page: the theme's tokens go on the document too (so the
119
+ * page behind the viewer, overscroll included, has the theme's background,
120
+ * not the stylesheet's dark default), and the tab's icon is the mark in the
121
+ * theme's accent (the page's own icon is neutral: no hard-coded brand colour).
122
+ */
123
+ function DocumentTheme() {
124
+ const { theme, root } = useTheme();
125
+ useEffect(() => {
126
+ if (typeof document === "undefined") return;
127
+ const html = document.documentElement;
128
+ const undo = applyTheme(html, theme);
129
+ html.style.background = "var(--uhd-background)";
130
+ return () => {
131
+ undo();
132
+ html.style.background = "";
133
+ };
134
+ }, [theme]);
135
+ useEffect(() => {
136
+ if (typeof document === "undefined" || !root) return;
137
+ const link = document.querySelector<HTMLLinkElement>('link[rel~="icon"]');
138
+ if (!link) return;
139
+ link.href = `data:image/svg+xml,${encodeURIComponent(markSvg(tokenColor(root, "accent", "#888888")))}`;
140
+ }, [theme, root]);
141
+ return null;
142
+ }
143
+
144
+ function useRegistry(views: ResolvedView[] | undefined, given?: ViewRegistry): ViewRegistry {
145
+ const registry = useMemo(() => {
146
+ if (given) return given;
147
+ const r = createViewRegistry();
148
+ if (views?.length) registerContributedViews(r, views);
149
+ return r;
150
+ }, [views, given]);
151
+ // re-render when views register later (an extension's component arriving)
152
+ useSyncExternalStore(
153
+ (l) => registry.subscribe(l),
154
+ () => registry.list().length + registry.list().filter((v) => v.status === "ready").length * 1000,
155
+ );
156
+ return registry;
157
+ }
158
+
159
+ /** Space the floating header covers at the top of the view (the viewer's floating cards sit below it). */
160
+ const INSETS = { "--uhd-inset-top": "44px", "--uhd-inset-left": "0px" } as CSSProperties;
161
+
162
+ function Shell({ views, registry: given, pairRequests, theme, onTheme }: { views?: ResolvedView[]; registry?: ViewRegistry; pairRequests?: EventTarget; theme: string; onTheme?: (t: string) => void }) {
163
+ const c = useViewer();
164
+ const registry = useRegistry(views, given);
165
+ const path = c.drill.join("/");
166
+ const tabs = registry.forModule(c.levelDef, c.source.bundle, path);
167
+ // a domain tab (schematic, BOM, …) when one is open; the viewer's own view (diagram, 3D) otherwise
168
+ const [domain, setDomain] = useState<string | null>(null);
169
+ // what a domain tab was opened with (the pair view's pair, a board)
170
+ const [params, setParams] = useState<Record<string, string> | undefined>(undefined);
171
+ // a host's setView (or anything else that changes the viewer's view) shows that view
172
+ useEffect(() => setDomain(null), [c.view]);
173
+ const wanted = domain ?? c.view;
174
+ const active = tabs.find((t) => t.id === wanted) ?? tabs[0];
175
+ const open = (v: ViewDefinition, with_?: Record<string, string>) => {
176
+ setParams(with_);
177
+ if (v.uhd && (v.id === "diagram" || v.id === "3d")) {
178
+ setDomain(null);
179
+ c.setView(v.id);
180
+ } else setDomain(v.id);
181
+ };
182
+ // the inspector's pair preview opens the pair tab on that pair
183
+ useEffect(() => {
184
+ if (!pairRequests) return;
185
+ const onOpen = (e: Event) => {
186
+ const r = (e as CustomEvent<{ linkId: string; a: string; b: string }>).detail;
187
+ const pair = registry.get("pair");
188
+ if (pair) open(pair, { link: r.linkId, a: r.a, b: r.b });
189
+ };
190
+ pairRequests.addEventListener("open", onOpen);
191
+ return () => pairRequests.removeEventListener("open", onOpen);
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
+ }, [pairRequests, registry]);
194
+ const host = useMemo<HostAdapter>(
195
+ () => ({
196
+ kind: "web",
197
+ openSource: c.hostActions.openSource ? (r) => c.hostActions.openSource!(r.defId, r.instancePath) : undefined,
198
+ openArtifact: c.hostActions.openArtifact ? (r) => c.hostActions.openArtifact!(r.defId, r.artifactId) : undefined,
199
+ openView: (id, at, with_) => {
200
+ const v = registry.get(id);
201
+ if (!v) return;
202
+ open(v, with_);
203
+ // the diagram of another level: go there, and select what was asked for
204
+ if (v.id === "diagram") {
205
+ if (at !== c.drill.join("/")) c.onGoto({ drill: at ? at.split("/") : [], selection: with_?.select ? { kind: "node", id: with_.select } : null });
206
+ else if (with_?.select) c.setSelection({ kind: "node", id: with_.select });
207
+ }
208
+ },
209
+ artifactUrl: (defId, filePath) => c.source.resolveArtifact(defId, filePath),
210
+ }),
211
+ // eslint-disable-next-line react-hooks/exhaustive-deps
212
+ [c.hostActions, registry, c.source, c.drill],
213
+ );
214
+ const View = active?.component ?? PlannedView;
215
+ const diagram = active?.id === "diagram";
216
+ const light = theme.endsWith("light");
217
+ // the hierarchy at the left: kept per browser tab; the header's button opens and folds it
218
+ const [hierarchyState] = useState(keptHierarchy);
219
+ const [hierarchyOpen, setHierarchyOpen] = useState<boolean | null>(null);
220
+ const [hierarchyCommand, setHierarchyCommand] = useState<HierarchyCommand>();
221
+ const seq = useRef(0);
222
+ const showHierarchy = !NO_HIERARCHY.has(active?.id ?? "");
223
+
224
+ return (
225
+ <div className="relative h-full overflow-hidden bg-background text-fg" style={INSETS}>
226
+ <header className="absolute top-1 right-1 left-1 z-50 flex h-10 items-center gap-1 rounded-panel border border-line bg-surface px-1 shadow-panel outline outline-1 -outline-offset-1 outline-contrast">
227
+ <Logo />
228
+ <ToolbarSeparator />
229
+ <HierarchyToggle open={showHierarchy && (hierarchyOpen ?? true)} disabled={!showHierarchy} onClick={() => setHierarchyCommand({ type: "toggle", seq: ++seq.current })} />
230
+ <ToolbarSeparator />
231
+ <nav role="tablist" aria-label="Views" className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none]">
232
+ {tabs.map((t) => (
233
+ <Tab key={t.id} view={t} active={t.id === active?.id} onClick={() => open(t)} />
234
+ ))}
235
+ </nav>
236
+ <ToolbarSeparator />
237
+ <Breadcrumb />
238
+ <span className="min-w-2 flex-1" />
239
+ <ToolbarSeparator />
240
+ <IconButton icon="panel-right" label="Inspector" pressed={c.panel === "hierarchy"} disabled={!active?.uhd} onClick={() => c.setPanel(c.panel === "hierarchy" ? null : "hierarchy")} />
241
+ <IconButton
242
+ icon="shield-check"
243
+ label={`Design rule checks${c.errors ? ` · ${c.errors} error${c.errors === 1 ? "" : "s"}` : ""}`}
244
+ pressed={c.panel === "checks"}
245
+ badge={c.errors || undefined}
246
+ disabled={!active?.uhd}
247
+ onClick={() => {
248
+ c.setSelection(null);
249
+ c.setPanel(c.panel === "checks" ? null : "checks");
250
+ }}
251
+ />
252
+ {onTheme && <IconButton icon={light ? "moon" : "sun"} label={light ? "Dark theme" : "Light theme"} onClick={() => onTheme(light ? "protoboard-dark" : "protoboard-light")} />}
253
+ </header>
254
+ {showHierarchy && <ViewerHierarchy state={hierarchyState} command={hierarchyCommand} onStateChange={keepHierarchy} onOpenChange={setHierarchyOpen} />}
255
+ <div className="workspace" role="tabpanel" aria-label={active?.title}>
256
+ {active && <View key={active.id} module={c.levelDef} path={path} bundle={c.source.bundle} host={host} view={active} params={active.uhd ? undefined : params} />}
257
+ {diagram && <ViewerInspector />}
258
+ </div>
259
+ <ViewerNodeMenu />
260
+ </div>
261
+ );
262
+ }
263
+
264
+ function Tab({ view, active, onClick }: { view: ViewDefinition; active: boolean; onClick: () => void }) {
265
+ const planned = view.status === "planned";
266
+ return (
267
+ <button
268
+ type="button"
269
+ role="tab"
270
+ aria-selected={active}
271
+ data-tooltip={`${view.title}${planned ? " · planned" : ""}${view.description ? `: ${view.description}` : ""}`}
272
+ onClick={onClick}
273
+ className={cx(
274
+ "relative flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-transparent px-2 font-ui text-[12px] tracking-[-0.01em] transition-colors",
275
+ active ? "bg-accent-tint text-fg outline outline-1 -outline-offset-1 outline-contrast-active" : "text-fg-muted hover:bg-hover hover:text-fg",
276
+ planned && !active && "text-fg-faint",
277
+ "focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus",
278
+ )}
279
+ >
280
+ <ViewIcon name={view.icon} size={15} strokeWidth={1.75} className={active ? "text-accent" : undefined} aria-hidden />
281
+ <span className="max-[1180px]:hidden">{view.title}</span>
282
+ {active && <span aria-hidden className="absolute right-2 bottom-0 left-2 h-0.5 rounded-full bg-accent" />}
283
+ </button>
284
+ );
285
+ }
286
+
287
+ function Breadcrumb() {
288
+ const c = useViewer();
289
+ const path = c.vm.path;
290
+ return (
291
+ <nav aria-label="Level" className="flex min-w-0 shrink items-center gap-0.5 overflow-hidden">
292
+ {path.map((p, i) => {
293
+ const current = i === path.length - 1;
294
+ return (
295
+ <span key={p.id || "root"} className="flex min-w-0 items-center gap-0.5">
296
+ {i > 0 && <Icon name="chevron-right" size={12} className="shrink-0 text-fg-faint" aria-hidden />}
297
+ <button
298
+ type="button"
299
+ aria-current={current ? "location" : undefined}
300
+ onClick={() => c.onNavigate(i)}
301
+ className={cx(
302
+ "h-8 max-w-56 cursor-pointer truncate rounded-md px-2 font-ui text-[12px]",
303
+ current ? "text-fg" : "text-fg-muted hover:bg-hover hover:text-fg",
304
+ )}
305
+ >
306
+ {p.label}
307
+ </button>
308
+ </span>
309
+ );
310
+ })}
311
+ </nav>
312
+ );
313
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @protoboardai/web-viewer: Protoboard's browser shell (docs/specs/viewer-stack.md).
3
+ *
4
+ * import { mountWebViewer } from "@protoboardai/web-viewer"; // or dist/web-viewer.js
5
+ * const viewer = mountWebViewer(el, { bundleUrl, themeChoice: "protoboard-dark", ...hostCallbacks });
6
+ *
7
+ * The handle is the viewer's (`focus`, `select`, `drill`, `setView`, `post`,
8
+ * `update`): hosts drive it with the same messages as the plain viewer.
9
+ */
10
+ import "@deltarobotics/uhd-viewer/components.css";
11
+ import "./styles.css";
12
+ import { renderViewer, type ViewerHandle } from "@deltarobotics/uhd-viewer/components";
13
+ import { WebShell, type WebViewerOptions } from "./Shell.js";
14
+
15
+ export { WebShell, type WebViewerOptions };
16
+ export { createDevPartsData, type DevPartsOptions } from "./partsData.js";
17
+ export type { ViewerHandle };
18
+
19
+ export function mountWebViewer(element: HTMLElement, options: WebViewerOptions): ViewerHandle {
20
+ if (!options.bundle && !options.bundleUrl) throw new Error("mountWebViewer: give a bundle or a bundleUrl");
21
+ // the pair view is a tab of the shell's own: the viewer hands it over (and tells a host too)
22
+ const pairRequests = new EventTarget();
23
+ const hostPair = options.onOpenPair;
24
+ return renderViewer(
25
+ element,
26
+ {
27
+ ...options,
28
+ pairRequests,
29
+ onOpenPair: (request) => {
30
+ pairRequests.dispatchEvent(new CustomEvent("open", { detail: request }));
31
+ hostPair?.(request);
32
+ },
33
+ },
34
+ WebShell,
35
+ );
36
+ }
@@ -0,0 +1,2 @@
1
+ // Generated by packages/vscode-protoboard/scripts/logo-assets.py: the Protoboard "0" mark (PB-916) in a 24 × 24 box.
2
+ export const MARK_PATH = "M8.529 13.629 15.272 6.885C15.348 6.81 15.385 6.715 15.385 6.6C15.385 6.486 15.348 6.39 15.272 6.315L13.815 4.886C13.624 4.695 13.386 4.6 13.101 4.6H10.9C10.614 4.6 10.376 4.695 10.186 4.886L8.529 6.543C8.338 6.733 8.243 6.972 8.243 7.257V13.486C8.243 13.638 8.281 13.714 8.357 13.714C8.414 13.714 8.472 13.686 8.529 13.629ZM10.9 19.4H13.101C13.386 19.4 13.624 19.305 13.815 19.114L15.472 17.457C15.662 17.267 15.757 17.029 15.757 16.743V10.514C15.757 10.362 15.719 10.286 15.643 10.286C15.586 10.286 15.529 10.315 15.472 10.372L8.729 17.115C8.634 17.209 8.586 17.305 8.586 17.4C8.586 17.496 8.634 17.591 8.729 17.685L10.186 19.114C10.376 19.305 10.614 19.4 10.9 19.4ZM6.015 5.372 9.015 2.4C9.243 2.133 9.548 2 9.929 2H14.072C14.433 2 14.738 2.124 14.986 2.372L17.958 5.372C18.224 5.6 18.357 5.905 18.357 6.286V17.714C18.357 18.076 18.224 18.391 17.958 18.657L14.986 21.628C14.738 21.876 14.433 22 14.072 22H9.929C9.548 22 9.243 21.867 9.015 21.6L6.015 18.628C5.767 18.381 5.643 18.076 5.643 17.714V6.286C5.643 5.924 5.767 5.619 6.015 5.372Z";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The parts views' data from `protoboard dev` (its `/__protoboard/parts/`
3
+ * routes, packages/cli/src/dev/parts.ts): the library's search and parts,
4
+ * the project's parts, the bill of materials, and adding a part with the
5
+ * page's session token.
6
+ */
7
+ import type { BomModel, LibraryQuery, PartDetailModel, PartPages, PartPricesModel, PartsData, ProjectPartsModel, LibraryPageResult } from "@protoboardai/views";
8
+
9
+ export interface DevPartsOptions {
10
+ /** The parts routes' base URL (`…/__protoboard/parts/`), absolute. */
11
+ base: string;
12
+ /** The session token writes carry, and the header it goes in. */
13
+ token?: string;
14
+ tokenHeader?: string;
15
+ }
16
+
17
+ export function createDevPartsData(o: DevPartsOptions): PartsData {
18
+ const base = o.base.endsWith("/") ? o.base : `${o.base}/`;
19
+ const at = (path: string) => new URL(path, base);
20
+ const get = async <T>(path: string, params: Record<string, string | undefined> = {}): Promise<T> => {
21
+ const url = at(path);
22
+ for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
23
+ const res = await fetch(url, { cache: "no-store" });
24
+ const body = (await res.json().catch(() => ({ error: `${path}: ${res.status}` }))) as T & { error?: string };
25
+ if (!res.ok) throw new Error(body.error ?? `${path}: ${res.status}`);
26
+ return body;
27
+ };
28
+ // thumbnails and models come back as paths relative to the dev page's root: make them absolute here
29
+ const root = new URL("../../", base);
30
+ const abs = (u: string | undefined) => (u ? new URL(u, root).href : u);
31
+ // a part's pages carry pictures the same way
32
+ const absPages = (p: PartPages): PartPages => ({ ...p, images: p.images.map((i) => ({ ...i, url: abs(i.url)! })), ...(p.cad ? { cad: { ...p.cad, ...(p.cad.preview ? { preview: abs(p.cad.preview) } : {}) } } : {}) });
33
+ return {
34
+ async search(q: LibraryQuery, cursor?: string): Promise<LibraryPageResult> {
35
+ const r = await get<LibraryPageResult>("library", { text: q.text, category: q.category, domain: q.domain, protocol: q.protocol, sort: q.sort, cursor });
36
+ return { ...r, model: { ...r.model, items: r.model.items.map((x) => ({ ...x, ...(x.thumbnail ? { thumbnail: abs(x.thumbnail) } : {}) })) } };
37
+ },
38
+ async part(key: string, revision?: number): Promise<PartDetailModel> {
39
+ const d = await get<PartDetailModel>("part", { key, revision: revision ? String(revision) : undefined });
40
+ return { ...d, ...(d.thumbnail ? { thumbnail: abs(d.thumbnail) } : {}), ...(d.model ? { model: abs(d.model) } : {}), ...(d.pages ? { pages: absPages(d.pages) } : {}) };
41
+ },
42
+ // asked for when a card first shows a page; a revision never changes, so the views keep what they got
43
+ async pages(ref) {
44
+ return absPages(await get<PartPages>("pages", { key: ref.key, revision: ref.revision ? String(ref.revision) : undefined }));
45
+ },
46
+ prices: (key) => get<PartPricesModel>("prices", { key }),
47
+ async vendor(key, kind, revision) {
48
+ const res = await fetch(at("vendor"), {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json", ...(o.token ? { [o.tokenHeader ?? "x-protoboard-dev"]: o.token } : {}) },
51
+ body: JSON.stringify({ key, kind: kind === "add" ? "add" : "update", revision }),
52
+ });
53
+ const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
54
+ if (!res.ok) throw new Error(body.error ?? `vendor: ${res.status}`);
55
+ return body.message ?? "done";
56
+ },
57
+ // pictures come back as paths relative to the dev page's root, like the library's
58
+ projectParts: async (check?: boolean) => {
59
+ const r = await get<ProjectPartsModel>("project", { check: check ? "1" : undefined });
60
+ return { ...r, parts: r.parts.map((p) => ({ ...p, ...(p.thumbnail ? { thumbnail: abs(p.thumbnail) } : {}) })) };
61
+ },
62
+ bom: () => get<BomModel>("bom"),
63
+ async saveBom(change) {
64
+ const res = await fetch(at("bom"), {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json", ...(o.token ? { [o.tokenHeader ?? "x-protoboard-dev"]: o.token } : {}) },
67
+ body: JSON.stringify(change),
68
+ });
69
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
70
+ if (!res.ok) throw new Error(body.error ?? `bom: ${res.status}`);
71
+ },
72
+ async refreshOffers() {
73
+ const res = await fetch(at("offers"), { method: "POST", headers: { "content-type": "application/json", ...(o.token ? { [o.tokenHeader ?? "x-protoboard-dev"]: o.token } : {}) }, body: "{}" });
74
+ const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
75
+ if (!res.ok) throw new Error(body.error ?? `offers: ${res.status}`);
76
+ return body.message ?? "The offers were refreshed.";
77
+ },
78
+ openLink: (url) => void window.open(url, "_blank", "noopener"),
79
+ };
80
+ }
package/src/styles.css ADDED
@@ -0,0 +1,46 @@
1
+ /* The web shell's stylesheet: Tailwind 4 utilities over the viewer's token
2
+ * theme (the same names as the viewer's own components), no Preflight (the
3
+ * viewer's rules set their own base). */
4
+ @layer theme, base, components, utilities;
5
+ @import "tailwindcss/theme.css" layer(theme);
6
+ @import "tailwindcss/utilities.css" layer(utilities);
7
+ @import "@deltarobotics/uhd-viewer/tailwind-theme.css";
8
+ @source "./";
9
+ @source "../../views/src";
10
+
11
+ @layer base {
12
+ html,
13
+ body {
14
+ height: 100%;
15
+ margin: 0;
16
+ }
17
+ }
18
+
19
+ /* the parts views' controls start bare (no Preflight): utilities draw them */
20
+ @layer base {
21
+ .pb-parts,
22
+ .pb-parts :where(*, ::before, ::after) {
23
+ box-sizing: border-box;
24
+ }
25
+ .pb-parts :where(button, input, select, textarea) {
26
+ font: inherit;
27
+ color: inherit;
28
+ background: none;
29
+ border: 0 solid;
30
+ padding: 0;
31
+ margin: 0;
32
+ }
33
+ .pb-parts :where(h1, h2, h3, p, ul, ol) {
34
+ margin: 0;
35
+ padding: 0;
36
+ }
37
+ .pb-parts :where(ul, ol) {
38
+ list-style: none;
39
+ }
40
+ .pb-parts :where(img, svg) {
41
+ display: block;
42
+ }
43
+ .pb-parts :where(code) {
44
+ font-family: var(--uhd-font-mono);
45
+ }
46
+ }