Round-Trip
The editor's ⬇ JSON button (packages/) downloads the current document as an order-ready panel config JSON, and the app now has a matching import path back in (the header's ⬆ JSON button, drag-and-drop anywhere on the page, or the command palette's Import JSON command — see Importing below). "Round-trip" here means: the downloaded JSON, fed back through parsePanelConfig(), reproduces the on-screen document. That guarantee is what makes the file trustworthy as an order artifact and as an interchange format for future tooling.
The download step
downloadPanelConfig(doc) calls serializePanelConfig(doc), pretty-prints it (JSON.stringify(config, null, 2)), and triggers a browser download named zpd-panel-<hp>hp.json. The pure serialization step — panelConfigJson(doc) — is factored out separately from the DOM/Blob mechanics so the exact JSON string is unit-testable without a real download.
Importing
Three entry points converge on the exact same code path (importJsonFile() in import.ts), so "import a file" behaves identically no matter how the file arrived:
The header's ⬆ JSON button — a persistent hidden
<input type="file">behind a ref.Drop-anywhere import (
components/) — drop adrop- import. tsx .jsonfile (or an image — see below) anywhere on the page, not just a precise canvas target. A full-page overlay ("Drop image or panel JSON") appears while a file is being dragged over the window, tracked with an enter-count balanceddragenter/dragleavepair so the overlay doesn't flicker as the cursor crosses nested elements. Only file drags are intercepted (dataTransfer.types.includes('Files')) — dragging selected text into an in-app field, like the layer-rename input, is left completely alone.The command palette's Import JSON command (chordless, palette-only) — opens a transient native file picker.
The import pipeline is strict, unlike the lenient parsePanelConfig() used everywhere else in this page: it runs the raw JSON through core's tryParsePanelConfig(), which checks the envelope markers (app === 'zpd', an version in the supported range, a layers array) before delegating to the same defensive per-field parsing described in Defensive Parsing. This distinction exists because import UX needs to tell "this is not a zpd panel config at all" apart from "this is a real config with one wonky field" — parsePanelConfig() alone can't (by design, it never fails; see Defensive Parsing → Why this matters).
On a rejected file (invalid JSON, wrong app, missing/out-of-range version, or no layers array), the current document is left completely untouched and an error toast reports why — "Could not import panel JSON" with a short reason (e.g. "File is not valid JSON."). A valid file prompts for confirmation before anything changes:
Replace current panel? This replaces the current panel with the imported one. This cannot be undone. [Cancel] [Replace]
Confirming replaces the whole document via the same replaceDoc() primitive New panel uses — discarding undo/redo history, clearing the selection, and evicting stale image-cache entries — then shows a success toast ("Panel imported").
Dropping an image instead
Drag-and-drop also accepts an image/SVG file in addition to a panel JSON file. isImportableImageFile() admits any image/* MIME type, a .svg extension, or an anonymous/extensionless non-JSON file with an empty MIME type so content sniffing can decide. It goes through the same routeImportFile() classifier as Add image… and clipboard paste. Raster content is decoded, scaled (never up, only down) to fit within 80% of the panel width / 50% of the panel height, placed at a fixed 10%-width / 15%-height offset from the panel's top-left corner, and committed as a new selected design-time image in Copper. A real SVG instead opens the vector import dialog: each source paint can be mapped to Copper, Solder mask, or Silkscreen; generated paths are routed to those containers, and a shape whose fill and stroke map to different materials is split so both mappings survive. Oversized SVGs preserve the raster-image fallback. None of these additions replaces the document or needs confirmation. A dropped file that's neither JSON nor a recognized image type shows an "Unsupported file" error toast ("Drop an image or a zpd panel JSON file.") instead of a silent no-op.
Paste uses the same image/SVG router, with a conservative candidate gate. The paste handler accepts an OS clipboard file whose MIME starts with image/, whose name ends in .svg, or whose name and MIME are both absent; the last case is handed to content sniffing. routeImportFile() then makes the same raster-versus-vector decision described above. Pasted content that's neither such a file nor a recognized zpd clipboard envelope is left completely untouched — no error toast — matching the rest of paste's "never hijack a normal paste" contract. See Clipboard → Paste for the full priority order.
What is preserved exactly
packages/ and packages/ both assert full round-trip fidelity for a document covering all 5 layer types:
doc → serializePanelConfig → JSON.stringify → JSON.parse → parsePanelConfig → docparsePanelConfig(JSON.parse(JSON.stringify(serializePanelConfig(doc)))) deep-equals the original doc for a fixture exercising:
every layer type (
shape,pattern,path,text,image)a hidden layer (
hidden: true)a
pathlayer with bezier handles (hin/hout) on some points but not othersa
pathlayer'sextraSubpaths(multiple closed subpaths from image tracing)rotationpresent on some layers and absent on othersthe document's ruler
guides, including a hidden one (the guide round-trip assertion checks thatroundTripped.guidesdeep-equals the original)
All of the above survives the round trip unchanged.
What is intentionally NOT preserved
Unknown/extra fields — anything not in the
PanelConfigshape (stray top-level keys, extra per-layer properties) is dropped on import. See Defensive Parsing.panel.widthMm/panel.heightMm— always recomputed fromhpon the way back in, even if the file was hand-edited to disagree. These are advisory output, not authoritative input. See PanelConfig Format.Invalid values — an out-of-range
color, a non-finite coordinate, a malformedhp, etc. get clamped or defaulted rather than reproduced verbatim (by definition — the input was invalid). See Defensive Parsing.patternTypevalidity — an unrecognizedpatternTypestring round-trips as opaque data (it is preserved verbatim), but nothing about the pattern's rendering is guaranteed — core has no dependency on the patterns registry, so only the app/patterns layer knows whether a givenpatternTyperenders to anything.
Verified at the browser level too
The Playwright @smoke suite exercises the real download in a browser, not just the pure functions: it clicks the download button, reads the produced file off disk, and asserts parsePanelConfig(downloaded) equals parsePanelConfig(onScreen), where onScreen comes from the live document via the window.__zpdTest test bridge. See Testing for how that suite is structured.
The Gerber export is not round-trippable
Everything above is the JSON round trip. The ⬇ Gerber button (see Interface → Download trigger → Gerber export) produces a second export artifact, and the guarantee this page documents does not extend to it. Treat it as terminal output, not an interchange format.
A successful export downloads zpd-panel-<hp>hp-gerber.zip, containing exactly five entries:
zpd-panel-<hp>hp.GTL— top copperzpd-panel-<hp>hp.GTS— top solder maskzpd-panel-<hp>hp.GTO— top silkscreenzpd-panel-<hp>hp.GKO— board outline profileREADME.txt— the panel spec line, a filename-to-role table for the four Gerber files above, and the same artwork-only statement the confirm gate shows, closing with: no Excellon drill file, no bottom-side files, no paste layer
Nothing reads that zip back in: the importer accepts panel config JSON and images/SVG only (see Importing above). The deeper reason is that Gerber sits downstream of the document model — by the time a file is written, layer structure, pattern parameters, and text strings have all been flattened into per-material, boolean-unioned regions, so the information a round trip would need is no longer in the file at all.
The two artifacts have different jobs. The JSON is the one you save, reload, and order from; the zip is artwork handed to an already-specified Takazudo blank panel, not a board you can order on its own (see PCB material layers & sizing for the material model this export writes from).