zudo-panel-designer docs
GitHub repository

Type to search...

to open search from anywhere

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.

components/header.tsx 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/toolbar.tsx 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/canvas-viewport.tsx 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/ruler.tsx): 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.

components/sidebar.tsx 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/collapsible-section.tsx) — 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_SIZES from @zpd/core), each labeled {hp}HP — {widthMm}×{heightMm}mm. Changing it commits a new panelHp on the document, which re-fits the camera.

  • Layerscomponents/layer-list.tsx, 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.

  • Align & Distributecomponents/align-panel.tsx. 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.

  • Propertiescomponents/inspector-host.tsx, 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).

  • Help (footer) — components/help-panel.tsx, 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's description text (see Tools and Extension architecture → Tool descriptions). It follows activeToolId, 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:

  1. Serializes the document with @zpd/core's serializePanelConfig (the canonical shape the fabrication reader expects) and JSON.stringifys it — pulled out as the pure, DOM-free panelConfigJson() helper so the exact output string is unit-testable without a real Blob/anchor.

  2. Wraps the string in a Blob, creates an object URL, and triggers a browser download named zpd-panel-{panelHp}hp.json via a temporary <a download> click.

  3. 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/artwork-only-statement.ts), 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:

  1. 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.

  2. Success. A "Gerber export downloaded" toast, and a zpd-panel-{panelHp}hp-gerber.zip download — see Round-Trip → The Gerber export is not round-trippable for what's inside.

  3. 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, titled Gerber 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-hp carry 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."

  4. Pipeline failure. A rejection from the pipeline itself — e.g. the kernel's lazy path-bool import, or the outliner's lazy opentype.js font 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.

CodeDialog messageNotes
unlisted-panel-hpThis 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-presentA raster image cannot be manufactured on the panel. Trace it to vector layers, or hide it, before exporting.
non-curated-fontThis text layer uses a font with no local file to outline.
missing-glyphThis text layer contains text the export cannot resolve to an outline in the resolved font subset.
unknown-pattern-idThis pattern layer names a generator that is not registered.
complexity-overrunThis design is too dense to export: the boolean pipeline would have to process more geometry than the export can handle.
unsupported-layer-typeNo 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-unreliableThe 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.