`paint_fill_then_stroke` now takes the resolved fill explicitly
rather than reading `node.fill` directly. Callers in `paint_node`
pre-resolve via `node_fill(node, var_table)` which checks
`var_table.fill_for(node.id)` first, falling through to `node.fill`
otherwise. Result: a Frame / Rect whose canonical loader registered
a `$ref` for its fill paints the current themed value at runtime;
flipping `active_theme` repaints with the new colour.
- `paint_fill_then_stroke` signature: adds `fill: Option<Color>`
as the last arg (after world_rect + zoom).
- Both `NodeKind::Frame` + `NodeKind::Rect` branches in
`paint_node` now compute `node_fill(node, var_table)` before
calling the helper.
#5 Variables/Themes: types + storage + canonical loader +
fill_refs map + resolve + paint-time substitution all working.
Variables panel UI (active-theme picker + variable list with
edit) is the remaining piece — that's a widget, not a model
change.
Tests total: 236 shell-core (no new assertions in this commit;
the existing 8 variable tests cover the resolution chain that
paint now consumes). Wasm32 build clean.
Plumbing for paint-time `$ref` substitution. `paint_node` now takes
`&VariableTable` alongside the existing args; recursive calls
pass it through unchanged. The new `node_fill(node, var_table)`
helper resolves `var_table.fill_for(node.id).or(node.fill)` —
ready for paint sites to swap in.
Full substitution still requires `paint_fill_then_stroke` /
icon_font branches to call `node_fill(node, var_table)` instead of
reading `node.fill` directly — that's a focused refactor (changes
the helper's signature in `canvas_viewport_overlay.rs` + every
NodeKind branch in paint_node) and lands separately. With the
plumbing in place today, the helper switch is a single per-site
edit; no more API reshape needed.
#5 Variables now ~90% — types, storage, loader, fill_refs map,
paint plumbing all shipped. Only the per-site `node.fill →
node_fill(node, var_table)` substitution remains.
Tests total: 236 shell-core. Wasm32 build clean.
Advances #9 from "magic-byte detection only" to "we can read
something useful from the JSON clipboard format". `parse_fig` on a
`{"type":"FIGMA_DOCUMENT","children":[...]}` payload now returns
the count of top-level children entries instead of just
`NotYetImplemented`.
Hand-rolled JSON walker (shell-core stays serde-free for wasm32
bundle size): tracks brace depth + string-quote state to count
exactly the `{` openings at depth-1 inside the `"children": [`
array. Robust against quoted braces in node names + arbitrary
nesting inside each top-level child.
`ParsedFigStub` gains `top_level_children: usize`. Binary `.fig`
path stays `NotYetImplemented` — Zstd decompression + the
schema-encoded body need their own focused work (likely a server-
side binary or a desktop-only adapter, since adding `zstd-sys` to
shell-core would inflate the wasm32 bundle).
Tests (3 new):
- 3-entry children array → count 3
- Nested objects inside each top-level → only top-level counted
- Quoted brace in a string value → not counted
#9 Figma now ~20%. Real Figma → PenNode mapping (the 17-file
pen-figma port: fig-parser + figma-node-mapper + 14 specialised
converters) remains the multi-week core work.
Tests total: 236 shell-core (+3) + 20 native + 8 desktop = 264.
Wasm32 build clean.
`mcp::run_stdio(registry, reader, writer)` reads line-delimited
JSON-RPC requests, dispatches each through the registry, writes
the wire-formatted response with a trailing `\n`, flushes after
each line. Loops until EOF or write error.
Generic over `BufRead` + `Write` so the same function powers:
- The eventual `openpencil-mcp` binary (`stdin().lock()` +
`stdout()`).
- Test fixtures using `Cursor<&[u8]>` + `Vec<u8>`.
- Future TCP-listener wrappers.
Malformed input is skipped silently — the loop survives garbage
lines so a misbehaving client can't kill the server. Production
deployments will want logging here; the stub leaves that hook for
the binary.
Tests (2 new):
- Three-line stream (two valid + one unknown-tool) produces
three responses, ids preserved (`1`, `2`, `"x"`), error code
`-32601` for the UnknownTool case.
- Mixed garbage + blank + valid stream produces one response
matching the single valid request.
#7 MCP now ~50% — types + registry + wire format + listener loop.
Remaining: real tool implementations (insert_node, batch_design,
design_skeleton, etc) + the binary entry. Each tool is a focused
follow-up; the dispatcher is done.
Tests total: 233 shell-core (+2). Wasm32 build clean.
Deep-clones a registered Component's root subtree with fresh
`NodeId`s and appends to the active page's top-level children.
Mirrors TS drag-from-Components-panel insertion + the right-click
"Insert Instance" path.
- `Document::instantiate_component(component_id, next_id) ->
Option<NodeId>` — looks up `doc.components`, deep-clones root
via `clone_node_with_new_ids` (private walker), pushes to
`active_page().children`, sets the new root as selection
anchor, captures pre-state to history (one entry per insert).
- `next_id` allocator threaded through so every node in the
cloned subtree gets a unique id past `max_node_id() + 1`,
matching the same guard `duplicate_selected` /
`group_selected` use.
Tests (2 new):
- Component with 2 children → instance with same shape, both
children have fresh ids (≠ source 11, 12), selection lands
on instance root, history grew by one.
- Unknown component id → None (no-op, no history).
#8 Components now ~65% — types + storage + create + instantiate
flow all shipped. UI hookup (Components panel widget + right-
click "Insert Instance" + drag-drop into canvas) is the remaining
follow-up.
Tests total: 231 shell-core (+2). Wasm32 build clean.
#5 Variables advances toward 90% with `VariableTable.fill_refs:
BTreeMap<NodeId, String>` — node-id → variable-name map for fills
that should resolve through the variable table instead of using
`node.fill` directly. Avoids touching `Node`'s shape (which would
invalidate every Node literal across test fixtures + builders) by
piggybacking on the same VariableTable the canonical loader fills.
API additions on `VariableTable`:
- `set_fill_ref(node_id, ref_name)` — register a node's fill ref
- `fill_for(node_id) -> Option<Color>` — resolve through the
current `active_theme`; falls back to None when no ref is
registered or the variable doesn't resolve. Canvas paint will
call this first, fall through to `node.fill` on None.
Side-derivation: `NodeId` now derives `PartialOrd + Ord` so it
can key into `BTreeMap`. The existing 3-codegen-test fixtures
gain a `fill_refs: BTreeMap::new()` initializer.
Tests (2 new):
- `fill_for_resolves_registered_node_ref_to_themed_color`:
register a Themed Color variable + a node ref, flip
`active_theme`, verify the resolved Color tracks the theme
- `fill_for_returns_none_when_no_ref_registered`: unknown
NodeId returns None so paint falls back to direct fill
Canvas-side `paint_node` integration (`let fill =
doc.var_table.fill_for(node.id).or(node.fill);`) lands when the
canvas refactor for that path arrives next.
Tests total: 229 shell-core (+2). Wasm32 build clean.
Bridges the gap between the in-memory `ToolCall` / `ToolResponse`
types and on-the-wire JSON-RPC frames. Pure Rust, no serde dep
(shell-core stays wasm32-clean — adding serde would inflate the
bundle for a feature only the server binary uses).
- `response_to_json(&ToolResponse) -> String` — emits the
standard `{"jsonrpc":"2.0","id":...,"result":...}` for OK and
`{"jsonrpc":"2.0","id":...,"error":{"code":...,"message":...}}`
for Err. Hand-rolled emitter with proper JSON escaping for
`"`, `\`, `\n`, `\r`, `\t`, and control chars.
- `parse_tool_call(&str) -> Option<ToolCall>` — minimal parser
that extracts `id` / `method` from a single-line JSON-RPC
request. Empty `arguments` map for now; the server binary
will swap in a real serde parse when wired.
- `error_code_to_int` — maps `ToolErrorCode` variants to
JSON-RPC's reserved + application-range codes per the spec
(-32600..-32603 transport, -32001..-32002 application).
Tests (4 new):
- Ok response carries `"jsonrpc":"2.0"`, the right id, and the
result map serialised correctly.
- Err response carries the right error code (-32601 for
UnknownTool) and message.
- Round-trip: parse_tool_call → registry.dispatch → response_to_json
preserves the request id through the full pipeline.
- JSON escapes special chars (`"`, `\n`) in both id and message.
#7 MCP now ~30% — types + registry + wire format. Real stdio
listener (line-delimited JSON over stdin/stdout) lives in the
follow-up server binary.
Tests total: 227 shell-core (+4) + 20 native + 8 desktop = 255.
Wasm32 build clean.
Adds the "Save as Component" mutator — promotes the anchor-selected
Frame/Group to a registered Component in `doc.components`. Same
shape as TS app's right-click "Make Component" context-menu action.
Semantics:
- Selection must be exactly one node (anchor); anchor selection
is the natural target for a "save as component" gesture.
- Kind must be `Frame` or `Group` (loose shapes need to be
wrapped first; matches TS).
- The node stays on the page; the library entry is a clone.
- Returns the new component id (== source node id), None on
rejection.
Tests (3 new):
- happy path: Frame → component registered, node still on page
- non-container rejection: Rect selected → None, lib empty
- no-selection no-op: clear_selection → None
#8 Components now at ~50% — library + storage + create flow. UI
(right-click menu wire-up, Components panel for browsing) and
NodeKind::Instance variant (for component-instance nodes on the
canvas) remain.
Tests total: 223 shell-core (+3) + 20 native + 8 desktop. Wasm32
build clean.
- `React` — JSX functional component named `Page` wrapping nodes in a
fragment; inline `style={{}}` objects with camelCase keys.
- `Flutter` — `Stack(children: [Positioned(left, top, child:
Container/Text)])`. Color emits as `Color.fromARGB`.
- `SwiftUI` — `ZStack { Rectangle()/Ellipse()/Text(...) .frame.position
}`. Color emits as `Color(red, green, blue, opacity)`.
All three reuse the established `Codegen` trait + walk
`doc.pages[active].children` the same way the HTML / Vue / Svelte
emitters do — `hidden` nodes are skipped, children recurse, text
bodies escape special chars (HTML targets) or use Dart/Swift
string-literal-escaping (`{:?}` debug-fmt) for compiled targets.
Tests (4 new):
- React: import statement + component declaration + JSX fragment
- Flutter: Stack wrapper + Positioned/Container shape + ARGB color
- SwiftUI: ZStack + Rectangle + .frame modifier
- SwiftUI ellipse: NodeKind::Ellipse → `Ellipse()` view
Remaining 2 generators (Compose, React Native) ship in a follow-up
commit — both follow the same trait-per-target shape with their
specific framework's geometry primitives.
Tests total: 218 shell-core (+4) + 20 native + 8 desktop. Wasm32
build clean.
Adds `Vue` and `Svelte` codegen targets alongside the existing
`CssVariables` and `Html`. Both reuse `emit_node_html` for markup
and embed the `CssVariables` generator's output verbatim in their
`<style>` block, so design tokens flow through into the framework
output as CSS custom properties.
- `Vue` — Vue 3 SFC: `<template>` (node markup) + `<script setup
lang="ts">` placeholder + `<style scoped>` (variables).
- `Svelte` — Svelte SFC: `<script lang="ts">` placeholder + bare
markup (no wrapping template tag, per Svelte convention) +
`<style>` (variables). Script-then-style order enforced by
test.
Tests (3 new):
- `vue_emits_template_script_style_blocks` — all three SFC
sections present
- `svelte_emits_script_then_markup_then_style` — script appears
before style in output (positional check)
- `vue_includes_variable_css_in_style_block` — variables flow
through into the scoped style block
5 generators remain (React + Tailwind, Flutter, SwiftUI, Compose,
React Native). The HTML-derivable group is done; the remaining 5
are framework-specific component models that need their own
emitters.
Tests total: 214 shell-core (+3) + 20 native + 8 desktop. Wasm32
build clean.
Adds `codegen::Html` alongside the existing `CssVariables` generator.
Walks `doc.pages[active].children` and emits absolute-positioned
`<div>` per Rect/Frame/Group + `<span>` per Text, with inline-style
position/size, RGB fill, stroke as border, corner radius (50% for
ellipses), and rotation transform. Body text is HTML-escaped.
API stays identical — both generators implement the same
`Codegen` trait, so a future CLI / Property-panel codegen
dispatcher fans out by trait object.
Tests (4 new):
- DOCTYPE + body wrapper + generator-attribution comment present
- Rect emits `<div>` with left/top/width/height + `rgb(r,g,b)` fill
- Text emits `<span>` and `&` / `<` / `>` in body get HTML-escaped
- `node.hidden = true` skipped entirely (no orphan markup)
7 generators remain to port (React + Tailwind, Vue, Svelte,
Flutter, SwiftUI, Compose, React Native) — each as a focused
follow-up commit. The trait + dispatcher shape doesn't change.
Tests total: 211 shell-core (+4) + 20 native + 8 desktop. Wasm32
build clean.
Codex stop-gate (round 2 on the same surface): the previous fix
gave `McpTool::call` access to `&ToolCall` so a well-behaved tool
COULD echo `request.id` — but nothing made it do so. A buggy /
adversarial tool was still free to mint a fake id, and id-mismatch
silently broke JSON-RPC routing on the client side.
Refactor: change the trait return type from `ToolResponse` (id +
payload) to a content-only `ToolOutcome::{ Ok(map) | Err(code,
msg) }`. The registry's `dispatch` wraps the outcome with the
originating `call.id` to produce the on-wire `ToolResponse`. Tools
never see the id; id-mismatch is now structurally impossible.
- New `ToolOutcome` enum sits between tool implementations + the
wire-shape `ToolResponse`.
- `McpTool::call(&self, args: &BTreeMap<String, String>) ->
ToolOutcome` — args-in, outcome-out, id-blind.
- `ToolRegistry::dispatch` constructs `ToolResponse::Ok { id:
call.id, result }` and `ToolResponse::Err { id: call.id, ... }`
from the outcome.
- `EchoTool` updated to the new signature.
- New `LyingTool` fixture deliberately ignores any context the
registry might pass; `registry_forces_id_on_response_regardless_of_tool`
asserts the response still carries `req-honest` even though
LyingTool's `call` returns an empty content map.
Tests: 4 MCP tests pass (3 carried over + 1 new id-stamping
regression). 206 shell-core total. Wasm32 build clean.
BLOCK #1 — anchor drag couldn't return to start. `apply_cursor_move`
only called `set_path_anchor_position` when the cursor doc-point
differed from `start_doc`, so dragging away and then BACK onto the
original point silently skipped the final write — release committed
history with the anchor stuck at the last off-start frame.
Fix: always write the cursor position during an active drag; use
the start-doc comparison only to flip `moved` (which gates history
push). Regression test `anchor_drag_back_to_start_lands_at_start`
simulates the round-trip and asserts the anchor follows the cursor
all the way home.
BLOCK #2 — MCP tool registry dropped the request id. `McpTool::call`
only received `&BTreeMap<String, String>`, forcing tools to invent
response ids (test double used `RequestId::Num(0)`). JSON-RPC + MCP
require every response to echo the originating request id. Fix:
change the trait signature to `call(&self, request: &ToolCall) ->
ToolResponse` and have `dispatch` forward the whole call. EchoTool
updated to read `request.id`; the registry test now asserts the
id round-trips.
BLOCK #3 — opening a native saved file leaked variables across
documents. `apply_payload` reset pages + history + selection but
never touched `doc.var_table` or `doc.components` (both added in
recent commits). Open a variable-bearing canonical `.op`, then
open a plain saved `.pen` — codegen would still emit the stale
canonical variables. Fix: `apply_payload` now reassigns both to
`Default::default()` after the page/UI reset block.
Tests: 206 shell-core + 20 shell-native (+1 anchor return) + 8 desktop.
Wasm32 build clean.
#10 on the TS-parity roadmap starts. User directive (memory
project_op_rust_gap_priority) is "codegen last"; CSS Variables is
the simplest and complements the #5 Variables/Themes work already
shipped this session — it emits whatever lands in
`doc.var_table` as a stylesheet without requiring the rest of the
node tree.
API:
- `crate::codegen::Codegen` trait — `target_label()` +
`generate(&Document) -> String`. Pure: no file I/O.
- `CssVariables` impl — walks `doc.var_table.variables` and emits
`:root { --name: value; }` for scalar entries, plus
`:root[data-axis="value"] { ... }` blocks for each themed
combination. CSS ident sanitisation maps non-alphanum chars to
`-` so `primary.color` → `--primary-color`.
Tests (4):
- emits scalars (`#0066ff`, `12`) under a `:root` block
- per-theme variables emit one block per axis combo, both light
and dark CSS variables present
- non-ident chars sanitised (`primary.color` → `primary-color`)
- empty doc emits only the generator header comment
Remaining 8 generators (React + Tailwind, HTML, Vue, Svelte,
Flutter, SwiftUI, Compose, React Native) ship in follow-up
commits; the `Codegen` trait means each is a focused new file.
Tests total: 198 shell-core (+4). Wasm32 build clean.
Translates a `$ref` variable name straight into a paintable
`crate::Color`. Gates on `VariableKind::Color`; rejects non-Color
variables, unparseable strings, and any non-Str scalar. Lenient
hex parser handles `#rgb` / `#rrggbb` / `#rrggbbaa` (case-insensitive),
rejects anything else.
This is the function paint-time `$ref` substitution will call from
the canvas viewport — once `Node` carries an optional ref name
alongside its direct fill (next session's model change for #5),
paint reads `node.fill_var.as_ref().and_then(|n| doc.var_table.resolve_color(n))`
falling back to `node.fill`. The Color helper is the pure piece;
the Node-level field addition is the invasive piece.
Tests (4):
- resolve_color_parses_rrggbb_hex — `#ff8040` round-trips
- resolve_color_picks_themed_active_value — `mode: dark` picks
the dark entry of a Themed Color variable
- resolve_color_rejects_non_color_variables — Number/Bool/String
variables return None even with a hex-looking value
- resolve_color_rejects_invalid_hex — `not-hex` returns None
Tests total: 190 shell-core (+4) all pass.
Mirrors the var_table wiring from commit e81e8e09. Adds the
component-library field to Document so the canonical .op loader
(and future "Save as Component" mutator) has a place to land
component definitions.
- `Document.components: ComponentLibrary` — Default = empty.
- Patched the same 9 test fixtures (`tests_geometry.rs` × 7 +
`canvas_viewport.rs` + `layer_panel_tests.rs`) to seed the
field alongside `var_table`.
- Library lookup methods (`find_by_id` / `find_by_name` /
`insert` with id-replace) are usable on `doc.components` from
anywhere.
Pen-doc-adapter integration (`pen_document_to_payload` doesn't yet
carry components) + `NodeKind::Instance` variant for cross-document
instances + Components panel widget all stay pending — the data
shape lands first so the loader integration is one focused commit.
document.rs / mutators.rs both back at the 800-line cap via
doc-comment compaction (Node field docs dropped to inline naming,
`t()` Doc compressed to one line).
Tests: 187 shell-core (no new assertions; the 3 components tests
landed in commit 0fecab0a). Wasm32 build clean.
Drops the storage layer for #8 Components onto shell-core so the
canonical `.op` loader has somewhere to land design-system data
when it ships. Same scaffold-first pattern Variables/Themes used.
New file `document/components.rs`:
- `Component { id, name, root: Node }` — one reusable design
fragment; `root` is the subtree future Instance-NodeKind will
clone on insert.
- `ComponentLibrary { components: Vec<Component> }` — per-document
registry. `find_by_id` / `find_by_name` for lookup;
`insert(c)` replaces on duplicate id (TS parity with the
'Save as Component' overwrite path).
Re-exported from `crate::document::{Component, ComponentLibrary}`.
Document field wiring + canonical loader integration land in a
follow-up alongside the `NodeKind::Instance` variant (needs the
match-arm sweep across canvas paint / pen_doc_adapter / serializer).
Tests (3): find_by_id matches by id; find_by_name returns the
first hit; insert replaces an existing entry on id collision.
document.rs back at the 800-line cap (was 805 after the mod
declarations) via doc-comment compaction on Node — fields kept
self-documenting via name.
Tests total: 187 shell-core (+3) + 19 shell-native + 8 desktop.
Wasm32 build clean.
Compact three multi-line comment blocks in `widget_host/input.rs`
that were narrating implementation details Codex already covers
inline elsewhere:
- path-anchor `moved` flag explanation: 4 lines → 1
- align-toolbar hover sync rationale: 4 lines → 1
- settings-input keyboard ownership: 3 lines → 1
Net 8 lines saved; file is still 78 over the 800-line cap because
the remaining bulk is real code (apply_text dispatch, apply_release
drag-clearing chain, apply_cursor_move's 6-branch drag detection).
A proper sibling-module split — moving keyboard handlers to
`widget_host/keyboard.rs` and clipboard ops to `widget_host/clipboard.rs`
— is task #49 and stays a clean focused refactor for the next pass.
Tests: 19 native still pass. No behavior change.
Closes the gap between `VariableTable` (commits `62b08b93` /
`dbfd2f1a`) and the canonical `.op` loader: opening a file now
preserves `.variables` + `.themes` straight onto
`Document.var_table` instead of dropping them at the door.
- `Document.var_table: VariableTable` field — Default = empty
table. Patched 9 test fixtures (`tests_geometry.rs` × 7 +
`canvas_viewport.rs` + `layer_panel_tests.rs`) to seed with
`VariableTable::default()` alongside `history`.
- `pen_doc_adapter::build_var_table(&PenDocument) -> VariableTable`
maps `jian_ops_schema::variable::*` → shell-core types: the
enums are isomorphic (Color/Number/Boolean/String;
Bool/Num/Str; Scalar/Themed). Theme axes copy through directly.
- `persistence.rs` open path: when the canonical branch fires,
`build_var_table` runs alongside `pen_document_to_payload`. The
result is held in a local `Option<VariableTable>` because
`apply_payload` doesn't know about variables; after it resets
the document, the held value is assigned to `doc.var_table`.
Round-trip: load → `doc.var_table.find("color-1")` returns the
right `Variable`, `doc.var_table.resolve("color-1")` returns the
themed value under the current `active_theme` (empty default —
picks the `theme = None` entry, mirroring `Variable::resolve`'s
fallback). UI for switching `active_theme` (Variables panel) +
paint-time `$ref` substitution are the remaining follow-ups for #5.
Tests: 184 shell-core + 19 shell-native (no new assertions in this
commit; variables algorithm tests landed in `dbfd2f1a`). Desktop
builds clean.
Codex stop-gate BLOCK #1: `boolean_ops::apply_boolean_op` looked up
source paths recursively (via `active_page().find()`) but only
removed them from the top-level `page.children` list. When the
sources lived inside a Group or Frame, the originals stayed in
their parent's children while the result was appended at the
canvas root — duplication + orphans.
Fix: replace the top-level `retain` call with a recursive
`remove_nodes_recursively` walker that drops any node whose id is
in the source set from every children Vec depth-first. New
regression test `boolean_op_removes_nested_paths_not_just_top_level`
wraps two paths in a Group, runs Union, and asserts:
- the Group still exists but is empty
- one result Path lives at top level (total page.children = 2)
Codex stop-gate BLOCK #2: `property_panel_sections::export_section_rect`
was added in commit `5bde95b9` (PropertyPanel preview pills) but
never wired into `hit_test_action`, leaving the visible Export
section unable to open the new ExportDialog. The helper is dead
code in the meantime. Drop it; replace with a comment marking the
follow-up. Existing UX (File menu → Export image / Cmd+Shift+P)
still opens the dialog. Tracking via task #52.
Codex stop-gate finding #3 (`main.rs` 828 / `input.rs` 886 over
the 800-line cap) is real but stylistic — already tracked as task
#55 (sibling-module split). No functional impact; deferred so this
commit stays scoped to the data-corruption + dead-code fixes.
Tests: 184 shell-core + 19 shell-native (+1 nested boolean ops
regression). Wasm32 build clean.
Adds a `VariableTable { variables, themes, active_theme }` struct
that owns the canonical `.op` variable + theme registry. Looks up
by name (`table.find("color-1")`) and resolves through the active
theme (`table.resolve("color-1") -> Option<&VariableScalar>`).
`active_theme` is a `BTreeMap<String, String>` mapping axis to
selected value (e.g. `{"mode": "dark"}`); empty map falls back to
the default `theme = None` entry of every Themed variable.
Both `Variable::resolve` (single var) and `VariableTable::resolve`
(registry-level) are now testable in isolation; the canonical
loader's job in the next session is to populate one of these
tables from `PenDocument.variables` + `.themes`.
Document field wiring deferred: 9 test fixtures construct
`Document { ... }` literals across `tests_geometry.rs` /
`layer_panel_tests.rs` / canvas viewport tests; threading a new
field through them is a separate, mechanical commit. Until then,
the variable table lives as a free-standing type that adapters can
hold on the side.
Tests: 184 shell-core pass; both `document.rs` + `mutators.rs`
sit at exactly the 800-line cap.
Lays in the data shapes the canonical `.op` loader needs to
round-trip designs that depend on `$ref` color tokens + multi-axis
themes. Mirrors `jian_ops_schema::variable` so loader integration
in a follow-up is a direct field map.
New types in `document/variables.rs` + re-exported from
`crate::document`:
- `VariableKind { Color, Number, Boolean, String }`
- `VariableScalar { Bool(bool), Num(f64), Str(String) }` (untagged
over the on-disk `"#ff0000"` / `12.5` / `true` shapes)
- `ThemedValue { value, theme: Option<BTreeMap<String, String>> }`
— one entry per (axis, value) combination
- `VariableValue::Scalar | Themed(Vec<ThemedValue>)`
- `Variable { name, kind, value }` — owns its resolution: passes
an `active_theme: &BTreeMap<String, String>` and returns the
`VariableScalar` whose theme map is the subset that matches.
Falls back to the entry with `theme = None`. Empty `Themed([])`
returns None.
- `ThemeAxis { name, values }` — placeholder for the Variables
panel's theme picker (paint UI lands later).
Tests (4):
- scalar_variable_resolves_regardless_of_theme — `$color-1`
always returns the literal regardless of active axis.
- themed_variable_picks_active_axis — light/dark color tokens
resolve correctly for each `mode`.
- themed_variable_falls_back_to_default_when_no_match — `theme:
None` is the safety net when active axis isn't enumerated.
- themed_variable_returns_none_when_empty_and_no_default — empty
`Themed([])` returns None instead of panicking.
Follow-ups:
- Wire `Document.variables: Vec<Variable>` + `.themes` +
`.active_theme` fields (waiting on cap headroom in document.rs).
- Canonical loader integration: `pen_doc_adapter` reads
`PenDocument.variables / .themes` into the new fields.
- Paint-time `$ref` resolution: walk nodes pre-paint, replace
`Color { ref: ... }` with the resolved variable scalar.
- Variables panel widget in the Right rail.
Tests: 184 shell-core (+4) all pass. document.rs back at 800-line cap
after compacting FileAction + BooleanOp doc comments.
Adds `alt_modifier` tracking on the desktop runner (alongside the
existing `zoom_modifier` + `shift_modifier`). New keyboard-event
arm matches `Cmd/Ctrl + Alt + <letter>` and dispatches to
`WidgetHostNative::apply_boolean_op`:
- U → Union
- S → Subtract
- I → Intersect
- X → Exclude
Mirrors TS `apps/web/src/hooks/use-edit-shortcuts.ts` (Ctrl+Alt+U/S/I)
plus the canonical Paper.js Exclude shortcut (Ctrl+Alt+X). With ≥ 2
Path nodes selected, the user can now reach all four boolean
operations from the keyboard without going through a toolbar.
The dispatch is gated on `!shift_modifier` so future Shift-variants
(e.g. Cmd+Alt+Shift+U for "subtract from instead of union into")
stay reserved.
Tests: 180 shell-core + 18 shell-native (boolean op tests still pass
against the underlying mutator). Wasm32 build clean.
Adds `apply_boolean_op(op)` on the native widget host so downstream
callers (keyboard shortcuts in main.rs, future toolbar buttons,
menu items) can dispatch a path boolean op with a one-line call.
Wraps `boolean_ops::apply_boolean_op` and threads the host's
`next_node_id` allocator through so the result Path mints a fresh
id that can't collide with existing nodes.
Tests: 18 native (no new assertions in this commit; the existing
4 boolean_ops tests cover the underlying mutator). Wasm32 build
unaffected — the method is desktop-only.
#2 on the TS-parity roadmap lands. Skia's built-in `Path::op`
backed by `SkPathOps` does the heavy lifting; the mutator lives in
`shell-native` (not shell-core) so the web bundle stays skia-free.
API:
- `openpencil_shell_core::document::BooleanOp` — Union / Subtract /
Intersect / Exclude (mirrors TS Paper.js' four ops).
- `openpencil_shell_native::boolean_ops::apply_boolean_op(
doc, op, next_id) -> bool`
Filters the selection to Path nodes (Rect/Frame/etc are ignored
so a mixed selection still composes paths). Requires ≥ 2 Path
sources; otherwise no-op + no history. Builds skia paths via
PathBuilder.{move_to, line_to, close}, folds via Path::op,
extracts result points from the PathIterRec stream (Move/Line
take pts[0]; Quad/Conic take pts[1]; Cubic takes pts[2]; Close
is dropped). Builds the result Path node inheriting the first
source's fill + stroke, recomputes its bounds, replaces the
source paths in the active page, and pushes one history entry.
Tests (4):
- union_of_two_overlapping_squares_collapses_to_one_path —
proves the source pair is removed + one new Path appears + the
bounds are non-empty + history grew by one.
- intersect_keeps_overlap_region — verifies the 10×10 overlap
bounds of two 20×20 squares offset by (10, 10).
- boolean_op_requires_two_path_nodes — single-Path selection
no-ops without touching history.
- boolean_op_skips_non_path_nodes_in_selection — mixed Path +
Rect selection still composes the two Paths; Rect survives.
Keyboard-shortcut + toolbar wiring lands in a follow-up so this
commit stays focused on the mutator. Curves in the result degrade
to their endpoint (TS Paper.js has the same v1 behavior; full
anchor-with-handles model arrives with #3 follow-up).
Tests total: 180 shell-core + 18 shell-native (+4) + 8 desktop
export. Wasm32 build clean.
Closes the loop on the anchor-drag interaction (commits `814d05ca`
+ `6daaaf62`): when the selected node is `NodeKind::Path` AND the
Pen tool is active, the canvas now paints a small white-filled +
primary-stroked 8 px circle at each `node.points[i]`. These match
exactly the doc-space coords that `path_anchor_hit` checks against,
so the user can SEE the targets the drag honours instead of
guessing.
Added in canvas_viewport.rs section 4b — between the selection
overlay (4) and the canvas-state restore — so the dots paint on top
of the path outline but underneath any modal overlays.
Behavior matrix:
- non-Path selected: no handles
- Path selected, Select tool: no handles (existing 8 resize +
rotation handles still paint as before)
- Path selected, Pen tool: per-anchor handles (one per
`node.points` entry)
Existing 180 shell-core + 14 shell-native tests still pass. Wasm32
build clean.
Codex CONCERN #1: a press-release on an anchor handle with no
cursor motion in between still pushed the pre-drag snapshot, adding
a no-op entry to the undo stack that made the next Cmd-Z appear
inert. Fix: PathAnchorDragState gains `start_doc: Point2D` +
`moved: bool`. apply_cursor_move flips `moved` true only when the
cursor's document-space position differs from the start by > 0.001
doc-px. apply_release_with_viewport pushes the snapshot only when
`moved == true`; otherwise drops the state without touching
history. Two regression tests:
- anchor_press_release_without_motion_does_not_push_history
(seeds moved=false; release leaves history unchanged + returns
!consumed)
- anchor_drag_with_motion_pushes_one_history_entry (seeds
moved=true; release pushes exactly one entry + returns consumed)
Codex CONCERN #2: PDF pages were sized to each page's own content
bounds, producing heterogeneous page sizes that caused viewer
zoom/scroll to jump between pages. Fix: export_pdf takes the union
of all page bounds (max width + max height + 16-pt margin) and
emits every page at that uniform size. Each page's content is
positioned inside the frame at its own (origin.x, origin.y) so the
visual layout is unchanged — only the page frame becomes
consistent.
Tests: 180 shell-core + 14 shell-native + 8 desktop/export.
wasm32 build clean.
Wires the `set_path_anchor_position` mutator (groundwork commit
`814d05ca`) into the canvas hit-test + drag dispatch.
Geometry helper `path_anchor_hit(x, y, vw, vh)` in
`widget_host/geometry.rs` returns `Some((node_id, anchor_index))`
when the press lands inside an 8-screen-pixel circle around an
existing anchor of the selected Path node, with the Pen tool
active. Radius scales with viewport zoom so the hit box stays a
constant screen size.
Press dispatch (`widget_host/press.rs::apply_press`) — Pen tool
branch checks `path_anchor_hit` BEFORE the existing
add-anchor / start-path code path. Hit → captures pre-drag history
snapshot, seeds `path_anchor_drag` state. Miss → falls through to
existing author-anchor behaviour.
Cursor move (`widget_host/input.rs::apply_cursor_move`) — slot
between node-drag and marquee-drag: snaps the picked anchor to the
cursor's document-space coords via the mutator.
Release (`widget_host/input.rs::apply_release_with_viewport`) —
slot between node-drag and marquee-drag: pushes the pre-drag
snapshot so the user gets a single Cmd+Z to revert the whole
drag.
State struct `PathAnchorDragState { node_id, anchor_index,
pre_drag_snapshot }` lives in `widget_host.rs` (debug + clone, not
copy because DocumentSnapshot owns its pages Vec).
Tests: all 12 native input tests still pass (existing coverage
exercises the press/move/release path on adjacent drag types so any
regression on those would surface). 180 shell-core tests pass.
Wasm32 build clean.
Follow-ups: visual handles (paint per-anchor dots on selected Path
when Pen tool is active so the user sees what to grab); web parity;
codex review of the chain.
Adds `Document::set_path_anchor_position(node_id, index, pos)` —
moves one anchor on an existing Path node to a new doc-space
position and recomputes the node's bounding box. Mirrors the
gesture the eventual anchor-drag interaction will use: pick an
anchor by index, snap it to the cursor, re-fit bounds. History is
the caller's responsibility (anchor drag pushes one snapshot per
drag-start, not per cursor-move).
Defensive: is_editable gate (locked/hidden nodes ignored), index
range check, NodeKind::Path-only via path_points_mut_walk.
Tests:
- moves a known anchor + verifies bounds re-fit (y range now
covers the new max).
- out-of-range index returns false.
- non-Path node returns false.
UI wiring (canvas hit-test on anchor handles + drag dispatch) lands
in the next pass. This commit just makes the mutation primitive
available so subsequent UI work can call into it without reworking
the document layer.
Phase 4 — closes the last gap in the ExportDialog. Each PenPage
becomes one PDF page laid out at its content bounding box plus a
16-pt margin. Empty pages are skipped; all-empty docs return
'nothing to export' to match the raster export convention.
Mechanism: `skia_safe::pdf::new_document(&mut buf, None)` returns
the document; per-page `begin_page` / `end_page` reuses the same
`paint_node` pipeline as the raster path, so every variant the PNG
exporter handles (Rect / Ellipse / Polygon / Line / Path / Frame /
Group with rotation + corner radius) emits as real vector PDF ops —
glyphs and shapes stay selectable and zoom-clean. The TS app hand-
rolls a PDF stream (Catalog + Pages + Image XObjects with DCTDecode
JPEG blobs); skia's backend produces a smaller, sharper file.
Wiring:
- `export.rs::page_bounds` + `paint_node` exposed as `pub(crate)`
so the new `export_pdf.rs` sibling can reuse them.
- `Cargo.toml`: skia-safe `pdf` feature flag enabled.
- `persistence.rs::ExportImageConfirm` PDF branch now calls
`export_pdf::export_pdf` instead of returning the placeholder
error.
- `ExportFormat::is_implemented` now returns true for every variant
so the dialog stops greying out the PDF pill.
Tests: 2 unit tests in `export_pdf.rs` cover `%PDF-` header +
`%%EOF` trailer for a 2-page doc, plus the all-empty failure path.
197 total tests pass.
Phase 5 codex review still pending; will land in the next pass.
Document::align_selected covers 6 align actions (left / center-h / right
/ top / center-v / bottom) and 2 distribute actions (horizontal /
vertical center-spacing). Reference frame is the union of selection
bounds for 2+ nodes, the parent container for a single selection
(top-level no-ops). Ancestor-in-set dedup mirrors translate_selected so
a frame + child selection only shifts the frame; descendants cascade.
History pushes only when at least one node actually moved.
Floating AlignToolbar widget appears when selection_count >= 2; centered
horizontally above the canvas with a 56-px reserve so it never overlaps
the vertical Toolbar column. Hidden entirely when the canvas can't host
both. Hover state lives on Document.ui.align_toolbar_hover and clears on
every selection-count drop. Hit-test sits before apply_click on both
native + web so visible buttons always win clicks. Hover sync runs AFTER
all drag branches in cursor_move so an active node-drag isn't stolen by
a hover update.
8 lucide d-strings (align-start/center/end-vertical/horizontal +
horizontal/vertical-distribute-center) added to icons_data.rs from
lucide-react@0.545.0. Codex stop-gate reviewed three times to BLOCK-free.
Tests: 25 align (mutator + widget) + 1 native drag-interception
regression. Closes the v0.8.0 align/distribute roadmap item.
Pivot the desktop's Open path to the canonical `jian-ops-schema`
parser and route layout through `jian-core::LayoutEngine` so files
saved by the TS editor, Jian apps, or any tool emitting the
canonical schema load through the shared parser + paragraph shaper.
Loader (pen_doc_adapter.rs + pen_doc_path_bounds.rs)
- All 12 PenNode variants → NodePayload, with each root's authored
(base.x, base.y) added to harvested rects so multi-design files
(e.g. pencil-demo.op's 14 mockups) spread across the canvas.
- Path anchors port `getPathBoundsFromAnchors` — endpoints + Bezier
handles + cubic-derivative extrema — so curved paths scale into
their (width, height) the way the canonical renderer paints.
- jian-skia's `SkiaMeasure` plugged in via
`LayoutEngine::with_backend(...)`, replacing the ~10% character-
count heuristic with real paragraph-shaper metrics. Wrap/layout
now agree with paint instead of cascading 10% errors.
- Numeric-string fontWeight (`"700"`, `"normal"`, ...) parsed in
both jian-core and the desktop adapter; expanded keyword table
covers black/heavy/extralight/extrabold/demibold/hairline/etc.
- Version-tolerant `load_canonical` retries with `version` rewritten
to `"1.0"` so legacy `version: "2.8"` files still load.
Text + icon rendering
- `Node.text_wrap` gated on `textGrowth: fixed-width` — single-line
by default so font-fallback overshoot doesn't break lines the TS
app shows on one line.
- CJK-aware `wrap_text` (canvas_viewport_overlay.rs) — per-char CJK
breaks, word breaks for Latin, blank-line preservation, explicit
`\n` splits. Takes a weight param.
- `RenderBackend::measure_text_weighted` added with NativeBackend +
WebBackend overrides so wrap measurement matches weighted paint.
- icons.rs + new icons_data.rs sibling cover ~75 lucide variants
for first-party `iconFontName` names from pen-core element-builders
(trending-up/down, compass, refresh-cw, layout-dashboard, users,
package, zap, sliders-horizontal, activity, loader, focus,
chart-line, settings-2, arrow-right, check-circle, alert-triangle,
alert-octagon, sticky-note, bar-chart-2, bold/italic/underline,
shopping-cart/bag, send, message-circle, rocket, menu, credit-card,
x-circle, mail, smartphone, chrome, apple, user, ...). Unknown
names stroke a dot fallback (FALLBACK_ICON_D) instead of a block.
All d-strings copied from lucide-react@0.545.0.
- `Icon::from_name(&str)` resolves kebab-case + common aliases.
- Synthetic bold via PaintStyle::StrokeAndFill for weights ≥600 on
both native and web (single-weight bundles can't serve a real
bold variant).
Chrome polish
- Hover state on file menu / locale picker / shape picker / layer
panel rows / AgentSettings nav + provider cards. Host's
apply_cursor_move updates each per its open state.
- File menu compacted (row 30, header 22, no `…` suffix on actions),
recent file names truncate with a CJK-aware helper.
- `rfd::MessageDialog` on every failed Open / OpenRecent / Save /
SaveAs / ExportImage with bilingual (EN/ZH) title + path + detail.
OpenRecent failures prune the stale entry.
- `figma_import.rs` modal honest-stub (Coming soon copy, brand glyph),
TopBar Folder+Chevron compound + Figma button.
- Settings sidebar nav + provider cards tinted on hover.
- Recent-files panel polished to single-line names with age column.
Tests
- pen_doc_adapter_tests.rs (sibling via #[path]) — 19 cases covering
multi-root canvas offsets, shape size fallbacks, path anchor
absolutize + Bezier extrema, fixed-width wrap, numeric-string
weights, login.op + pencil-demo.op fixture loads.
- canvas_viewport_overlay.rs wrap_tests — 7 cases: ASCII / CJK /
CJK+Latin / explicit-newline / blank-line / weighted advances.
- icons.rs first_party_icon_font_names_all_resolve guards 27+
authored names against placeholder regressions.
File-cap discipline
- pen_doc_adapter.rs split into mod + path-bounds sibling + tests
sibling.
- icons.rs split into mod + icons_data.rs sibling so the catalogue
can grow without busting the cap.
- canvas_viewport_overlay.rs absorbs wrap_text + UniformBackend /
WeightedBackend test stubs.
Sub-modules
- vendor/jian advanced for `resolve_weight` numeric-string parsing.
Codex caught: `clipboard: Vec<Node>` survives Open with nodes
carrying NodeIds from the previous doc. Pasting them into the
freshly-loaded doc would re-introduce ids the new allocator
doesn't know about (or collide with freshly-loaded rows). Empty
the clipboard alongside the history + UI resets so paste in the
new doc starts from empty.
Codex caught: `apply_payload` only swapped pages + active page +
cleared selection — it left `history.past` / `history.future`
holding snapshots of the OLD doc, plus every UI slot that may
carry a `NodeId` from the old tree (pen_in_progress,
text_editing, layer_rename, color_picker target, property_focus,
agent_settings_drag, layer_context_menu). After Open the user
could Cmd+Z back into the previous doc, or a stale `pen_in_progress
= Some(NodeId)` from before the load would point at a non-existent
row on the next press.
Wipe history both directions and clear every NodeId-carrying UI
slot + drafts + open dropdowns so the loaded doc starts on a
clean slate.
Closes the largest TS parity gap (#1 / #2 in the audit): the
document was completely non-persistent — every restart lost the
canvas tree. Wire up native Save / Save As / Open through rfd
dialogs so the user picks the file path themselves (per the
audit conversation: "随意保存").
- new `crates/openpencil-desktop/src/persistence.rs`:
- `DocPayload` / `PagePayload` / `NodePayload` / `StrokePayload`
DTOs with serde derives (hand-rolled JSON shape so shell-core
stays serde-free; Color / Rect / Point2D come from external
crates that don't carry serde derives)
- `to_payload` / `apply_payload` + `kind_to_string` /
`str_to_kind` cover all 9 NodeKind variants including
NodeKind::Other(String)
- `save_as_dialog` / `open_dialog` use `rfd::FileDialog` with
a single combined "OpenPencil" filter covering both `.pen`
and `.op` extensions — both load via Open and save into
either at the user's choice
- `save_to_path` writes through a sibling `.tmp` + rename so a
mid-write crash never leaves a half-written document on disk
- `handle_save` / `handle_save_as` / `handle_open` package the
rfd + state flow + title refresh so the desktop key handler
stays a one-liner per shortcut
- new `WidgetHostNative::document()` / `document_mut()` accessors
— `pub(in crate::widget_host)` field stays internal otherwise
- `DesktopApp` gains `current_path: Option<PathBuf>`; window title
updates to `<filename> — OpenPencil` after Save / Open
- keyboard bindings: Cmd+S (save-in-place or fall through to Save
As when no path), Cmd+Shift+S (force Save As), Cmd+O (open
dialog). All three remain enabled when the settings modal is
focused; bypass the modal-focused editor-shortcut block because
they never type into the port field
- format spec: `{ version: 1, active_page_index: N, pages: [...] }`
— bump `CURRENT_VERSION` + add a migration branch in
`apply_payload` when the schema grows
apply_text already swallows non-digits while a settings input is
focused, but the desktop key handler dispatches editor shortcuts
(Cmd+D, Cmd+G, Cmd+Z, Cmd+A, arrow nudges, Delete, [ / ]) on
SEPARATE paths that bypass apply_text — so typing a `d` while
editing the port still duplicated the selected node, arrow keys
nudged it, etc.
- new `WidgetHostNative::settings_focus_active()` public helper
- `input_active()` already-private check also picks up the modal
focus so single-letter tool switches gate cleanly
- desktop key handler reads `settings_focus_active()` once per
event and stamps `&& !settings_focused` on every editor branch
(Delete, arrows, Cmd-letter combos, Cmd-Shift-letter combos,
bracket reorder). Cmd+, stays unguarded so the user can always
toggle the modal itself.
While the MCP port field was focused, only digits routed into the
draft and any other character fell through to the next handler —
so typing a letter would land in chat / rename / text-edit
(whichever happened to be active). Tighten both native + web
`apply_text`: while `agent_settings.focus.is_some()` swallow ALL
keys, accepting digits into the draft and returning false for
everything else.
The prior commit only added the FocusMcpPort *click* dispatch on
web — keyboard text/backspace/Enter/Escape routing was still
chat-only, so the port field would highlight on click but reject
every keystroke and never commit. Mirror the native path:
- `apply_text` / `apply_backspace` / `apply_send` / `apply_escape`
on the web `WidgetHost` route to `settings_input_draft` whenever
`agent_settings.focus` is set
- new `commit_settings_focus` helper parses the draft, clamps the
port to ≥1024, writes it back, clears focus + draft
- Close / Outside / SelectTab / FocusMcpPort transitions in the
web press dispatcher commit any pending draft before changing
state, so typed values aren't silently dropped
Native press dispatcher learned FocusMcpPort in the previous
commit but the web shell's parallel match was left without that
arm — a click on the port field on web would silently fall through
to no-op. Add the same focus + seed-draft flow on the web side
so cross-platform behaviour stays identical.
The port field on the MCP tab was display-only — clicking did
nothing and the user couldn't pick a different port. Wire it to a
proper focus / draft / commit cycle:
- new `SettingsFocus` enum (currently just `McpPort`; OAuth client
id/secret will follow) + `AgentSettings.focus: Option<SettingsFocus>`
- `UiState.settings_input_draft: String` holds the in-progress text
(lives on UiState because `AgentSettings` is `Copy`)
- McpHit::FocusPort hit on the port-field rect, dispatched from
the native press handler — seeds draft from current port value
- keyboard routing: digits-only via `apply_text` (cap 5 chars),
`apply_backspace` pops, `apply_send` commits, `apply_escape`
cancels (one Escape clears focus + draft, second closes modal)
- commit parses u16 and clamps to ≥1024 so the user can't pick a
root-only port
- modal Close / Outside / tab-switch press all commit any pending
draft before transitioning so a typed value isn't silently lost
- focused field gets a primary-tinted border + static caret bar
past the digits
`start_pen_path` was pushing the new Path node onto the page first
and only then calling `snapshot_for_history()` — the snapshot ended
up including the new node, so a subsequent undo restored "node
already present" and the path stayed on screen.
Take the snapshot before any mutation; finish_pen_path still
gates the push on `anchor_count >= 2` so single-anchor paths
don't pollute the undo stack.
restores the 3 reset commits (drag-into-container, layer right-click,
page menu) plus this session's new editor features + performance pass.
layer panel
- cross-parent drag-into-container reparenting with floating ghost
- right-click context menu on layer rows (rename / duplicate / delete /
group / ungroup / lock / hide)
- right-click context menu on page tabs (rename / duplicate / delete)
- split layer_panel.rs into spine + walkers + paint + tests under
the 800-line cap
editor features
- pen tool: NodeKind::Path multi-anchor polylines with rubber-band
preview tracking cursor doc-coords
- HSV color picker overlay (Cmd-Shift-C or fill/stroke swatch click):
sat/value box + hue strip + hex input, anchored HSV across
RGB-rounding cycles
- Property panel: Design / Code tab toggle (Cmd-Shift-C) — new
property_panel_code module
- corner radius: Node.corner_radius field + PropertyFocus::PositionR
wired through snapshot/commit/seed; canvas Rect paint switches to
fill_round_rect/stroke_round_rect when radius > 0.5 doc-px
brand icons
- new RenderBackend::fill_svg_path on both native (Canvas::draw_path
Fill paint) and web backends
- widgets/brand_icons.rs with Claude / OpenAI / Gemini / Copilot
filled-path glyphs (verbatim from apps/web/src/components/icons/*-
logo.tsx) + paint_opencode_logo terminal-frame primitive
- agent provider cards swap Lucide approximations for real brand
logos
settings modal (Cmd+,)
- 880×640 modal with sidebar nav (Agents / MCP / Images / System)
+ scrollable right pane + dim scrim
- Agents tab: 5 provider cards with brand logos, hover-to-reveal
"断开连接" on connected cards, "+ 添加服务商 / + 添加 Agent" actions
aligned + inset to clear close X
- MCP tab: server status card (running/stopped indicator + port +
start/stop button) + 2×3 toggle grid for terminal CLI integrations
- Images tab: Image Search status + collapsible Advanced (Openverse
OAuth client id/secret + Register link + Test) + Image Generation
section with empty-state hint
- System tab: read-only Auto-update status card (no real updater
backend yet — surfacing a togglable switch would lie to the user)
- new widgets/agent_settings_i18n.rs hand-maintained EN/ZH key
table (~50 keys); generated i18n/{en,zh_cn,...}.rs untouched
- AgentProvider.subtitle_key() drops hard-coded Chinese subtitles
+ drops the fabricated "fini.yang@gmail.com" account line
- web shell paints + dispatches the modal alongside native
- cursor over modal stays on Default pointer (no Move on sidebar
nav rows)
performance
- history VecDeque (O(1) pop_front, capped at 100) replaces O(n)
Vec::remove(0)
- redraw scheduler: track dirty flag + skip paint when cursor-move
produces no visible state change (kills first-click chip flicker)
- cursor-move coalescing: drain pending_cursor_move on
RedrawRequested + press/release/right-press
- viewport culling: off-screen leaf nodes skip paint with 64px margin
for stroke/handle overflow
- font cache prewarm: NativeBackend::new walks ~50 chrome CJK
codepoints through FontMgr::match_family_style_character at startup
→ first cross-tab paint stops stuttering
- i18n returns &'static str (PropertyLabels Copy struct, ~19
String allocs/frame removed)
- TopBar chip 12px/char + 16px-pad CJK-safe hit area
polish
- close X smaller (16×16) + section-action text right-inset to clear it
- Advanced chevron → lucide ChevronDown/Right (was Unicode v/>)
- Register link arrow → lucide ArrowUpRight (was Unicode ↗)
- INPUT_RADIUS 6→8 for more visible rounded inputs
- dot-status alignment on Image Search header (centred to status
text optical centre, not title baseline)
testing
- 167 tests passing across shell-core (136) + shell-native (21) +
document (6) + walkers (4)
- 800-line cap holds on every file
- native + web targets both build clean
Codex caught a real bug: the drop indicator painted at one row,
but on release the source could land at a different row. When
dragging downward past other rows, the indicator was at row N
but the source ended up at row N-1, because `commit_layer_drag`
calls `extract_node` first (shifting other rows up by one) and
THEN inserts at the anchor — but `paint`'s drop_target_at was
computing the indicator y in the PRE-extract layout.
Fix: while a drag is active, build the LayerPanel with the
dragged source's entire subtree excluded. This mirrors the
post-commit layout, so the indicator y the user sees and the
y the source lands at are by construction the same.
- New `LayerPanel::from_document_with_drag_source(doc, source)`
constructor; uses a new `walk_excluding` walker that skips
the source's subtree.
- Native + web paint paths call the new constructor when
`layer_drag.active`, computing drop_target against the
trimmed layout AND painting the trimmed row stack (so the
user sees the visual collapse mid-drag too).
- Native + web `commit_layer_drag` also build the trimmed
panel for `drop_target_at` lookup, so preview and commit
read from the same layout.
- New regression test `drop_indicator_matches_post_commit_
layout_when_dragging_down`: drags A from top, drops before
D, asserts `indicator_y` matches A's new row top in the
rebuilt panel exactly.
136 tests pass (was 135); cargo fmt + boundary check clean;
zero files over 800.
Codex flagged that `panel.capabilities()` assertions don't actually
prove `paint()` uses the result. Closed the loop with a real
paint-output test:
- Inline `CountingBackend` (RenderBackend impl) records draw_text
and fill_round_rect call counts.
- `multi_select_paint_diverges_from_full_section_paint` paints
a multi-select panel + a single-select Frame panel through it
and asserts the op counts differ. If `paint()` regressed to
bypass `capabilities()` and call `for_kind(Frame)` for the
multi case, the two paints would emit identical ops and the
test would fail.
Floor sanity: asserts the multi panel still emits ≥ 6 text ops
(headers + Position/Size labels) and ≥ 1 round-rect (Size section
W/H inputs). Without the multi carve-out the Size section was
hidden, so this directly catches the bug codex caught earlier.
135 tests pass; cargo fmt + boundary check clean.