Interface
The editor (Editor.tsx) is a single full-screen shell with four regions: a header, a left toolbar, a center canvas viewport, and a right sidebar. The shell owns only generic state — the document (with undo/redo history), the camera, the current selection, and which tool is active. Everything domain-specific is rendered from data the extension registries expose, so this page describes the chrome as it behaves once those registries are populated.
Header
components/ is the top bar. From left to right it shows:
The app name.
The save-status chip — a small pill reporting whether the document is saved locally, still unsaved, or failed to save.
A zoom cluster: zoom-out / zoom-in buttons (25% steps), the current zoom percentage (relative to the "fit" scale, not to physical mm), and a Fit button that re-centers and re-scales the camera to frame the whole panel.
Undo / redo buttons, disabled when there is nothing to undo/redo.
A ? button that opens the searchable keyboard shortcuts overlay.
An ⬆ JSON button that imports a panel config JSON file (see Round-Trip → Importing).
A ⬇ JSON button that downloads the current document as an order-ready panel config JSON (see Download trigger below).
A ⬇ Gerber button that downloads a Gerber (.zip) export of the panel's artwork, gated by an artwork-only confirmation (see Download trigger → Gerber export below).
A New panel button that resets to the default starter document, after confirming (see Autosave & New Panel → New panel).
The whole app is also driven by the command registry: every one of the shortcuts above, plus the ones with no header button at all (clipboard, align/distribute, the command palette itself), routes through the same commands.ts list.
Browser zoom guard
browser-zoom-guard.ts is installed for the lifetime of the Editor (useEffect(() => installBrowserZoomGuard(), [])) and suppresses the browser's own native zoom gestures — preventDefault() on ⌘/Ctrl + wheel (how pinch-to-zoom is delivered as a wheel event), ⌘/Ctrl + +/=/-/0, and Safari's gesturestart/gesturechange/gestureend trackpad events. A plain, unmodified wheel or keypress is left alone — this only blocks the browser's own page-zoom, never the editor's in-canvas zoom (the header buttons and the canvas's own wheel handler).
This exists because a browser-zoomed viewport desyncs the cursor position the app is told about from the actual screen position, which misaligns every drag handle, resize handle, and click target in this drag-heavy editor. Unlike the reference app this was ported from, zpd has no separate display-scale setting to step in its place — the guard is pure prevention, with nothing to dispatch instead.
Left toolbar
components/ is entirely data-driven: it renders one button per entry in the tool registry, in registration order, followed by a divider and one button per entry in the add-action registry. Clicking a tool button calls ctx.setActiveTool(tool.id); clicking an add-action button calls action.run(ctx). Neither list is hand-maintained — a new file dropped into tools/ or add-actions/ appears here automatically. See Tools for the built-in tool set.
Canvas viewport & rulers
components/ is a purely presentational wrapper around a single <canvas>: it exposes a containerRef (measured with a ResizeObserver so the camera can re-fit on resize) and forwards pointer events to the Editor shell. The <canvas> itself has touch-action: none so drags aren't hijacked as scroll/pinch gestures on touch devices. All actual painting happens in the Editor's repaint effect — see Rendering & camera.
The viewport sits inside a fixed ruler frame (components/): a 20px millimeter ruler strip along the top, a matching strip down the left, and a small mm corner box where they meet. The strips redraw their ticks and labels whenever the camera pans or zooms, but they never move in layout — only the canvas content transforms underneath them. See Rendering & camera → The mm rulers for the tick math.
Right sidebar
components/ is a vertical stack of collapsible card panels. The top five share a scrolling inner column (overflow-y-auto overscroll-contain, so the list bottoming out never scrolls the canvas underneath); the Help panel is pinned below them as a non-scrolling footer that stays visible even when the stack above overflows.
Each panel is a CollapsibleSection (components/) — a bordered card whose header is a button that toggles the body open or closed (aria-expanded on the button, a ▾/▸ affordance). The open/closed state is per-session and deliberately not persisted; there is no reordering or animation.
View — two view-only checkboxes at the top of the stack. Show content outside the panel (default on) ghost-paints, at low opacity, any layer content that spills past the panel edge — so a layer dragged partly off-panel stays visible and grabbable instead of leaving its selection handles floating over empty workspace. The dimming is meaningful: the region beyond the panel edge is physically cut away in fabrication, so it reads as "this will not be manufactured" (see Rendering & camera → The off-panel ghost pass). Show guides (default on) shows the ruler guides and lets you create, move, and delete them by dragging from the rulers (see Guides). Both are pure view state — neither changes the document or the exported JSON.
Panel — a
<select>of the real Eurorack HP sizes (PANEL_SIZESfrom@zpd/core), each labeled{hp}HP — {widthMm}×{heightMm}mm. Changing it commits a newpanelHpon the document, which re-fits the camera.Layers —
components/, organized into three fixed material sections shown top-to-bottom as Silkscreen (white), Solder mask (black), and Copper (gold/HASL). The physical/persisted order is the reverse: Copper → Solder mask → Silkscreen. The fixed headers can be collapsed for the session and hidden through persisted, undoable visibility, but cannot be selected, renamed, deleted, nested, or reordered. Ordinary rows inside them support selection, rename, reorder/drag, show/hide, delete, and grouping. Moving artwork between material sections changes its effective fabrication material; any legacy per-object color is compatibility data, not an editable finish.layer- list. tsx Align & Distribute —
components/. Six align buttons, two distribute buttons, and a selection/panel reference toggle, disabled until the current selection meets the operation's minimum layer count. See Align & Distribute.align- panel. tsx Properties —
components/, which renders the registered inspector for the selected layer's type, or a placeholder when nothing is selected. The card title also names the selected layer's type (e.g. Properties — shape).inspector- host. tsx Help (footer) —
components/, pinned at the very bottom of the sidebar and starting collapsed. It describes the currently active tool: its name, a keyboard-shortcut badge, and the tool'shelp- panel. tsx descriptiontext (see Tools and Extension architecture → Tool descriptions). It followsactiveToolId, so the transient Space-held pan override never changes what it shows.
App-wide text selection
The editor shell's root element carries select-none, so click-dragging anywhere in the chrome or on the canvas never starts a native text selection. This keeps drag gestures — moving a layer, panning, drawing a pen path — from accidentally highlighting UI labels mid-interaction. The editable fields opt back in explicitly with select-text (the layer-rename input, the inspector number/text fields, the text inspector's <textarea>), so you can still select and edit their contents normally.
Download trigger
The header's ⬇ JSON button calls downloadPanelConfig(ctx.doc) from download.tsx. That function:
Serializes the document with
@zpd/core'sserializePanelConfig(the canonical shape the fabrication reader expects) andJSON.stringifys it — pulled out as the pure, DOM-freepanelConfigJson()helper so the exact output string is unit-testable without a realBlob/anchor.Wraps the string in a
Blob, creates an object URL, and triggers a browser download namedzpd-panel-{panelHp}hp.jsonvia a temporary<a download>click.Revokes the object URL on a deferred tick (
setTimeout(..., 0)) rather than immediately — revoking in the same tick as the click can abort the download in some browsers before it has read the blob.
This is the editor-side trigger only; the shape of the exported JSON and how it's consumed downstream is documented in the Export section.
Gerber export
The header's ⬇ Gerber button (title/aria-label both read Download Gerber export (.zip)) and the command palette's Download Gerber (.zip) command (file-download-gerber, chordless — see Shortcuts & Command Palette) both call exportGerberZip(ctx.doc) from download.tsx. The whole Gerber pipeline — the IR builder, extractors, boolean kernel, and RS-274X writer — sits behind a lazy await import(...), never a static import, so it stays out of the main chunk for a feature that only runs on an export click. The confirm gate's copy is the one exception: it lives in its own dependency-free module (gerber/), statically imported, so the first dialog renders without waiting on that lazy chunk.
Unlike ⬇ JSON, which writes its file on the spot, exportGerberZip is asynchronous and always interposes one confirm dialog before anything is generated:
Confirm gate. The dialog states the export is artwork only, and appends the current panel spec:
Export Gerber (.zip) This export contains artwork only — copper, solder mask, silkscreen, and the board outline. It contains no drill file and no mounting-hole geometry. It is artwork for an already-specified Takazudo blank panel, not a standalone orderable board.
Panel: {panelHp}HP, {widthMm} × {heightMm} mm.[Cancel] [Export .zip]The dialog opens with focus already on Cancel. Cancelling here produces no file at all.
Success. A "Gerber export downloaded" toast, and a
zpd-panel-{panelHp}hp-gerber.zipdownload — see Round-Trip → The Gerber export is not round-trippable for what's inside.Refusal.
buildGerberIr()can refuse the export instead of producing one — every reason collected across the whole document, never reported one at a time. A second dialog, titledGerber export blocked — N issue(s), then names them all together:Gerber export blocked — 2 issues [Cancel] [OK]
Its body is one entry per reason, with the layers that caused that reason nested underneath:
This text layer uses a font with no local file to outline.
Label
A raster image cannot be manufactured on the panel. Trace it to vector layers, or hide it, before exporting.
Photo
Document-level refusals such as
unlisted-panel-hpcarry no layer names, because no layer caused them. This dialog is an acknowledgement, not a choice — both buttons simply dismiss it, and its OK button renders in red (danger: true). A refusal always aborts the export and produces no file: never a partial export, never a console warning. Hidden layers are skipped before refusal evaluation entirely and so can never trigger one — which is why the image-layer message above says "…or hide it, before exporting."Pipeline failure. A rejection from the pipeline itself — e.g. the kernel's lazy
path-boolimport, or the outliner's lazyopentype.jsfont fetch, throwing — shows a "Gerber export failed" error toast carrying the error message. Both entry points call this fire-and-forget (void exportGerberZip(...)), so an uncaught rejection would surface nowhere at all: no dialog, no toast. Catching it here is what extends the "never fail silently" rule past the anticipated refusals to cover unexpected crashes too.
Every refusal carries one of the codes below. Dialog message is the string the dialog prints verbatim; Notes is this page's own commentary.
| Code | Dialog message | Notes |
|---|---|---|
unlisted-panel-hp | This panel HP has no entry in the blank-panel spec table, so its width is only an approximation — not an order-ready dimension. | Document-level, so no layer names are listed under it. |
image-layer-present | A raster image cannot be manufactured on the panel. Trace it to vector layers, or hide it, before exporting. | — |
non-curated-font | This text layer uses a font with no local file to outline. | — |
missing-glyph | This text layer contains text the export cannot resolve to an outline in the resolved font subset. | — |
unknown-pattern-id | This pattern layer names a generator that is not registered. | — |
complexity-overrun | This design is too dense to export: the boolean pipeline would have to process more geometry than the export can handle. | — |
unsupported-layer-type | No geometry extractor is registered for this layer type, so its artwork cannot be exported. | Exists to surface an unhandled type as a refusal rather than silently drop it. Every current layer type has a registered extractor, so normal use should never reach it. |
pattern-union-unreliable | The boolean union this export depends on is measured to corrupt this pattern generator — a #206/path-bool backend defect tracked in #218, not a caller bug. Refusing rather than shipping fabrication data already known to be wrong. | Layer names render as <layer name> (pattern "<patternType>"). Tracked as issue #218. |
Note
The document model, undo/redo history, and layer types referenced throughout this page (DocState, Layer, PATTERN_GENERATORS, serializePanelConfig, …) live in @zpd/core and @zpd/patterns and are covered in the Document Model section.