Repository navigation
Rust Document Engine
pixlprfct-core is the Rust crate in src/ that owns the PixlPrfct document: its schema, every mutation, validation, layout, component synchronization, undo/redo and hit testing. It compiles to WebAssembly (via wasm-bindgen/wasm-pack) and exposes a single stateful class, Editor, whose methods take and return JSON strings. The single most important thing to know: every edit is a whole-document transaction — a method clones the document, changes the clone, and hands it to Editor::commit, which normalizes, validates, lays out, synchronizes components and only then swaps it in and pushes the previous document onto a 100-entry undo stack. If any step fails, the method returns an error string and the live document is untouched.
| Key files | Entry points | Depends on | Used by | Related docs |
|---|---|---|---|---|
src/lib.rs (schema, validate, Editor, commit pipeline, hit testing), 26 sibling modules in src/, Cargo.toml
|
Editor (#[wasm_bindgen] struct), Editor::commit / commit_validated_document (private), validate(&Document)
|
serde, serde_json, wasm-bindgen 0.2, js-sys 0.3
|
web/app.js, web/agent-runtime.js, web/project-home.js, web/prototype.js, mcp/projects.mjs, server/live-editing.mjs, 126 tests/*.test.mjs files |
README "Architecture", PERFORMANCE.md, MCP.md |
Cargo.toml declares package pixlprfct-core 0.1.0 (edition 2021) with crate-type = ["cdylib", "rlib"]:
-
cdylibis whatwasm-pack build --target web --out-dir web/pkgturns intoweb/pkg/pixlprfct_core.js+pixlprfct_core_bg.wasm+pixlprfct_core.d.ts. This runs as the first real step ofnpm run build:editor(see package.json).web/pkgis a build output and is not checked in. -
rlibletscargo test(the first half ofnpm test) and any native Rust consumer link the crate directly. On native targets the JS callbacks are simply never set, so text keeps its stored dimensions (seereflow_text). - Release profile:
opt-level = "s",lto = true;[package.metadata.wasm-pack.profile.release] wasm-opt = false(Binaryen is not run). - Dependencies are deliberately minimal:
serde(derive),serde_json,wasm-bindgen,js-sys.js-sysis used for the two callback types (js_sys::Function) and forDate.now()/Math.random()inclipboard_origin()onwasm32.
There is no build script and no feature flags. Target-specific code is limited to clipboard_origin() (#[cfg(target_arch = "wasm32")] vs. an AtomicU64 counter natively).
After the WASM build, scripts/generate-editor-api.mjs parses web/pkg/pixlprfct_core.d.ts and every pub struct/pub enum in src/*.rs to regenerate web/editor-api.js (engineCommands + modelReference). npm run mcp:check fails if that file is stale. See Build-System.
lib.rs declares every module privately (mod x;); nothing but Editor and the public structs is visible from JS. Most modules contribute either validation/resolution helpers called from the commit pipeline, or an extra #[wasm_bindgen] impl Editor { ... } block (wasm-bindgen merges these into one JS class).
| File | Role | Exports on Editor
|
|---|---|---|
| lib.rs | Core schema (Document, Page, Shape, Appearance, Paint, Gradient, DropShadow, Typography, AutoLayout, LayoutItem, LayoutGrid, Prototype, Constraints, RasterImage, Control, Behavior, …), validate, sync_paint_stacks, constraint math (constrain_axis), curve flattening, the Editor struct, commit pipeline, history, hit testing, pages, clipboard |
60 members incl. constructor |
| layout.rs | Auto-layout engine: reflow (flex rows/columns, wrap, hug/fill, min/max, baseline, margins, border insets), infer (Shift+A layout inference), drop_before
|
— (used by auto_layout_selection, move_into_before, commit) |
| grid.rs | CSS-Grid-style layout: GridLayout/GridTrack/GridCell/GridArea/GridOptions, plan (track sizing incl. fr, minmax, auto tracks, areas, masonry), track shift/move/delete, ensure_containers, drop_cell, validate
|
— (grid methods live in lib.rs) |
| components.rs |
Component enum (master / instance{master,source,overrides}), override recording (record_overrides), propagation (sync), validate_links, protect_structure, subtree, legacy button-label repair |
10 |
| variants.rs |
ComponentSet/Variant, validate_sets, prune, variant naming and switching |
6 |
| component_properties.rs |
ComponentProperty (kinds text, boolean, slot), propagate_edits, prune, transfer on swap |
4 |
| slots.rs |
SlotSettings/SlotState; slot binding and "modified" tracking in prepare (runs at the start of every commit) |
3 |
| variables.rs |
Variables/Collection/Mode/Variable, alias resolution, property-path typing (property_kind), resolve (writes resolved literals into bound properties), detach_edited
|
4 |
| color_styles.rs, text_styles.rs, effect_styles.rs, guide_styles.rs | Named shared styles: each has validate_styles, resolve (push style values onto linked layers) and save/apply/remove methods. effect_styles::Effects also validates per-layer shadows/blurs; text_styles::validate_typography and guide_styles::validate_grids validate per-layer values |
3 + 3 + 4 + 3 |
| paint.rs |
Media (image/video paint), Pattern (tile another layer), Shader; validate, validate_stack, validate_gradient, pattern-cycle check validate_sources, remap_sources for paste |
— |
| groups.rs |
Shape::is_container (frame or group), roots, prune_empty, fit (group bounds follow children) |
3 |
| frame_enclosure.rs | A drawn/moved frame adopts fully enclosed, unlocked siblings (rotated-box test) | 2 |
| layer_drop.rs | Layers-panel drag-and-drop (before/after/inside) | 3 |
| arrangement.rs | Z-order: front/back/forward/backward | 1 |
| stacking.rs |
paint_order: painting/hit order, honoring auto_layout.canvas_stacking == "first"
|
— |
| document_order.rs |
parents_first: stable reorder so parents precede children |
— |
| rotation.rs |
Shape::visual_bounds, Shape::unrotate_point (counter-clockwise degrees about the layer origin) |
— |
| path_join.rs, path_split.rs | Join two open paths at nearest endpoints; split a path at an anchor | — (join_paths, split_path in lib.rs) |
| stroke_alignment.rs, stroke_tips.rs | Hit-test helpers: stroke reach for inside/center/outside alignment; arrowhead/endpoint tips | — |
| page_background.rs | PageBackground {color, opacity, visible} |
1 |
| starter_defaults.rs | Control "starter" components: records which control fields a user changed (shared_overrides) and re-syncs the rest |
— |
| editor_tools.rs |
EditorMeta (role: section/slice/comment/annotation/measurement/spec, text-on-path, per-layer Motion keyframes, LibraryOverrides) and its validation |
— |
#[wasm_bindgen]
pub struct Editor {
clipboard_origin: String, // random per instance; regenerated by load()
text_measurer: Option<js_sys::Function>, // JS callback: Shape JSON -> "[w,h(,baseline)]"
write_guard: Option<js_sys::Function>, // JS callback: (before, after, history) -> "" | error
document: Document, // the live document
undo: Vec<Document>, // full previous documents (max 100)
redo: Vec<Document>,
next_id: u32, // next shape ID
next_page_id: u32, // next page ID
}Editor::new() starts with an empty version-2 document named "Untitled design" with one page {id:1, name:"Page 1"}, next_id = 1, next_page_id = 2. All fields are private; JS reads state only through snapshot() (a serde_json::to_string of the document).
Every mutating export follows the same shape:
- Parse arguments (JSON strings are deserialized with
serde_json; failures becomeErr(e.to_string())). - Check preconditions against
self.document(e.g.selection_idsrequires a non-empty, unique list of layers on the active page). -
let mut document = self.document.clone();and change the clone. Composite operations often build a throw-away "staged"Editor(same document, empty history) and call other methods on it — seeupdate_across_pages,insert_control_starter,preview_update_many,preview_layout_update. -
self.commit(document)(orcommit_documentforload). - Bump
next_idonly after a successful commit.
update/update_many take complete Shape objects, not patches. The JS side always sends the full shape (usually {...existingShape, ...changes}); the generated agent API (build/update in web/agent-runtime.js) converts partial deep patches to full shapes before calling the engine. update_many additionally records component overrides and starter overrides per shape, propagates component-property edits, syncs grid-area sizes, and applies child constraints (constrain_axis) when a parent frame is resized. Groups never establish a constraint coordinate system: children use the enclosing frame unless the group itself was explicitly edited (then they scale).
flowchart TD
A["mutating method<br/>(clone + edit)"] --> B["commit(document)"]
B --> B1["drop prototype links to deleted frames"]
B1 --> B2["slots::prepare · groups::prune_empty<br/>variants::prune · component_properties::prune"]
B2 --> B3["validate()"]
B3 --> B4["components::protect_structure(old, new)"]
B4 --> C["commit_validated_document"]
L["load(json)"] -->|"v1→v2 migration, legacy upgrades"| L2["commit_document → validate()"] --> C
C --> C1["grid::ensure_containers · starter_defaults::sync(+_dropdown)<br/>variables::resolve · sync_paint_stacks"]
C1 --> C2["validate() · components::validate_links · size ≤ 5 MB"]
C2 --> C3["document_order::parents_first · reflow_text (layout)"]
C3 --> C4["components::sync · guide/effect/text/color style resolve<br/>variables::resolve · sync_paint_stacks · drop dangling links"]
C4 --> C5["reflow_text again · assign unique layout_class names"]
C5 --> C6["validate() · validate_links · serialize ≤ 5,000,000 bytes"]
C6 --> C7["check_write(write_guard)"]
C7 -->|"document changed"| D["push old document to undo (cap 100), clear redo"]
C7 -->|"identical"| E["no history entry"]
B3 & B4 & C2 & C6 & C7 -.->|"Err(String)"| X["live document unchanged; JS sees exception"]
Things the pipeline does that are easy to miss:
-
Derived data is rewritten on every commit. Style links (
fill_style,text_style,effect_style,guide_style,stroke_style) and variable bindings are re-resolved and their literal values written back into each layer. The legacy scalar fields (fill,appearance.fill_enabled,gradient,fill_opacity,stroke_color, …) are recomputed from the first paint ofappearance.fills/strokesbysync_paint_stacks. Component instances are rebuilt from their mains bycomponents::sync, which may allocate new IDs (tracked throughnext). -
Layout runs inside the transaction.
reflow_textcallslayout::reflow; when text with auto sizing, baseline-aligned rows or auto-layout buttons exist and a text measurer is installed, it calls back into JS to measure and iterates to a fixed point (at most 130 passes) — otherwise it errors with "Text and frame sizing did not converge; use a fixed frame size". -
layout_classnames are generated here. Each shape gets a unique CSS class: custom valid names are reserved first, then generated ones are slugged from the layer name plus ID (e.g.frame-1-1). Names starting withpx-or equal tolayoutare reserved. -
Order is normalized.
parents_firstmakesdocument.shapesa pre-order traversal (parent before children, sibling order preserved). Paint order is derived from this, not stored separately.
-
undoandredoareVec<Document>of full document copies.commit_validated_documentpushes the previous document only when the new one differs (document != self.document), caps the stack at 100 byself.undo.remove(0), and clearsredo. One method call = one undo step, which is why composite operations go through a stagedEditorand a single finalcommit. -
undo()/redo()first run the write guard withhistory = trueagainst the target document, then swap documents withstd::mem::replace(no re-validation — history entries were already valid). -
select_pagechangesactive_pagedirectly without committing: page navigation never creates or discards history. -
load()does create an undo entry when the loaded document differs. The editor callsclear_history()after opening a file; web/app.js also usesloadfor document renames (document-namechange handler), which therefore are undoable. -
clear_history()empties both stacks.can_undo()/can_redo()drive the toolbar buttons. - Team live editing layers a separate per-user history on top (web/document-merge.js
PersonalHistory, also 100 entries, three-way merges each undo against the current shared document). See Teams-Libraries-and-Collaboration.
Note: Each history entry is a full
Document. With a 5 MB document cap, 100 entries can in the worst case hold roughly 500 MB of history in WASM memory. PERFORMANCE.md records that the previous document is now moved (not cloned) into the stack, but there is no structural sharing.
validate(&Document) in lib.rs is the single gate. It is called by commit (once), commit_validated_document (twice), load (before migration fix-ups), paste_layers (on a synthetic document containing only the clipboard shapes) and refresh_text_layout. It delegates to per-module validators: variables::validate_variables, variants::validate_sets, the four *_styles::validate_styles, component_properties::validate_properties, paint::validate_sources, grid::validate, editor_tools::validate, page_background::validate, text_styles::validate_typography, guide_styles::validate_grids, paint::validate_stack/validate_gradient, effect_styles::Effects::validate. components::validate_links and components::protect_structure enforce component rules separately.
Highlights (exact constants; the full table is in Document-Model-and-File-Format):
| Rule | Constant |
|---|---|
| Document version | must equal 2 after load-time migration |
| Shapes per document | ≤ 10,000 |
| Pages | 1–100 |
| Serialized document | ≤ 5,000,000 bytes (checked on load input and after each commit) |
| Hierarchy depth | ≤ 128 ancestors, no cycles; parents must be a frame or group on the same page |
| Coordinates, sizes, rotation | finite, abs ≤ 1,000,000; width/height ≥ 1 (groups ≥ 0) |
| Vector points | ≤ 4096, normalized 0..=1; vector kinds need ≥ 2 |
| Raster image | PNG/JPEG/WebP data URL, ≤ 8192×8192, base64 payload ≤ 2,000,000 chars |
| Paints per fill/stroke stack | ≤ 64 |
| Prototype interactions per layer | 1 primary + ≤ 5 additional, one per trigger |
Error messages are user-facing strings (they are shown verbatim in toasts), e.g. "Invalid shape data", "Rotated layers require fixed sizing", "Instance appearance is protected. Edit the main component instead.".
hit_test(x, y) -> u32 returns the topmost layer ID at a page coordinate, or 0:
- Iterate
stacking::paint_order(&shapes)in reverse (topmost first). Paint order is a pre-order walk from roots, reversing siblings for auto-layout frames whosecanvas_stacking == "first". - Skip layers not on
active_page, hidden, locked, or of kindgroup(groups are selected through their children). - Walk ancestors from the root down: any hidden/locked ancestor rejects the hit; the point is un-rotated through each ancestor (
unrotate_point) and must lie inside the rounded rect of everyclip_contentancestor (in_rounded_rect, which scales radii like CSS). - Un-rotate into the layer's own frame. Stroke bands are tested first (
stroke_alignment::hit). - Paths (
pointsnon-empty): arrow tips (stroke_tips::hit), then distance to the flattened polyline withinmax(stroke reach, 4.0); closed paths also use even-odd point-in-polygon. Cubic segments are flattened adaptively byflatten_curve(depth ≤ 12, tolerance 0.1). - Ellipses use the ellipse equation; everything else uses
in_rounded_rect.
Prototype playback uses prototype_hit_test, prototype_hits and prototype_pointer_hits. They build a staged Editor containing only the presented frame's subtree (unlocked, root clipped), hit-test it, then walk up the ancestors collecting layers with matching triggers. prototype_hits returns {"click":id,"mouseenter":[ids],"mouseleave":[ids]}; prototype_pointer_hits adds mousedown/mouseup.
When CSS rendering is active (custom CSS, controls, backdrop blur), web/app.js canvasHit uses the CSS renderer's DOM-based hit test instead of editor.hit_test. See Rendering-and-Export.
- Every fallible export returns
Result<T, String>. wasm-bindgen convertsErr(String)into a thrown JS value (a string, not anError), so JS wraps calls insafe(action)in web/app.js, which catches andtoast(String(error))s. - Infallible readers return plain values:
snapshot() -> String,hit_test -> u32,can_undo -> bool,prototype_hits -> String. - Errors are atomic: nothing is written to
selfuntil the end ofcommit_validated_document. The testload_is_atomic_and_roundtripsin lib.rs asserts that a failingloadleaves the snapshot unchanged. - Internal invariants use
unwrap()where validation guarantees them (e.g.snapshot()serialization; parent lookups inhit_test). A panic in WASM aborts the module instance, so new code should returnErrfor anything input-dependent. - Callback failures are mapped to strings: a throwing write guard yields
"Library permissions could not be checked"; a bad measurement yields"Text measurement failed"/"Invalid text measurement".
| Setter | Called from | Contract |
|---|---|---|
set_text_measurer(fn) |
reflow_text |
fn(shapeJson) -> string returning "[width,height]" or "[width,height,baseline]" (buttons: label size). Values must be finite and 1..=1,000,000. web/app.js measures with a 2D canvas (measureCtx); mcp/projects.mjs createHeadlessEditor uses approximate metrics. |
set_write_guard(fn | undefined) |
check_write (each commit, undo, redo) |
fn(beforeJson, afterJson, history: bool) -> string; non-empty string rejects the transaction with that message. app.js installs libraryWriteGuard (team permissions + libraries.guard, built on importedContentError in web/library-model.js) only when needed (syncLibraryGuard). |
refresh_text_layout() |
app.js on document.fonts loadingdone
|
Re-measures and re-syncs without creating a history entry (font availability is environment state, not an edit). |
Editor has 110 exported members: the constructor plus 109 methods across 15 #[wasm_bindgen] impl Editor blocks (wasm-bindgen also generates free()). JSON-typed parameters are named json, selection, variables or placements; "IDs" means a JSON array of layer IDs. Return u32 IDs are new layer/page/set IDs; String returns are JSON.
| Area | Methods (file) | Notes |
|---|---|---|
| Lifecycle & environment |
new() (constructor), load(json), snapshot(), set_write_guard(fn?), set_text_measurer(fn), refresh_text_layout(), reserve_ids(start, end), clear_history() — lib.rs |
reserve_ids confines new shape/page IDs to a server-assigned half-open range for live team editing (web/team-collaboration.js → LiveSession); errors "This editing session exhausted its ID range. Reopen the file." |
| History |
undo(), redo(), can_undo(), can_redo() — lib.rs |
|
| Create layers |
add(kind, x, y) -> id, insert(shapeJson) -> id, insert_control_starter(shapeJson, variablesJson) -> id, reset_starter_overrides(id) — lib.rs; insert_enclosing_frame(shapeJson) -> id — frame_enclosure.rs |
add uses fixed defaults (frame 600×400 #ffffff, text 240×56 #000000, others 160×120 #d9d9d9). insert takes a complete Shape; ID and page are assigned by the engine. |
| Edit layers |
update(shapeJson), update_many(shapesJson), update_across_pages(shapesJson), preview_update_many(shapesJson) -> docJson, preview_layout_update(shapesJson, id) -> {document, grid} — lib.rs |
Previews run on a staged editor and never commit. |
| Delete / duplicate |
remove(id), remove_many(ids), duplicate(id) -> id, duplicate_many(ids) -> idsJson, duplicate_many_offset(ids, x, y) -> idsJson — lib.rs |
Removal includes descendants. duplicate* offsets +24/+24. |
| Hierarchy & order |
move_into(shapesJson, parent), move_into_before(shapesJson, parent, before), reparent_many(ids, parent), reorder(id, delta) — lib.rs; move_and_enclose(shapesJson, parent, reparent) — frame_enclosure.rs; arrange_many(ids, direction) — arrangement.rs; drop_layer(id, target, position), drop_layers(ids, target, position), duplicate_drop_layers(ids, target, position) -> idsJson — layer_drop.rs; group_many(ids) -> id, ungroup_many(ids), rename_group(id, name) — groups.rs; frame_selection(ids) -> id — components.rs |
position is before/after/inside; direction is front/back/forward/backward. |
| Align / distribute |
align_many(ids, alignment), distribute_many(ids, axis) — lib.rs |
left/center/right/top/middle/bottom; horizontal/vertical. |
| Auto layout & grid |
auto_layout_selection(ids) -> id, enable_grid_containers(), grid_layout(id) -> planJson, move_into_cells(shapesJson, parent, placementsJson), preview_grid_drop(shapesJson, grid, parent, before, column, row) -> json, shift_grid_track(id, axis, index, delta) -> usize, move_grid_track(id, axis, index, before) -> usize, delete_grid_track(id, axis, index), delete_grid_area(id, name), rename_grid_area(id, old, new) — lib.rs |
Grid track edits compute changed shapes in grid.rs and commit via update_many. See Layout-Auto-Layout-and-Grid. |
| Vector paths |
join_paths(ids) -> id, split_path(id, index) -> idsJson — lib.rs |
Join requires exactly two open, unlocked paths with the same parent. |
| Clipboard |
copy_layers(ids) -> clipboardJson, paste_layers(json, dx, dy, parent) -> rootIdsJson, paste_into_frames(json, frameIds) -> json, paste_replace(json, ids) -> json — lib.rs |
Payload format pixlprfct.layers v1; see Document-Model-and-File-Format. |
| Pages |
add_page() -> id, duplicate_page(id) -> id, duplicate_page_at(id, target, after) -> id, reorder_page(id, target, after), select_page(id), rename_page(id, name), delete_page(id) — lib.rs; set_page_background(id, json) — page_background.rs |
select_page is not an edit. The last page cannot be deleted. |
| Components |
create_component(id), create_component_selection(ids) -> id, create_instance(master, x, y) -> id, place_instance(master, x, y, parent) -> id, detach_instance(id), swap_instance(id, master) -> id, reset_override(id, path), reset_instance(id), push_style_overrides(id) — components.rs; sort_components(orderJson) — lib.rs |
sort_components takes ["set:ID","component:ID",…] (stored as component_order). |
| Variants |
combine_variants(ids) -> frameId, add_variant(id) -> id, duplicate_variant(id) -> id, save_component_set(json) -> id, remove_component_set(id), set_variant_property(id, property, value) -> id — variants.rs |
|
| Component properties & slots |
save_component_property(json) -> id, remove_component_property(id), set_component_property(id, property, valueJson), reset_component_property(id, property) — component_properties.rs; wrap_in_slot(ids, name) -> id, clear_slot(id), reset_slot(id) — slots.rs |
See Components-Variants-and-Slots. |
| Variables |
save_variables(json), bind_variable(id, path, variable), set_variable_mode(id, collection, mode) — variables.rs; set_page_variable_mode(id, collection, mode) — variables.rs; bind_shadow_color(id, index, variable) — effect_styles.rs |
save_variables replaces the whole Variables object. Variable 0 detaches. |
| Styles |
save_color_style(json, layer, stroke) -> id, apply_color_style(ids, style, stroke), remove_color_style(id); save_text_style(json, layer) -> id, apply_text_style(ids, style), remove_text_style(id); save_effect_style(json, layer) -> id, apply_effect_style(ids, style), remove_effect_style(id); save_guide_style(json, frame) -> id, apply_guide_style(ids, style), remove_guide_style(id)
|
Style ID 0 in save_* creates; layer 0 saves without applying; style 0 in apply_* detaches, keeping resolved values. See Styles-Variables-and-Paints. |
| Hit testing (read-only) |
hit_test(x, y) -> id, prototype_hit_test(root, x, y) -> id, prototype_hits(root, x, y) -> json, prototype_pointer_hits(root, x, y) -> json — lib.rs |
The generated catalog in web/editor-api.js lists 100 of these as engineCommands: everything except new, load, snapshot, undo, redo, clear_history, reserve_ids, set_write_guard, set_text_measurer, refresh_text_layout (and free). Eleven are marked readOnly (copy_layers, can_undo, can_redo, grid_layout, hit_test, the three prototype_* hit tests, preview_update_many, preview_layout_update, preview_grid_drop). Descriptions come from the notes map in the generator script.
sequenceDiagram
participant UI as web/app.js (UI event)
participant E as Editor (WASM)
participant JS as JS callbacks
UI->>E: editor.update_many(JSON.stringify(shapes))
E->>E: clone + edit + commit pipeline
E->>JS: text_measurer(shapeJson) (0..n times)
JS-->>E: "[w,h,baseline]"
E->>JS: write_guard(before, after, false)
JS-->>E: "" (allow) or message (reject)
alt Err(String)
E--xUI: throws string → safe() → toast
else Ok
UI->>E: editor.snapshot()
E-->>UI: document JSON
UI->>UI: doc = JSON.parse(...), redraw, persist
end
-
Loading. web/app.js does
import init, { Editor } from './pkg/pixlprfct_core.js', thenawait init(); editor = new Editor();, installs the text measurer, and loads the project body. Node tests and the MCP server pass the.wasmbytes explicitly (init({module_or_path: await readFile(...)})); server/live-editing.mjs usesinitSync. -
Pattern in app.js. Small wrappers call the engine and then
refresh(): e.g.function updateMany(shapes){ safe(()=>{ editor.update_many(JSON.stringify(shapes.map(reconcilePaints...))); refresh(); }); }.refresh()callssnapshot()(documentBody = editor.snapshot(); doc = JSON.parse(documentBody)), re-syncs the library guard, redraws, and schedules persistence. The UI never mutatesdocdirectly; it is a read-only mirror. app.js contains ~158editor.*(call sites; inspector modules (text-style-controls.js,effect-style-controls.js,text-editing.js) call a few more. -
Live previews (dragging a width, CSS editing, grid drops) call
preview_update_many/preview_layout_update/preview_grid_dropand draw the returned document without committing. -
Throw-away editors. Several consumers create an isolated
Editorjust to normalize or validate:normalizeDocumentin app.js (isolated.load(...); isolated.snapshot(); isolated.free()), project-home's preview/normalize path, the prototype player (presentPrototypebuilds apreviewEditor), the server's live-edit merge validation (e.load(merged)→ 400 if invalid) and MCP'screateHeadlessEditor. -
Agents/MCP. web/agent-runtime.js
transactloads the current snapshot into a scratch editor, installs the write guard, runs the requestedengineCommandsagainst it, then loads the result back — one undo step per agent transaction. mcp/reference.mjs servesmodelReference(the verbatim Rust struct declarations) to agents. See MCP-Server.
-
Rust unit tests: only three, in
mod testsat the bottom of lib.rs —history_restores_edits_and_invalidates_redo,load_is_atomic_and_roundtrips,hit_testing_respects_ellipses_visibility_and_order. No other module has#[cfg(test)]. -
WASM integration tests: the bulk of engine coverage is Node
node:testsuites that import the compiledweb/pkg/pixlprfct_core.js(126 files in tests/ do this), e.g. tests/wasm.test.mjs (session, pages, v1 migration, atomic loads),components.test.mjs,layout*.test.mjs,grid-*.test.mjs,clipboard.test.mjs,variables.test.mjs. These requirenpm run build:editor(or at least thewasm-packstep) first. -
npm test=cargo test && node --test tests/*.test.mjs. Browser behavior is covered separately by Playwright (tests/browser/). See Testing-and-Verification.
Worked example: a hypothetical per-layer export_name: String that is saved, validated, overridable on instances and settable through a dedicated command.
-
Schema (Rust struct). Add the field to
Shapein src/lib.rs with a serde default so existing files still load (there is no per-field version bump):#[serde(default, skip_serializing_if = "String::is_empty")] pub export_name: String,
Shapeis constructed literally in exactly one place,Editor::add(document.shapes.push(Shape { ... })) — add the field there. Nested structs (Appearance,AutoLayout,Typography, …) follow the same rule; prefer#[serde(default = "fn")]for non-zero defaults, as the existingdefault_*functions do. -
Validation. Add a check in
validate()(or the owning module's validator) returning a short, user-readableErr. Bound every string length and numeric range and reject non-finite floats — validation is the only protection against malformed.pixlfiles, agent calls and pasted payloads. -
Cross-cutting behaviour. Decide whether the field participates in:
- component sync/overrides → add
"export_name"toFIELDSin src/components.rs and decide whetherrecord_overridesallows instances to change it (theallowedmatch); - clipboard →
copy_layers/paste_layersclear some per-document references (style IDs, variable bindings, components); clear yours if it holds an ID that is meaningless in another document, or remap it likepaint::remap_sources; - variables → add its path to
variables::property_kindandassign; - libraries → web/library-model.js
mergeLibraryremaps IDs when importing a library.
- component sync/overrides → add
-
Mutation (optional). Most properties need no new export — the inspector sends the whole shape through
update_many. For a dedicated command, add a method to a#[wasm_bindgen] impl Editorblock (lib.rs or the owning module): validate inputs, cloneself.document, edit,self.commit(document). ReturnResult<_, String>; useu32/String/bool/f64/usize/i32parameters and JSON strings for structures. Never write toself.documentoutsidecommit*(except navigation-like state such asselect_page). -
Rebuild and regenerate.
npm run build:editorrunswasm-packand regenerates web/editor-api.js. Add a description to thenotesmap in scripts/generate-editor-api.mjs; if the command is read-only, add it toreadOnly; if it takes JSON under a new parameter name, add the name tojsonNames. Commit the regeneratedweb/editor-api.js(npm run mcp:checkverifies it). -
JS. Call it from the UI via
safe(()=>{ editor.your_command(...); refresh(); }), or set the field in the shape passed toupdate/update_many. Renderers (web/appearance.js, web/css-renderer.js, web/export.js) and code export readdocdirectly. If MCP agents should use it, it is already exposed throughengineCommands; check web/agent-runtime.js for any allow/deny lists. -
Tests. Add a Rust
#[test]in lib.rsmod testsfor pure engine logic (round-trip throughsnapshot()/load(), rejection of invalid values, undo). Add or extend atests/<area>.test.mjsthat drives the compiled WASM like tests/wasm.test.mjs: set the value, assert it survivessnapshot()→load(),copy_layers→paste_layers,undo()/redo(), and that an invalid value throws and leavessnapshot()unchanged. Add a Playwright spec only if UI is involved.
-
Atomicity: a method either commits one new document or leaves everything unchanged. Do not mutate
selfbeforecommitsucceeds; bumpnext_idafter. -
One method = one undo step. Compose multi-step operations on a staged
Editorand commit its document once. -
Selection methods are page-scoped.
selection_idsrejects IDs that are not onactive_page;update_manyrejects apage_idchange. Useupdate_across_pagesfor cross-page edits. -
Do not trust stored derived values. Anything resolved from styles, variables, components, layout or paint stacks is overwritten on the next commit. To change a styled/bound value, detach (style
0, variable0) or edit the source. -
Instance edits are restricted.
record_overridesonly lets instances changename,text,hidden,locked,control_state, rootx/y/width, some sizing keys oflayout_item/auto_layout, and content keys ofcontrol; appearance-type fields are allowed only inside slot content. Others fail with "Instance appearance is protected. Edit the main component instead." Structural edits to instance children requiredetach_instance. -
load()regeneratesclipboard_origin. Clipboard payloads copied before aload(including the rename-via-load path in app.js) count as foreign: component links and slot state are dropped on paste. -
load()is undoable unless followed byclear_history(). - Text measurement makes layout environment-dependent. Native/headless editors without (or with approximate) measurers produce different text sizes than the browser.
- Performance is O(document) per edit: full clone, at least three full validations and two serializations per commit. See PERFORMANCE.md for measured costs (≈15 ms median name edit on a 727-layer private document at alpha.11).
- Minimal native test coverage (3 Rust tests); correctness relies on the WASM test suite, which requires a prior
wasm-packbuild. - Full-copy history without structural sharing (memory, see above).
- No
deny_unknown_fields: unknown JSON keys are silently dropped onload. Documents with extra top-level data (e.g. SVG Animator documents withdesign_type/svg_animation) must never pass through the engine; web/editor-entry.js routes them tosvg-animation.htmlinstead. - Single-threaded, synchronous API: long commits block the UI thread.
-
Document-Model-and-File-Format — schema,
.pixl, clipboard,.pixllib, limits - Architecture-Overview · Web-Editor-Architecture · Rendering-and-Export
- Layout-Auto-Layout-and-Grid · Components-Variants-and-Slots · Styles-Variables-and-Paints · Prototyping-Behaviors-and-Controls
- Build-System · Testing-and-Verification · MCP-Server · Teams-Libraries-and-Collaboration
-
src/lib.rs —
validate,Editor::commit,commit_validated_document,reflow_text,hit_test,copy_layers/paste_layers,load - src/components.rs, src/layout.rs, src/grid.rs, src/variables.rs — the largest subsystems
-
Cargo.toml, package.json (
build:editor,test,mcp:check), scripts/generate-editor-api.mjs -
web/app.js (
safe,refresh,snapshot,syncLibraryGuard,applyHistory), web/agent-runtime.js (transact) -
tests/wasm.test.mjs and the other
tests/*.test.mjssuites - Existing docs: README ("Architecture" section), PERFORMANCE.md, MCP.md
Start
Core
- Rust Document Engine
- Document Model & File Format
- Web Editor Architecture
- Rendering & Export
- Build System
Editor features
- Canvas Tools & Editing
- Layout, Auto Layout & Grid
- Components, Variants & Slots
- Styles, Variables & Paints
- Prototyping, Behaviors & Controls
- Import: Design Files & SVG
- SVG Animator
- Panel Workspace — tear-outs, icons, side flyouts and saved layouts
- Icon Library
Platforms
- Hosting Modes
- Self-Hosted Server
- Cloud API & Database
- Teams, Libraries & Collaboration
- Desktop App
- MCP Server
Practice