Codex stop-time review on 23b0bfba flagged that real MCP clients
(Claude Code, Codex, etc.) won't dispatch tools/call cold — they
open with \`initialize\` and \`tools/list\` to discover the
server. The previous --mcp implementation responded only to
tools/call, so real clients hung at handshake.
Add in-binary handlers for the MCP control-plane methods:
- \`initialize\` → protocolVersion "2024-11-05" + capabilities
(tools.listChanged=false) + serverInfo
- \`notifications/initialized\` (and bare \`initialized\`) →
absorbed silently (spec: notifications have no response)
- \`tools/list\` → response with all 14 tools + their JSON
inputSchemas (name / description / required args / enums for
kind + drop_children)
- \`ping\` → empty result
- everything else falls through to shell-core's
run_stdio_with_applier so the parser hardening + apply
discipline still apply
Top-level method/id sniffing uses the same key-walker pattern as
shell-core's arguments_field, so a nested key called "method" or
a string value of "initialize" can't shadow the real top-level
method. Four sniff/response unit tests cover the discipline.
End-to-end roundtrip verified with real stdin/stdout:
initialize → notifications/initialized → tools/list → tools/call
insert_node → tools/call list_pages → all five frames return
correct JSON-RPC + the inserted rect persists to disk.
Closes BLOCKER #1 from the 2026-05-14 gap report: the Rust
shell's MCP ToolRegistry now has a host. \`openpencil-desktop
--mcp <path>\` skips the winit event loop and runs a JSON-RPC
stdio server backed by that .op file. External CLIs (Claude
Code / Codex / Gemini / Copilot) can spawn the binary in this
mode the same way they spawn TS pen-mcp today.
Implementation (\`crates/openpencil-desktop/src/mcp_serve.rs\`,
~95 lines):
- Loads the .op file via the existing canonical persistence path
(made \`load_from_path\` pub).
- Re-builds the ToolRegistry against the live document between
every dispatched call so read-tool snapshots reflect the
latest state (write commands mutate the doc; reused registry
would have stale snapshots).
- Dispatches one JSON-RPC line at a time through
shell-core's run_stdio_with_applier so the parser-level
hardening (structured-arg rejection / top-level walker / no-
hang error path) protects this binary too.
- Apply closure mutates the doc + saves to the same path on
every successful write command. Save failures surface to
stderr but don't crash the loop.
Smoke test passed: list_pages → insert_node → list_pages
roundtrip through real stdin/stdout returns three JSON-RPC
responses, and the inserted rect persists to disk (verified
by grepping FromMCP out of the saved file).
361 shell-core tests still pass; workspace build clean.
Adds a new "MCP server (shell-core)" section to
crates/CLAUDE.md covering:
- The 14-tool catalog (6 read + 8 write) with arg shapes and
emitted McpCommand variants per tool.
- Pre-validate-then-mutate discipline at the apply layer.
- `replace_node`'s `drop_children` destructive-swap guard.
- Wire-format stop-gates (structured-arg rejection, top-level
`arguments` walker, no-hang on parse failure, read-only path
refusing write tools).
- mcp/ file layout including the sibling test files for
copy_node and replace_node.
- Pending: host wiring, batch_design / design_skeleton tools,
HttpServer transport.
Codex stop-time review on 4ff2b87f flagged that the stricter
parser created a new failure mode: when parse_tool_call returns
None (structured args, unparseable JSON, etc.), run_stdio just
\`continue\`s — JSON-RPC clients waiting on a response correlated
by id never get one and hang.
Fix:
- Expose \`parser::extract_request_id\` so the dispatcher can
recover the id when the full parse fails. Helper does only
what parse_tool_call's first three lines used to do; parse_tool_call
now calls it internally so there's a single source of truth.
- In \`run_stdio_with_applier\`, on \`parse_tool_call → None\`,
attempt to extract the id; if found, write a typed error
response (\`InvalidArgument\` + "malformed tool call:
unparseable or structured arguments"). If even the id is
missing, drop the line silently — there's nothing to
correlate against.
- Drop the now-dead \`extract_object_body\` helper (\`arguments_field\`
replaced it in the previous commit).
Tests:
- \`run_stdio_emits_error_when_parse_fails_so_clients_dont_hang\`
— structured \`drop_children\` arrives with valid id 42;
response echoes id 42 + carries an \`error\` field naming
"malformed tool call".
- \`run_stdio_skips_lines_without_an_id\` — id-less lines stay
silent (existing behavior, explicitly covered now).
361 shell-core tests pass (was 359).
Codex BLOCKed ee0671a7 on the substring-find inside
arguments_field: `body.find("\"arguments\"")` matched any
nested key or string value containing the literal `"arguments"`,
not just the top-level params.arguments field. Two valid-but-
unusual JSON shapes bypassed the rejection contract:
1. `meta:{"arguments":{}}` alongside `"arguments":"oops"` — the
substring scan hit meta.arguments as a Body and parsed it,
skipping the real top-level string-typed arguments which
should have rejected.
2. `name:"arguments"` with no real arguments field — false
positive Malformed instead of Missing.
Replace with a proper top-level key walker that:
- skips whitespace + commas between top-level pairs;
- reads each quoted key + its value, classifying the value
(string / object / array / number-bool-null);
- only treats `arguments` as a real hit when seen at depth 0;
- categorizes the matched value: object → Body, anything else
→ Malformed; missing entirely → Missing.
Three new regression tests in `mcp_tests.rs::
parse_tool_call_arguments_lookup_is_top_level_only` cover the
nested-shadow, string-value-collision, and deep-nested-key
cases.
359 shell-core tests pass.
Codex BLOCKed 0a434e6b on three items:
1. MCP arguments non-object downgrade — \`"arguments":"oops"\`
used to flow through extract_object_body returning None and
parse_tool_call downgrading to empty args. Replace the call
site with a tri-state \`arguments_field\` helper:
- Missing → empty args (legit MCP can omit \`arguments\`).
- Body(s) → parse the object body.
- Malformed → reject the parse.
2. Test coverage gap — the structured-rejection tests only
exercised the legacy/direct line-protocol shape. Add two new
tests under mcp_tests:
- parse_tool_call_rejects_structured_values_in_mcp_tools_call_shape:
nested object + array inside MCP \`arguments\` both reject.
- parse_tool_call_rejects_non_object_arguments_field: string /
number / array \`arguments\` reject; missing \`arguments\`
still parses as empty args.
3. Stale doc comment on parse_flat_object_body — said nested
values are "skipped", now correctly describes the wire-layer
rejection contract.
358 shell-core tests pass (was 356 — two new MCP tools/call
shape tests).
Codex re-review on c6e6a308 flagged the sentinel approach's
collision risk: a variable literally named "{...}" — or any
string-accepting arg with that value — was indistinguishable
from wire-malformed input. Move the rejection up to the wire
layer so no tool sees a structured value at all.
- `parse_flat_object_body` now returns `None` the moment it
sees `{` or `[` for any value (was: insert a sentinel).
- `parse_tool_call` previously masked None with
`.unwrap_or_default()`, which would have silently swallowed
the rejection back into empty args. Replace
`extract_params_object` with a tri-state `ParamsResult`
(Missing / Body / Malformed) so we can distinguish "no params
key" (legit empty args) from "params present but malformed"
(full call rejection). MCP `tools/call` path treats a missing
`arguments` key the same way (empty args), and propagates a
`None` from the inner parse as a full call rejection.
Tests:
- `parse_tool_call_rejects_structured_arg_values` (in
`mcp_tests.rs`) asserts both object and array values cause
`parse_tool_call` to return None, while scalar-only still
parses.
- `parser_refuses_structured_drop_children_at_wire_layer` (in
`mcp/replace_node_tests.rs`) end-to-end: real MCP JSON with
structured `drop_children` and structured `name` never reach
the tool layer.
- `replace_node_rejects_malformed_drop_children` drops the
obsolete sentinel cases — they can no longer reach the tool.
356 shell-core tests pass.
Codex re-review on fd0af355 walked one level deeper into the
parser and found that ANY tool's safe-default-on-missing logic
could be bypassed by sending the arg as a JSON object or array.
Example: `{"drop_children": {}}` arrived at ReplaceNode as
absent → defaulted to false → silent acceptance.
Fix at the parser layer so every write tool benefits at once:
- `parse_flat_object_body` (parser.rs:287) now inserts `"{...}"`
or `"[...]"` as a sentinel value for the key when it walks
past a nested literal, instead of dropping the key.
- Every existing tool's scalar validation (hex regex, i32 parse,
enum match, bool match) naturally rejects the sentinel as an
InvalidArgument, so no per-tool wiring is needed.
Tests:
- `parse_tool_call_skips_nested_object_values` rewritten to
`_surfaces_nested_values_as_sentinels` — asserts the present-
with-sentinel contract for both `{}` and `[]`.
- `parser_to_tool_chain_rejects_structured_drop_children` (new
in `mcp/replace_node_tests.rs`) — end-to-end: real MCP JSON
with structured `drop_children` goes through the parser into
ReplaceNode::call and lands on `InvalidArgument`. Sweeps both
object and array shapes.
- `replace_node_rejects_malformed_drop_children` now also sweeps
the sentinels themselves as malformed strings.
356 shell-core tests pass (one obsolete test replaced + one
end-to-end test added).
Codex re-review on 2f0200b0 flagged that drop_children="" was
silently treated as false, hiding a possible mis-serialized
confirmation from the caller. Drop the empty-string special
case — only "true" / "false" / missing are now accepted; anything
else returns InvalidArgument.
Test broadened: the malformed-string case now sweeps "yes" / ""
/ "TRUE" / "1" / "0" so every common mis-spelling is covered.
Codex stop-time review on replace_node flagged that a doc-comment
warning isn't enough — the tool would still silently delete a
container's subtree if the LLM picked the wrong target. Promote
the safety from advisory to enforced:
- Thread `drop_children: bool` through `McpCommand::ReplaceNode`.
- Apply path: if the target has children AND `drop_children` is
false, refuse the swap before allocating an id or mutating
pages. Container subtrees are preserved by default.
- Tool side: parse optional `drop_children` arg ("true"/"false");
default false; malformed string returns `InvalidArgument` with
a message naming the arg.
Tests cover the four reachable shapes: leaf swap with default
`drop_children=false` still works (pre-existing behavior); a
container swap without consent refuses; the same swap with
`drop_children=true` succeeds; a malformed `drop_children` arg
fails parsing.
355 shell-core tests pass (was 352 — three new guard tests).
Codex review on e51b979f flagged that the replace_node doc
comment didn't mention the destructive behavior on containers.
Add an explicit "Destructive on containers" note to both the
McpCommand variant and the tool struct, pointing readers at
update_node for in-place patches.
No behavior change.
Eighth write tool in the catalog. Required args: node_id, kind,
name, x, y, width, height. Optional: fill_hex. Builds a fresh
node with a non-colliding id and swaps it into the same parent
slot the target node currently occupies, preserving sibling
order.
Bounded scope: only leaf-style fields land on the replacement.
Full subtree replacement requires a JSON Node parser that
doesn't live on this side yet. The current contract matches TS
`replace_node` for primitives, minus children.
Apply path follows the same pre-validate-then-mutate discipline
as `update_node` and `move_node`: kind / geometry / fill_hex /
target existence / id space — every check before any mutation.
A bad fill_hex never leaves the document half-touched (covered
by `apply_mcp_command_replace_node_atomic_on_invalid_fill_hex`).
Tests live in `mcp/replace_node_tests.rs` (matches the
`copy_node_tests.rs` sibling pattern; keeps `write_tools_tests.rs`
under cap).
Codex review on 100eb78a flagged that the McpCommand::CopyNode
doc comment promised a `new_root_id` field that the tool never
emits. The tool returns `{"wrote": "true"}` like every other
write command because `ToolOutcome` is built before apply runs —
the allocator that mints fresh ids is reachable only from the
host applier, not the tool.
Rewrite the doc comment to describe the actual contract instead
of a feature that's wishful. Threading the allocator back to the
tool to surface real clone ids is a future patch.
Seventh write tool in the catalog. Validates node_id +
target_parent_id args, then issues `McpCommand::CopyNode` which
the host applies via `Document::apply_mcp_command`. The applier
walks the source subtree, allocates fresh ids past `max_node_id()`
for every cloned node, and attaches the clone under the target
(page root when `target_parent_id == 0`).
Allows `node_id == target_parent_id` so an LLM can duplicate a
container's contents under itself (valid copy semantics; differs
from move_node which rejects that case).
Tests live in a new `mcp/copy_node_tests.rs` sibling — bundling
them into `write_tools_tests.rs` would have pushed it past the
800-line cap.
Codex stop-gate caught a real correctness bug: `move_node`'s
apply path was:
1. validate source exists + cycle check
2. detach the source (vec.remove → owned Node)
3. find_node_mut_in_doc(target) — if None, return false
When target_parent_id was unknown, step 2 had already removed
the source from its parent. The owned Node fell off the end of
the function and was DROPPED — silently destroying the source.
The existing unknown-id test only covered the path where the
old `find_node_in_doc` on the cycle check incidentally caught
it; an LLM passing a bogus target_parent_id would lose data.
Fix: every validation (source exists, target exists, cycle
guard, page-root path's active page exists) runs upfront. Only
after all checks pass does the detach + reattach proceed. The
two halves of the mutation are no longer separable from each
other — match-statement style atomicity, no orphan path.
Test (1 added, 343 shell-core total):
- `apply_mcp_command_move_node_preserves_source_when_target_unknown`
builds the exact codex scenario: MoveNode { node_id: 11
(sample Title), target_parent_id: 99999 (doesn't exist) }.
Asserts:
1. apply returns false (no successful move),
2. Frame's child count is unchanged (Title still there),
3. Title is still in Frame's children vec,
4. Title appears EXACTLY ONCE in the doc (the dropped-
source bug would have left zero occurrences).
Pre-fix assertion #4 would have failed with count = 0.
The same pre-validate-then-mutate pattern is now consistent
across update_node (atomic on invalid geometry, 9e601573),
insert_node (id-space-exhausted guard, 62a2fb5b), and
move_node (this fix). DeleteNode is naturally atomic — single
remove_in_subtree call.
Sixth MCP write tool, completing the node-lifecycle quartet
(insert + update + delete + move). LLMs can now reparent nodes
across the document tree — from page root to a group, between
groups, or back to the root.
`McpCommand::MoveNode { node_id, target_parent_id }`:
- `target_parent_id == 0` reparents to the active page root.
Non-zero ids must resolve to an existing node.
- Cycle guard at apply time: if target_parent is a descendant
of source, the move would orphan + cycle the subtree, so
apply returns false.
- Same-id reparent (node_id == target_parent_id) is rejected
at the tool layer with InvalidArgument.
`src/document/mcp_apply.rs`:
- MoveNode branch on Document::apply_mcp_command. Runs the
cycle guard via `find_node_in_doc` + `subtree_contains`
BEFORE detaching, so a rejected cycle leaves state intact.
- `detach_node(doc, id)` + `detach_from_subtree(vec, id)`
walkers — locate the node, `vec.remove(idx)` it, return
the owned Node. Reattach via push to the new parent's
children.
- `find_node_in_doc(doc, id)` + `find_in_subtree_ref(slice,
id)` — immutable variants for the cycle check (can't share
a mutable borrow with the subsequent detach).
- `subtree_contains(node, target) -> bool` — recursive scan
of a node's id + descendants.
`src/document/variables.rs`:
- VariableTable::apply_mcp_command's "Pages-level commands not
mine" arm grew MoveNode to keep the exhaustive match.
`src/mcp/write_tools.rs`:
- `MoveNode` (stateless) + `move_node_snapshot()` factory.
Validates both node_id (positive u64) + target_parent_id
(any u64, 0 ⇒ page root) and rejects same-id pair.
Tests (6 added, 342 shell-core total):
- move_node_validates_args — missing both / missing target /
node_id=0 / node_id == target → InvalidArgument.
- move_node_returns_command_with_zero_target_for_page_root —
target_parent_id=0 carries through unchanged.
- apply_mcp_command_move_node_reparents_to_page_root — sample
doc; Title(11) under Frame(10); after move target_parent_id=0
Title is at page root + Frame no longer carries it.
- apply_mcp_command_move_node_reparents_to_another_node —
Title(11) → Button group(12); Title leaves Frame, lives
under Button.
- apply_mcp_command_move_node_rejects_cycle — Frame(10) ↓
Button(12) → MoveNode { node_id: 10, target_parent_id: 12 }
would cycle. Apply returns false + tree is structurally
intact (Frame at root, Button as child).
- apply_mcp_command_move_node_rejects_unknown_id — both
unknown-source and unknown-target branches.
MCP write surface: set_variable_color, set_active_axis_value,
insert_node, update_node, delete_node, move_node. The remaining
TS pen-mcp catalog: copy_node, replace, batch_design,
design_skeleton.
Codex stop-gate caught a real correctness bug: `update_node`'s
apply path was mutating bounds.origin.x and bounds.origin.y BEFORE
checking width / height for negative values. A request like:
UpdateNode { x: Some(999), y: Some(999), width: Some(-1), ... }
would move the node to (999, 999) AND then reject the resize,
leaving the node half-updated. Worse, in the wire-level flow the
client receives `host rejected command` (Internal demotion), so
they think nothing changed — but actually the move did apply.
Fix: run ALL field validation BEFORE the mutable borrow + writes.
The negative-width / negative-height checks run upfront alongside
the existing fill_hex parse. After the validation block, no
early-return is possible, so every set of writes either applies
in full or doesn't apply at all.
Tests (2 added, 336 shell-core total):
- `apply_mcp_command_update_node_is_atomic_on_invalid_geometry`
— sample doc's Title (id 11) bounds snapshotted; UpdateNode
with valid x/y/name + negative width. Asserts apply returns
false AND every field matches pre-call state. Pre-fix the
test would have observed x=999, y=999, and the rename.
- `apply_mcp_command_update_node_is_atomic_on_invalid_hex` —
parallel atomicity for the fill_hex path; valid x/y/name +
bad hex. Same pre/post bounds + name assertion.
The same atomicity invariant already held for InsertNode (single
mutation point at the end of the branch) and DeleteNode (atomic
by definition — single `retain` call).
Two more MCP write tools, mirroring the TS pen-mcp catalog's most-
common mutations beyond insert_node. Both follow the established
write architecture: tool validates args + returns `OkWithCommand`;
host applier mutates the live document; stdio applier path demotes
to Internal on apply-time rejection.
`McpCommand` (in `src/mcp.rs`):
- `UpdateNode { node_id, x, y, width, height, name, fill_hex }`.
Every patch field is Option<…>; None leaves the live value
unchanged. Bounds writes replace coords piecemeal so a caller
can move (x, y) without resizing or vice versa.
- `DeleteNode { node_id }`. Removes the node + all its
descendants from its parent.
`src/document.rs`:
- `NodeId::new_opt(u64) -> Option<Self>`. Non-panicking sibling
of `new()` — returns None for id 0 (NONE sentinel). Used by
the write tools to validate arbitrary wire-supplied ids
without panicking.
`src/document/mcp_apply.rs`:
- Document::apply_mcp_command branches grow UpdateNode +
DeleteNode. UpdateNode pre-validates the optional fill_hex
BEFORE the mutable borrow on pages so a bad hex doesn't
partially mutate the node. Width / height patches reject
negative values.
- `find_node_mut_in_doc(doc, id)` + `find_in_subtree(slice, id)`
+ `remove_in_subtree(vec, id)` recursion helpers that walk
every page's tree. find_in_subtree uses the "position-then-
index" split so the borrow checker doesn't see overlapping
iter_mut ranges.
`src/document/variables.rs`:
- VariableTable::apply_mcp_command's UpdateNode + DeleteNode
arms return false (Pages-level commands aren't theirs to
apply), preserving the exhaustive match.
`src/mcp/write_tools.rs` (NEW, 437 lines):
- All MCP write tools moved out of `tools.rs` (which would
have grown past the 800-line cap): SetVariableColor,
SetActiveAxisValue, InsertNode, UpdateNode, DeleteNode +
their factories + shared parse helpers (validate_hex,
parse_i32_arg, parse_opt_i32, ALLOWED_KINDS).
- tools.rs now holds only read tools (510 lines, under cap).
- get_active_theme_snapshot stays in tools.rs (read-side
factory for the GetActiveTheme tool which is also a read
tool).
`src/mcp/write_tools_tests.rs` (NEW, 532 lines):
- All write-tool tests moved out of tools_tests.rs (which was
900 lines after the write-tool tests were appended). Both
test files now under cap.
- mcp.rs registers `#[cfg(test)] mod write_tools_tests;`
alongside its sibling tools_tests.
`src/mcp.rs`:
- Re-export surface split into read-side (from tools::) and
write-side (from write_tools::) so the public API stays
flat (`mcp::SetVariableColor`, `mcp::UpdateNode`, etc.).
Tests (10 added, 334 shell-core total):
- update_node tools: required-node-id, id-format validation
(must be positive u64), empty-patch error, partial-patch
happy path, apply routes to the right node + leaves
unspecified fields untouched, apply rejects unknown id.
- delete_node tools: required-arg + id-format validation;
apply removes the node from its parent + descendants;
apply rejects unknown id.
MCP write surface now: set_variable_color, set_active_axis_value,
insert_node, update_node, delete_node. Remaining TS pen-mcp
catalog: move_node, copy_node, replace, batch_design,
design_skeleton.
Codex stop-gate caught: `next_node_id_seed` used
`max_node_id().saturating_add(1)`. When a live node sits at
`u64::MAX`, saturating_add wraps back to u64::MAX — so the
allocator would return the SAME id as the existing node and the
push would create a duplicate NodeId. Document::find would then
return whichever was first, silently masking or partially
mutating the original.
Fix: switch to `checked_add(1)`. The seed now returns `Option<u64>`
— `None` when the id space is exhausted. `Document::apply_mcp_
command`'s InsertNode branch surfaces the None as `false` (apply
failure), and the run_stdio_with_applier path already demotes
that to `ToolErrorCode::Internal` so the client sees a clear
"host rejected command" rather than a fake success that doesn't
actually create anything new.
Test (1 added, 326 shell-core total):
- `apply_mcp_command_insert_rejects_when_id_space_exhausted` —
plants a live node at `u64::MAX`, attempts InsertNode, asserts
apply returns `false` AND `pages[0].children` length is
unchanged AND the live u64::MAX node's name is preserved
(so we know the duplicate id didn't accidentally overwrite
it). Pre-fix this test would have passed `apply` returning
`true` while corrupting state.
The bound is comfortable in practice — a document would need 2^64
live nodes to hit it. The fix is defensive correctness, not a
performance concern.
Third MCP write tool, completing the "create + style + theme" write
surface for LLM clients. `insert_node` is the TS pen-mcp
equivalent's flagship capability — without it, LLMs can only
modify existing nodes; with it, they can build entire designs.
`crates/openpencil-shell-core/src/mcp.rs`:
- `McpCommand::InsertNode { kind, name, x, y, width, height,
fill_hex }`. fill_hex is `Option<String>` — color-bearing
shapes can carry a fill; structural nodes (frame/group)
pass None.
`crates/openpencil-shell-core/src/document/mcp_apply.rs` (NEW):
- `Document::apply_mcp_command(cmd)` — lifted to Document level
so InsertNode can reach Pages + the id allocator (variable +
theme commands still route to `var_table.apply_mcp_command`).
- `Document::next_node_id_seed()` — allocates a fresh id past
`max_node_id() + 1`, saturating_add-guarded so u64::MAX
returns 1 instead of colliding with NodeId::NONE.
- `parse_node_kind(s)` — accepts the same lowercase strings
the read-side tools (get_node, get_selection) emit, so an
LLM can round-trip a node's kind through read → modify →
re-insert without re-encoding.
- Pulled out of mutators.rs to keep that file under 800 lines.
`crates/openpencil-shell-core/src/mcp/tools.rs`:
- `InsertNode` (stateless tool struct) + `insert_node_snapshot()`
factory. No document snapshot needed — the tool doesn't need
to know the current state; the host's applier handles id
allocation + bounds installation.
- `McpTool::call` validates:
- All required args present (kind / name / x / y / width /
height); each `MissingArgument` carries the missing
name.
- kind in ALLOWED_KINDS (frame / group / rect / ellipse /
polygon / line / text / path).
- x / y / width / height parse as decimal i32 (negative x/y
allowed for nodes placed off the page-origin; width /
height must be non-negative).
- Optional fill_hex parses as #rgb / #rrggbb / #rrggbbaa.
- Returns `OkWithCommand(InsertNode)` on success.
Tests (6 added, 325 shell-core total):
- insert_node_validates_required_args — missing kind →
MissingArgument; invalid kind → InvalidArgument.
- insert_node_validates_numeric_args — non-numeric x →
InvalidArgument; negative width → InvalidArgument.
- insert_node_validates_optional_fill_hex — bad hex →
InvalidArgument.
- insert_node_returns_command_with_parsed_args — happy path;
every field round-trips into the McpCommand payload.
- apply_mcp_command_routes_insert_node — end-to-end: doc.empty()
→ apply InsertNode → new node lives on active page, bounds +
fill flow through, name matches.
- apply_mcp_command_rejects_invalid_node_kind — bad kind at
apply time → false (defensive re-check beyond the tool's
validation, so host-only call sites are safe too).
mutators.rs trimmed from 814 → 798 (apply_mcp_command extracted +
some redundant doc comments compacted). All OP-owned files now
under the 800 cap except 3 pre-existing tech-debt violators
(codegen.rs 867, widget_host/press.rs 840,
widgets/canvas_viewport.rs 839).
MCP write surface now: 3 first-party writes (set_variable_color,
set_active_axis_value, insert_node). The TS pen-mcp catalog still
needs: update_node, delete_node, move_node, copy_node, replace,
batch_design, design_skeleton — each extends McpCommand + the
same applier pattern.
Second MCP write tool, completing the variable + theme write
surface. Mirrors `cycle_active_axis_value` (which the
VariablesPanel chip click drives) but PINS the value rather than
cycles — LLM clients use it when they want to land on a specific
axis state ("switch to dark mode") instead of stepping through
options ("flip to whatever's next").
`crates/openpencil-shell-core/src/mcp/tools.rs`:
- `SetActiveAxisValue { axes: BTreeMap<String, Vec<String>> }`
snapshot. Mirrors `var_table.themes` keyed by axis name.
- `set_active_axis_value_snapshot(doc)` factory.
- `McpTool::call` validates:
- Both `axis` and `value` args present
(`MissingArgument` on omission).
- Axis exists in the themes table (`ToolFailed`).
- Value is in `themes[axis].values` (`InvalidArgument`,
error message lists allowed values).
- Returns `OkWithCommand(SetActiveAxisValue { axis, value })`.
The McpCommand variant has existed since the write arch
landed (0f09671a); `Document::apply_mcp_command` already
routes it to `var_table.active_theme.insert`.
- Re-validates at apply time so a stale snapshot can't slip
an unauthorized value through (host applier rejects with
`false`, which the stdio path demotes to Internal).
Tests (5 added, 319 shell-core total):
- `set_active_axis_value_validates_args_and_returns_command`
— happy path; OkWithCommand variant + correct payload.
- `set_active_axis_value_errors_on_missing_args` — both args
required, message names the missing one.
- `set_active_axis_value_errors_on_unknown_axis` — ToolFailed
+ error names the axis.
- `set_active_axis_value_errors_on_value_not_in_axis` —
InvalidArgument; error message lists every allowed value.
- `apply_mcp_command_routes_set_active_axis_value` —
end-to-end happy path + the stale-state rejection branch
(invalid value at apply time returns `false`).
MCP write tool surface now:
- set_variable_color (Color hex through var_table)
- set_active_axis_value (theme axis through active_theme map)
- insert_node, update_node_position, etc. land later — each
extends `McpCommand` + the existing applier pattern.
Codex stop-gate caught: the write architecture commits (0f09671a +
63387f3f) pushed mcp.rs from 663 → 904 lines and tools.rs from 419
→ 1088. Both well over the 800 ceiling.
Split tests to sibling files, mirroring the
`layer_panel_tests` / `property_panel_tests` / `variables_tests`
pattern already used elsewhere in shell-core.
src/mcp.rs 904 → 347 (impl spine only)
src/mcp_tests.rs (new, 560)
src/mcp/tools.rs 1088 → 610 (impl spine only)
src/mcp/tools_tests.rs (new, 479)
`src/lib.rs` gains `#[cfg(test)] mod mcp_tests;` next to
`pub mod mcp;` (matching the inline form for tests_geometry /
tests_mutators).
`src/mcp.rs` gains `#[cfg(test)] mod tools_tests;` next to
`pub mod tools;` (one-line form keeps mcp.rs under cap).
Visibility lift: `escape_record_field` bumped from private to
`pub(crate)` so the sibling tests file can drive the
list_variables backward-compat assertions.
No behavior change — every test moved verbatim. 314 shell-core
tests still pass. All OP-owned files under 800 lines except the
3 long-standing pre-existing violators (codegen.rs 867,
widget_host/press.rs 840, widgets/canvas_viewport.rs 839) which
are tracked separately and not touched by this session's edits.
Codex stop-gate caught: the previous commit (0f09671a) shipped the
`OkWithCommand` write path but `run_stdio` would dispatch a write
tool, get back `ToolResponse::Ok { command: Some(_), .. }`, and
write `result:{wrote:true}` to the wire WITHOUT applying the
command. Clients saw success for a mutation that never happened.
Split run_stdio into a read-only path + a write-aware path:
`run_stdio(registry, reader, writer)` — read-only. Calls the new
applier-aware variant with an applier that always returns false,
so any `OkWithCommand` is demoted to `ToolErrorCode::Internal`
("host rejected command: ..."). Clients can't see misleading
success on this path.
`run_stdio_with_applier(registry, reader, writer, F)` — accepts
`F: FnMut(&McpCommand) -> bool`. For each dispatched ToolCall:
- read tools (no command) → response written verbatim.
- write tools, applier returns true → response written as
success (the host has applied + the client gets the tool's
`result` payload).
- write tools, applier returns false → demoted to
`Internal` with `host rejected command: <Debug>` so the
client knows the mutation didn't land.
The real `openpencil-mcp` binary wires this with `Document::
apply_mcp_command` as the closure — same as the existing
applier signature `VariableTable::apply_mcp_command(&cmd) -> bool`.
Tests (3 added, 314 shell-core total):
- `run_stdio_demotes_write_tool_response_to_error_without_applier`
— the codex repro: read-only stdio with a registered write
tool. Sends a valid set_variable_color request; asserts the
wire output carries `code: -32603` (Internal) + the "host
rejected command" sentinel.
- `run_stdio_with_applier_applies_write_command_then_writes_success`
— applier returns true; the closure receives the command
exactly once (verified by collecting into a Vec); the wire
response is a clean Ok with no error code.
- `run_stdio_with_applier_demotes_when_applier_rejects` —
applier returns false (simulates host state drift); response
demotes to Internal so the client knows the write didn't
land.
The MCP write architecture is now end-to-end safe: tools validate,
the registry surfaces commands, stdio applies them through a
host-supplied closure or refuses to claim success.
Closes the architectural gap for MCP write tools that's been
deferred across the session. The McpTool trait stays `&self` (so
trait-object registry + Send + Sync bounds work cleanly), but
ToolOutcome grows a third variant that lets validate-only tools
describe a mutation the host applies later.
`crates/openpencil-shell-core/src/mcp.rs`:
- `ToolOutcome::OkWithCommand(BTreeMap, McpCommand)` — write
tools return this from `call`. The registry's dispatch lifts
the command into `ToolResponse::Ok { id, result, command:
Some(...) }` so the caller doesn't have to re-walk the tool
list to learn what was queued.
- `McpCommand` enum — typed mutation requests. v1 variants:
SetVariableColor { name, hex }
SetActiveAxisValue { axis, value }
Each maps to an existing Document mutator (set_color_hex /
set_active_theme) so the correctness chain (subset matching,
no-default-clobber, no-other-axis-shadow, var_table in the
history snapshot) carries forward verbatim.
- `ToolResponse::Ok` grew an optional `command: Option<
McpCommand>` field. Pre-existing read tools fill `None`.
- `response_to_json` ignores `command` when serialising
(host-only consumption — wire format unchanged).
`crates/openpencil-shell-core/src/mcp/tools.rs`:
- `SetVariableColor` tool. Validates `name` exists + is
Color-kind (snapshot from var_table) + `hex` parses as
`#rgb` / `#rrggbb` / `#rrggbbaa`. Returns
`OkWithCommand({wrote: true}, SetVariableColor)`.
- `set_variable_color_snapshot(doc)` factory — same pattern
as the read tools, snapshotted at host registration time.
- `validate_hex` lenient on case, requires leading `#`.
`crates/openpencil-shell-core/src/document/variables.rs`:
- `VariableTable::apply_mcp_command(&cmd)` — the host applier.
Branches per command variant, delegates to the underlying
mutator. Returns true when the table actually changed (caller
pushes an undo snapshot via the existing history machinery).
- `SetActiveAxisValue` rejects values not in `theme_axis.values`
so an LLM can't drift the active map into invalid states.
Tests (5 added, 311 shell-core total):
- set_variable_color_validates_args_and_returns_command — the
happy path; OkWithCommand variant + correct McpCommand
payload.
- set_variable_color_errors_on_missing_args — both `name`
AND `hex` are required.
- set_variable_color_errors_on_unknown_variable — ToolFailed +
error message names the missing variable.
- set_variable_color_errors_on_invalid_hex — fuzz across 4 bad
inputs (no `#`, too short, bogus chars), all → InvalidArgument.
- apply_mcp_command_routes_set_variable_color_to_var_table —
end-to-end: build command → apply → resolve_color reads back
the new value.
MCP surface now: 6 read tools + 1 write tool + architectural
support for arbitrary future writes. `insert_node`,
`update_node_position`, and the rest of the TS pen-mcp write
catalog plug into the same `McpCommand` enum + `apply_mcp_command`
applier.
Codex stop-gate (third pass on the comma-escape work): the
previous commit (525acbb6) used `escape_layered_field` for BOTH
output fields of get_active_theme, but `axes` is structurally a
2-level format (`;`-records of `|`-pairs) — exactly like
list_variables. Standard `unescape_record_field` decoders strip
only `\\` / `\;` / `\|`, so any `\,` we emit shows up as a
literal 2-char `\,` sequence in the client's decoded output.
Now:
`axes` — `escape_record_field` (3-char set: `\;|`)
↳ wire-compatible with list_variables decoders.
`options` — `escape_layered_field` (4-char set: `\;|,`)
↳ the only field that needs comma protection,
because the inner value list joins with `,`.
The two encodings cohabit cleanly: a client written for
list_variables can decode `axes` without changes; a client that
wants `options` knows to expect the deeper escape set + uses the
layered_split helper.
Test (1 added, 306 shell-core total):
- `get_active_theme_axes_field_does_not_escape_commas` —
active theme with axis name + value both containing commas
(`"axis,with,commas"` = `"value,with,commas"`). Asserts the
`axes` output substring is exact, AND that no `\,` sequence
appears anywhere in the field.
Existing tests still pass:
- `get_active_theme_round_trips_comma_in_value` (options
field; commas escape correctly via the layered set).
- `list_variables_does_not_escape_commas_backward_compat`
(records-and-pairs format unaffected).
Codex stop-gate caught: the previous fix (85579365) added `,` to
the shared `escape_record_field`, which changed the wire output of
`list_variables` for variables whose VALUE contains a comma. Any
existing client decoding `list_variables` with the original two-
delimiter unescape rules (`\;` + `\|`) would see literal commas
turn into `\,` sequences — backward-incompatible.
Split escape concerns into two helpers:
escape_record_field — `\;|` only. Used by list_variables
(sticks to the v1 wire contract).
escape_layered_field — `\;|,`. Used by get_active_theme's
three-level format (`;`-records of
`|`-pairs with inner `,`-joined value
lists).
unescape_record_field still only unescapes `\\`, `\;`, `\|` — its
contract is the legacy Rust-side counterpart for list_variables
decoders. Each layer of get_active_theme decoding uses the
`layered_split(s, delim)` helper which unescapes only the
current delimiter and passes other escape sequences through to
the next split level (the test fixture already encodes this
contract).
Tests (1 added, 305 shell-core total):
- `list_variables_does_not_escape_commas_backward_compat` —
encodes a variable whose value is "red, white, blue" and
asserts the output substring is preserved verbatim (no
`\,` sequences). Pre-this-commit the shared escape would
have output `red\, white\, blue`.
`get_active_theme_round_trips_comma_in_value` still passes —
its values get the layered escape via `escape_layered_field`,
and the layered_split helper decodes correctly.
Backslash-in-theme-value remains a known limitation of the
layered escape: each split only unescapes its own delimiter, so
intermediate `\\` sequences pile up across levels. The current
test scope (commas without backslashes) covers the codex BLOCK
case; the backslash edge case is rare in real theme value lists
and would need a final-pass unescape on the innermost split's
output. Tracked as a TODO in `escape_layered_field`'s doc
comment for when a real workload hits it.
Codex stop-gate: `get_active_theme.options` joins per-axis value
lists with `,` but theme values can legitimately contain commas
(e.g. "red, white, and blue"). The previous escape set (`\;|`)
left `,` unescaped, so the comma joiner would mis-split a single
value into multiple fake ones on decode.
`escape_record_field` + `unescape_record_field` extended to also
escape `,` (and accept `\,` on decode). The Rust-side helpers
stay backward-compatible because no existing encoder previously
emitted a literal `\,`. `list_variables` is unaffected because
that wire format doesn't use `,` as a delimiter; the escaped form
is harmless there.
New layered-decoder pattern in the test: `get_active_theme.options`
is a TWO-LEVEL split (outer `|`, inner `,`), so the decoder must
unescape only the delimiter for the current level — leaving
`\,` intact during the outer `|` split, then unescaping `\,`
during the inner `,` split. The previous test had an over-eager
walker that unescaped every delimiter at once, which broke the
inner split. Helper `layered_split(s, delim)` makes this contract
explicit:
let outer = layered_split(&opts, '|'); // unescape only \|
let inner = layered_split(&outer[1], ','); // unescape only \,
Tests (1 added, 304 shell-core total):
- `get_active_theme_round_trips_comma_in_value` builds an axis
with value `"a,b,c"` plus a plain second value. After encode
+ layered decode, asserts the values vec is exactly
`["a,b,c", "plain"]`. Pre-fix the inner comma split saw 4
values; with the layered decoder + comma escape it sees 2.
The wire format is now safe for every char a `.op` theme value
can legally carry.
Sixth first-party MCP tool. LLM clients now learn the document's
complete theme topology (active selection + every defined axis +
its possible values) so they can plan multi-axis edits without
guessing what's allowed. Previously the tool surface only exposed
the resolved value of each variable (`list_variables`); the wider
"which axes can flip + to what" was inaccessible.
`crates/openpencil-shell-core/src/mcp/tools.rs`:
- `GetActiveTheme { active: Vec<(axis, value)>, options:
Vec<(axis, Vec<value>)> }`. Two parallel vectors: `active`
holds the currently-selected axis/value pairs (subset of all
axes); `options` holds every axis defined in `themes` + its
full value list. An axis appears in `options` even when not in
`active` so clients can seed it via the future
`set_active_axis_value` write tool.
- Wire shape: `axes` = `axis|value;...` (matches `list_variables`'s
escape rules), `options` = `axis|v1,v2,v3;...`. `axis_count` is
reported separately so clients can probe size without parsing
the full list.
- `escape_record_field` reused for both axes + options + each
individual value, so the backslash-escape contract proven by
`escape_record_field_round_trips_pipe_semicolon_backslash`
covers this tool transparently.
Re-exported from `mcp.rs` alongside the other read tools.
Tests (3 added, 303 shell-core, 36 mcp module total):
- `get_active_theme_reports_active_axes_and_options`: two axes
defined (`mode` with 3 values, `density` with 2); only `mode`
in active selection. Asserts `axis_count=2`, `axes` carries
only `mode|dark`, `options` carries both axes' value lists.
- `get_active_theme_empty_document_is_zero`: empty docs return
`axis_count=0` + empty `axes` / `options` strings.
- `get_active_theme_escapes_pipe_and_semicolon_in_values`:
fuzzes a theme axis whose values include `a|b`, `c;d`,
`e\\f`. Decoder walks the encoded options + verifies all
three round-trip via `unescape_record_field`.
MCP read surface now covers the full theme picture:
- get_document_info — counts, page count, active page
- list_pages — page names + active index
- get_selection — selected node id, kind, bounds
- get_node(id) — kind, name, bounds, parent, fill_ref,
stroke_ref
- list_variables — every variable + resolved value
- get_active_theme — axis selection + every axis's options
(NEW)
Write tools (set_variable_color, set_active_axis_value,
insert_node) remain TODO — they need either Arc<Mutex<Document>>
on the tool or a pending-command queue out of ToolOutcome.
VariablesPanel chips were previously click-swallowing placeholders.
This commit adds the actual axis-cycle behavior — clicking the
"mode: dark" chip flips it to "mode: light", then "mode: sepia",
wrapping back to "mode: light". The TS app has the equivalent
behavior via its theme dropdown; the Rust shell now offers the
one-click chip flip as the simpler entry point.
`crates/openpencil-shell-core/src/document/variables.rs`:
- `VariableTable::cycle_active_axis_value(axis) -> bool`:
1. Returns false (no-op) when `axis` isn't in `themes` or
its values list is empty.
2. When the axis isn't in `active_theme`, seeds it with the
first value.
3. When current value matches an entry, advances to the
next (wrapping past the last).
4. When current value is unrecognized (axis options
changed since file loaded), falls back to the first.
`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
- `dispatch_variables_panel_press` `AxisChip(idx)` branch now
looks up the axis name by position in `active_theme` (BTreeMap
iteration is stable + matches the chip walk order in
VariablesPanel::paint), commits any pending property focus,
captures an undo snapshot, calls `cycle_active_axis_value`,
and pushes the snapshot onto history when the cycle actually
moved (return true). Snapshot+restore covers var_table per
the prior history fix (99d602a3), so undo round-trips the
theme cycle the same way it does variable color edits.
Tests (5 added, 300 shell-core total):
- `cycle_active_axis_seeds_first_value_when_absent` — axis in
`themes` but not in `active_theme` → cycle plants the first
value.
- `cycle_active_axis_advances_to_next_value` — three-value
walk through light → dark → sepia → light (wrap).
- `cycle_active_axis_returns_false_for_unknown_axis` — no-op +
false.
- `cycle_active_axis_returns_false_for_empty_values` — defined
in themes but with empty values list → no-op + false.
- `cycle_active_axis_falls_back_to_first_when_current_unknown`
— graceful degradation when active_theme has a stale value
(axis options changed since file load).
The variable edit chain now covers BOTH primary surfaces:
- Row click on a Color-kind variable → ColorPicker → set_color_hex
- Axis chip click → cycle_active_axis_value
Both push undo entries that restore through the var_table-aware
snapshot.
The previous commit (7fafa379) moved fill-picker dismiss to the
very top of the press cascade, but that stole layer-context-menu
clicks (codex stop-gate). The commit before THAT (c3394fda) had it
late enough to be stolen by VariablesPanel.
Right answer: middle position (the original 0c0 spot, between
TopBar and VariablesPanel) — after the higher-z overlays (color
picker, layer-context-menu, agent-settings modal) so those keep
their own click handling when both happen to be open, but BEFORE
the rail walkers (VariablesPanel, PropertyPanel) so the picker
can't survive a click into them.
Cascade now reads:
pre rename / text-edit blur
pre agent-settings modal
0-c color picker overlay
pre layer context menu
0aa commit-on-blur for property inputs
0z panel resize gutter
0ab shape picker overlay
0a locale picker overlay
0b TopBar
0c0 **fill-type picker dismiss** ← settled position
0b1 VariablesPanel
0c PropertyPanel input + action
1+ chat, toolbar, canvas, …
The remaining edge case — TopBar / shape-picker / locale-picker
clicks while fill-picker is open consume the click without closing
fill — is pre-existing behavior. The cascade ordering between
those small overlays is a wider design question (last-opened wins
vs. priority list); not in scope for this commit.
press.rs: 839 → 840 (+1). 295 shell-core + 20 shell-native tests
pass.
Codex stop-gate caught: the previous commit (c3394fda) called the
fill-picker dismiss "first" but it was actually at step 0c0 — after
0-color (color picker), layer-context-menu, 0aa (commit-on-blur),
0z (panel resize), 0ab (shape picker), 0a (locale picker), and 0b
(TopBar). Any of those earlier steps could consume a click and
leave `fill_type_picker_open=true` behind.
Moved the entire fill-picker dismiss block to step 0-fp, which
runs immediately after the rename / text-edit blur lines and the
agent-settings modal dispatch — before any other overlay can
swallow the click:
pre. rename + text-edit blur (always run)
pre. agent-settings modal dispatch (only when modal open)
0-fp. **fill-type picker dismiss** ← now first
0-color. color picker overlay
... (all other overlays)
0b1. VariablesPanel
0c. PropertyPanel
...
Behavior: any click while the fill-type picker is open closes it
+ swallows the click (matches previous semantics for the case
where the click was outside the picker's dropdown rows). Clicks
that land on the picker's own dropdown rows still route to
SetFillType / ToggleFillTypePicker.
Concrete fix: a sequence like "open fill picker on Property panel
→ click TopBar locale globe" now closes the fill picker (which the
old order missed because TopBar at 0b consumed the click first).
press.rs: 836 → 839 (+3). The block content is unchanged; only its
position moved, so the file growth is from the new doc-comment.
Still over the 800-line cap as pre-existing tech debt. 295
shell-core + 20 shell-native tests still pass.
Codex stop-gate on the previous reorder (96cf753b): putting
`dispatch_variables_panel_press` at the absolute top of the cascade
meant a Variables-row click while the PropertyPanel's fill-type
picker was open returned true and left `fill_type_picker_open=true`.
The fill picker would float over the chrome until the next
unrelated click happened to land outside its rect.
Cascade order now:
0c0. Fill-type picker outside-click dismiss
(must run first when open — any click anywhere closes the
picker, then swallows or routes to a SetFillType /
ToggleFillTypePicker action).
0b1. VariablesPanel hit dispatch
(BEFORE PropertyPanel so the bottom-anchored Variables rect
wins for clicks in its z-order overlap with the rail).
0c. PropertyPanel input + action hit-tests.
The fill-picker block already swallows every click when open, so
reaching the Variables dispatcher requires the picker to be
closed — which means the existing dismiss path runs unconditionally
before any Variables click can fire. 295 shell-core + 20 shell-
native tests still pass.
press.rs: 832 → 836 (the dismiss comment + restored ordering adds
4 lines). Still over the 800 cap as pre-existing tech debt; my
session has added +11 lines total to this file (825 → 836), which
is small and bounded.
Codex stop-gate caught: PropertyPanel was tested first in the press
cascade, so any click in the rail's bottom region went to its hit
walker — which would resolve to e.g. the Export-section row or an
input — even when the click was visually OVER the bottom-anchored
VariablesPanel. Paint stack has VariablesPanel ON TOP of
PropertyPanel (step 4 → step 4b), so hit-test order must follow.
`crates/openpencil-shell-native/src/widget_host/press.rs`:
- The `dispatch_variables_panel_press` call moved from step 0d
(after PropertyPanel) to step 0b1 (before fill-type picker +
PropertyPanel input dispatch). Returns true when the click
consumes a Variables row / chip; otherwise false, so the
cascade falls through to the property-panel walkers exactly as
before for non-rail-bottom clicks.
- The dispatcher itself short-circuits when `var_table.variables`
is empty (existing guard), so the reorder is a no-op for
documents without variables.
Test surface unchanged — 295 shell-core + 20 shell-native tests
still pass. The fix is purely an ordering change; no new logic.
press.rs: 833 → 832 (the relocation trims one blank line). Still
over the 800 cap as pre-existing tech debt; not made materially
worse by this commit.
Closes the missing host wire that codex called out as pending: the
VariablesPanel widget paints (140d5495 / 0bd98ae2) and the picker
has variable-mode commit (3c2e7711 / 99d602a3), but clicking a row
did nothing. This commit dispatches.
`crates/openpencil-shell-native/src/widget_host/press.rs`:
- New step 0d in `apply_press` (between PropertyPanel input and
AI chat dispatch): one-line call to the new helper. Keeps the
cascade ordering — properties consume their hits first, vars
next, chat after.
`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
- `dispatch_variables_panel_press(x, y, vw, vh) -> bool` mirrors
the existing `dispatch_export_dialog_press` pattern. Reconstructs
the same right-rail rect math `paint.rs` step 4b uses (top
when no selection, bottom-anchored above status bar when
PropertyPanel owns the rail), hit-tests, then routes:
- `Row(idx)` on a Color-kind variable → commit any pending
property focus, then `open_color_picker_for_variable(name)`.
The picker's HSV-drag path writes through `set_color_hex`
(which carries all three correctness invariants); close
pushes the snapshot+var_table undo entry from 99d602a3.
- `Row(idx)` on a non-color variable → swallow (TODO: row
inputs for string / number). Prevents fall-through to
canvas deselect.
- `AxisChip(_)` → swallow (TODO: theme-axis picker).
- Returns `false` when var_table is empty or the click missed,
so press.rs's cascade continues to chat / canvas.
Tests: 295 shell-core + 20 shell-native still pass. The dispatcher
is exercised by the existing `VariablesPanel::hit_test` tests +
the `color_picker::tests::*` end-to-end variable flow; a wired
host integration test would need a fake-render harness that's out
of scope here.
press.rs grew from 825 → 830 (+5 lines for the cascade call site).
That file was over the 800-line cap before this session — pre-
existing tech debt tracked separately. Not making it materially
worse.
TOP-10 #5 (Variables/Themes UI) edit chain is now functionally
complete: paint → click → picker → write → undo. The remaining UX
gaps are non-color variable inputs + the theme-axis picker, both
flagged as TODOs in the dispatcher.
Codex stop-gate caught a real correctness gap: the previous commit
(3c2e7711) wired the ColorPicker's variable-mode commit through
`set_color_hex` and pushed `pre_snap` onto undo when the colour
changed. But `DocumentSnapshot` only covered `pages /
active_page_index / selected / selected_set` — `var_table` wasn't
in the snapshot, so `undo()` would restore pages + selection
without touching the variable. Variable edits were effectively
unrecoverable.
`crates/openpencil-shell-core/src/document.rs`:
- `DocumentSnapshot` grew `var_table: VariableTable`. Captures
every Variable definition + themed entries + active_theme +
fill_refs / stroke_refs at snapshot time, so undo can restore
the complete colour-token graph.
`crates/openpencil-shell-core/src/document/mutators.rs`:
- `snapshot()` clones `var_table` into the captured snapshot.
- `restore()` writes the snapshot's var_table back to the
document. The single literal site for DocumentSnapshot stays
consistent across all callers (`snapshot_for_history()`,
`commit_history()`, `undo()`, `redo()` — all flow through
`snapshot()` + `restore()`).
`crates/openpencil-shell-core/src/document/color_picker.rs`:
- Dropped the now-redundant `variable_pre_color: Option<Color>`
parallel field from `ColorPickerState`. It was a workaround
for snapshot not carrying var_table; now `close_color_picker`
reads `snap.var_table.resolve_color(name)` directly to detect
the pre-edit colour. Cleaner single source of truth.
- The change-detection in `close_color_picker`'s variable branch
now compares `snap.var_table.resolve_color(name)` against the
live resolution. Same result as before, fewer parallel fields.
Tests (1 added — the regression repro, 295 shell-core total):
- `undo_after_variable_edit_restores_pre_edit_color` builds the
exact codex BLOCK case: variable starts at `#ff8800`, picker
opens, HSV drags to pure red, picker closes (history push).
`doc.undo()` runs; the variable must resolve back to the
original orange. Pre-fix the snapshot's pages + selection
would restore but the variable would still read red — the
undo entry was effectively broken.
Closes the missing wire between the new `VariableTable::set_color_hex`
write path (round 3 fix in febe7a76) and the existing ColorPicker
floating UI. The host can now route a VariablesPanel row click into
the picker keyed to a variable; HSV drag flows back through the
variable's storage, paint reflects the change on the next frame.
`crates/openpencil-shell-core/src/document.rs`:
- `ColorPickerState` grew two parallel fields:
`variable: Option<String>` — name of the Color variable
being edited; `None` ⇒ node-target
mode (existing Fill/Stroke path).
`variable_pre_color: Option<Color>` — resolved colour at open
time, used by `close_color_picker`
to detect "did anything change?"
without requiring DocumentSnapshot
to carry var_table.
- `ColorTarget` stays Copy + Fill/Stroke-only. Adding a
`Variable(String)` variant would have forced touching every
pattern-match site; the parallel field keeps the existing 18+
`ColorTarget` callers untouched.
`crates/openpencil-shell-core/src/document/color_picker.rs`:
- `Document::open_color_picker_for_variable(name, anchor_y)`:
new entry point. Seeds HSV from `var_table.resolve_color(name)`,
captures the resolved colour into `variable_pre_color`. Returns
`false` when the name is unknown or the variable isn't
Color-kind — so a host-side dispatcher can `if !doc.open_...
return;` without inspecting state.
- `Document::color_picker_set_hsv` branches on `variable.is_some()`:
routes through `format_color_hex_for_var(rgb)` →
`var_table.set_color_hex(name, hex)`. The set_color_hex
correctness saga (3 commits, no shadowing, no cross-axis
clobber) carries this commit transparently.
- `Document::close_color_picker` for the variable path compares
`state.variable_pre_color` vs `var_table.resolve_color(name)` to
decide whether to push the pre-edit snapshot onto undo.
Single-bool changed-check matches the node-target branch.
- New `format_color_hex_for_var(rgba) -> String` helper emits
`#rrggbb` for the var-write commit path.
Tests (4 added, 294 shell-core total):
- `open_color_picker_for_variable_seeds_hsv_from_resolved_color`
— picker opens against `#ff8800`; HSV anchor reads as orange-hue
(20°–45° range), near-max sat + val.
- `open_color_picker_for_variable_fails_when_var_missing_or_wrong_kind`
— unknown name returns false; Number-kind variable returns false;
picker state stays None in both cases.
- `color_picker_set_hsv_writes_through_variable_path` — end-to-end:
open on `#ff8800`, push HSV (0°, 1, 1) = pure red, assert
`resolve_color` returns red.
- `close_color_picker_after_variable_edit_pushes_history_only_when_changed`
— open + close with no HSV change → no history push. Open +
drag + close → history depth grows by 1.
The full variable edit UX now has a working host integration point:
when VariablesPanel emits `VariablesPanelHit::Row(idx)` for a color
variable, the host calls `open_color_picker_for_variable(name)` and
the existing picker chrome takes over.
The previous commit (0bd342ad) registered the new variables_tests
sibling with the canonical two-line form:
#[cfg(test)]
mod variables_tests;
That pushed document.rs to 801 lines — one over the 800-line cap
codex stop-gate enforces. Collapsed to the inline form:
#[cfg(test)] mod variables_tests;
This matches the existing inline `#[cfg(test)] mod tests_geometry;`
+ `#[cfg(test)] mod tests_mutators;` declarations a few lines
below in the same file, so the style is consistent.
document.rs: 801 → 800 lines (exactly at cap). 290 shell-core
tests still pass.
Codex stop-gate flagged that `document/variables.rs` was 865 lines
after the three rounds of `set_color_hex` correctness fixes — over
the project's 800-line ceiling. Tests moved verbatim to a sibling,
mirroring the `layer_panel_tests` / `property_panel_tests` pattern.
crates/openpencil-shell-core/src/document/variables.rs 865 → 318
crates/openpencil-shell-core/src/document/variables_tests.rs (new) 549
`document.rs` registers the sibling under `#[cfg(test)]` between
the existing `mod variables;` and the rest of the file's mod list.
Tests file gets a flat layout — bare `#[test]` functions at file
scope (no inner `mod tests {}` wrapper), `use super::*` removed in
favor of the explicit `use super::{ThemedValue, Variable, ...}`
imports the body actually needs.
No semantic change. 290 shell-core tests pass.
Third codex stop-gate on `set_color_hex` themed writes. The
previous fix (599fc859) inserted the new entry at index 0 to
guarantee precedence under the active theme, but that shadowed
existing entries on UNRELATED axes:
Existing: [{theme: Some({density: compact}), value: "#compact"}]
Active: {mode: dark}
Step 3 (no subset match) inserted at front:
[{theme: Some({mode: dark}), value: "#new"},
{theme: Some({density: compact}), value: "#compact"}]
Under future active = {mode: dark, density: compact}: resolve's
first-match subset walk picked entry 0 ({mode: dark} ⊆ active) and
returned "#new" — even though the user's edit was only meant to
apply to mode = dark, not to override the density-specific entry
under the combined axis.
Switched to end-push. End-push is safe because Step 1 (subset
match) already proved no existing entry is a subset of `active`,
so the new entry is the UNIQUE subset match under the active
theme regardless of position. Under OTHER actives, every
pre-existing entry retains its original resolve precedence.
Test (1 added, 290 shell-core total):
- `set_color_hex_does_not_shadow_existing_entries_on_other_axes`
builds the exact pathological case: existing
{density: compact} entry; write under active {mode: dark};
then flips active to {mode: dark, density: compact} and
asserts resolve_color still returns the compact value
(#11ccaa), not the dark-only write (#000000). Pre-fix
front-insert would have read #000000 — the codex BLOCK.
The three-round set_color_hex saga is now closed:
Round 1 (042a62c8): write path mirrors resolve's subset
matching → no shadow by stale-vec ordering.
Round 2 (599fc859): non-empty active never clobbers the
theme=None default.
Round 3 (this): end-push not front-insert → no shadow of
other-axis themed entries.
Second codex stop-gate on `set_color_hex` themed-write routing.
The previous fix (042a62c8) made the write follow resolve's subset
match, but its fallback path still mutated the `theme: None`
default entry when no themed subset matched. That's wrong under a
non-empty active theme: the default is the resolve fallback for
EVERY axis the user hasn't explicitly authored, so mutating it
during a dark-mode edit silently rewrites light + sepia + every
unmapped axis too.
New write rules:
Subset match: → write through that entry.
No subset match + active EMPTY: → write through default
(creating it if absent).
No subset match + active SET: → push a new entry keyed to
active_theme at index 0, so
resolve's first-match walk
picks the new entry under the
active axes. The default and
other-axis entries stay
untouched.
Inserting at the front (not pushing at the end) matters because
resolve uses subset matching: a pre-existing entry whose theme is
a superset of the new one's axes would otherwise win the resolve
walk for active = (its theme + extra axes). Front-insert pins the
new entry's precedence under the active theme it was created
for.
Tests rewired (2 swapped — 288 → 289 shell-core total):
- `set_color_hex_under_active_theme_does_not_clobber_default`
replaces the previous "writes the default" test. Setup:
light entry + None default + active=dark; write under dark
must scope to dark only. Asserts three theme states:
active=dark → reads NEW value
active=sepia → reads ORIGINAL default (untouched)
active=light → reads ORIGINAL light entry (untouched)
Pre-fix this test would have failed on the sepia assertion
because the default would carry the new dark value.
- `set_color_hex_empty_active_theme_targets_default_entry`
confirms the inverse: with active_theme empty, the write
still routes through the default (resolve's fallback in that
state). This is the explicit "edit the universal value" path.
Existing themed-axis test passes unchanged because that case
already had a subset match and never reached the fallback path.
Codex stop-gate caught a real correctness bug in the previous
commit (62653659): `set_color_hex` used EQUALITY to match themed
entries against `active_theme`, but `Variable::resolve` uses
SUBSET matching. The mismatch let writes report success without
actually changing the resolved value:
Entry: {theme: Some({"mode": "dark"}), value: "#111"}
Active: {"mode": "dark", "density": "compact"}
Before: equality check fails (entry's theme has fewer keys), so
the write pushes a new entry at the end:
{theme: Some({"mode": "dark", "density": "compact"}), value: NEW}
resolve() walks first-to-last, picks the ORIGINAL entry (mode=
dark is a subset of active), returns "#111" — the new entry is
shadowed and never read.
Fix: `set_color_hex` now mirrors `resolve` exactly. Three-step
lookup matching the resolve walk order:
1. First themed entry whose `theme` map is a subset of
active_theme → write there.
2. Else, the `theme: None` default entry → write there.
3. Else, push a fresh entry keyed to active_theme (or None when
active is empty).
The write is now guaranteed to flip the resolved value when it
returns true.
Tests (2 added — direct repros of the codex BLOCK, 287 shell-core
total):
- `set_color_hex_changes_resolved_value_under_subset_active_theme`
builds the exact pathological case (entry mode-only, active
has mode+density), writes, then asserts `resolve_color`
returns the NEW value. Pre-fix this test would have read the
untouched "#111" and failed.
- `set_color_hex_falls_back_to_default_entry_when_no_subset_match`
covers the second branch — themed entry mismatch + `theme:
None` default + active that matches neither. The write must
update the default (which is what resolve falls back to) and
not append a never-read dark entry.
Closes the "view-only" gap the VariablesPanel widget left: the model
now exposes a real edit primitive for color variables. The
ColorPicker's commit-on-release path (and the future MCP write
tool) can route through this method to push a hex string into a
color variable; paint sees the new colour on the next frame because
`resolve_color` reads the same data.
`crates/openpencil-shell-core/src/document/variables.rs`:
- `find_mut(name)` parallels the existing `find(name)`. Returns
`Option<&mut Variable>` so editor surfaces can stage in-progress
writes without re-walking the variables vec.
- `set_color_hex(name, hex) -> bool`:
1. Validates `hex` via `parse_hex_color` BEFORE touching
the store, so a malformed input never corrupts state.
2. Returns false (and skips mutation) for unknown name,
wrong `kind` (must be Color), or unparseable hex.
3. For `Scalar` variables: overwrites the single value.
4. For `Themed` variables: finds the entry whose `theme`
matches the current `active_theme` (or the default
`theme: None` entry when active is empty); writes through
on match. When no exact entry exists, pushes a new
`ThemedValue` keyed to the current active map — so
editing a light/dark theme adds the right entry without
silently stomping the other axis value.
Tests (5 added, 286 shell-core total):
- `set_color_hex_writes_scalar_variable` — basic happy path.
- `set_color_hex_rejects_malformed_input` — pre-mutation
validation; original scalar untouched on reject.
- `set_color_hex_returns_false_for_unknown_variable` — silent
no-op when the name doesn't exist.
- `set_color_hex_returns_false_for_wrong_kind` — Number-kind
variable can't be written via the color helper.
- `set_color_hex_writes_themed_entry_matching_active_axis` —
two themed entries (light/dark); active=dark; write only
updates the dark entry; flipping to light keeps the
pre-existing light value intact. This pins the "no
cross-axis stomp" contract.
The edit-UX side of TOP-10 #5 now has the bedrock write path; the
ColorPicker dispatcher + VariablesPanel row-click routing is the
next layer.
`NodeRecord` grew two fields so LLM clients can tell when a node's
colour follows a variable instead of a literal. Without this, the
client can only see the resolved hex — it doesn't know whether to
bump the variable (theme-wide ripple) or write a per-node override.
`crates/openpencil-shell-core/src/mcp/tools.rs`:
- `NodeRecord { fill_ref: String, stroke_ref: String }`. Empty
string when the node doesn't use a variable for that paint
channel. Empty (not omitted) so clients can probe with a
single `out.get("fill_ref") == Some(&"")` instead of having
to handle the missing-key case as well.
- `get_node_snapshot` + `walk_node` thread the document's
`var_table` through the tree walk and look up
`var_table.fill_refs[node.id]` + `var_table.stroke_refs[
node.id]` per node. The lookup is O(log n) BTreeMap, so the
full-document walk stays O(n log n) — fine for typical
documents (≤ thousands of nodes); LLM call frequency is far
below paint frequency anyway.
- The `get_node` `call()` payload adds the two new keys
alongside the existing kind / name / bounds / parent_id.
Test (1 added, 281 shell-core total, 33 mcp module total):
- `get_node_surfaces_fill_and_stroke_refs` — installs
`fill_refs[11] = "color-primary"` +
`stroke_refs[11] = "color-accent"` on the sample doc, calls
`get_node` with node_id=11, asserts the payload carries the
variable names. A second call with node_id=12 (no ref
mapping) asserts both fields are empty strings (not absent),
pinning the API contract.
The MCP read-side now covers the full graph that paint-time `$ref`
substitution uses: list_variables → choose a variable;
list_pages → choose a page; get_document_info → counts;
get_selection → current focus; get_node(id) → kind, bounds,
parent, and which paint channels follow which variable.
Codex stop-gate flagged that the previous commit (a54464ce) made an
unfounded claim about the canonical `.op` schema rejecting `;` and
`|` in variable names + values. There is no such validator —
string-kind variables can legitimately carry payloads like
`"a|b;c\\d"`, and my flat `name|kind|value;...` encoding would
mangle the field boundaries.
`crates/openpencil-shell-core/src/mcp/tools.rs`:
- `escape_record_field(s)` — `\` → `\\`, `;` → `\;`, `|` → `\|`.
Applied to every field before joining. Standard
backslash-escape pattern that any client can invert with a
one-pass byte walk.
- `unescape_record_field(s)` — exposed as `pub` so Rust-side MCP
clients have a canonical decoder reference. Unrecognized
escape sequences pass through verbatim (`\X` for X ∉ {`\`,
`;`, `|`} stays as `\X`) so future format extensions don't
silently corrupt today's data.
- Doc comment correctly states the wire format + the escape
rules instead of the previous wrong "schema forbids these
chars" claim.
Tests (2 added, 5 mcp::tools total, 280 shell-core total):
- `escape_record_field_round_trips_pipe_semicolon_backslash`
fuzzes the round-trip across 10 inputs including every
delimiter alone, mixed payloads (`a|b;c\\d`), empty string,
and pure-delimiter strings (`;;|||`).
- `list_variables_encodes_string_value_with_special_chars`
drives the full end-to-end path: a string variable whose
value is `a|b;c\\d` is encoded by `list_variables`, then
decoded by a hand-written walker matching the doc-comment's
"promote `\X` → `X`" rule. Asserts the split yields exactly
3 fields + the third field matches the original payload byte
for byte.
The wire format is now unambiguous for any variable data the
canonical loader will accept.
Fifth first-party MCP tool, completing the read-side discovery
quartet alongside the new VariablesPanel widget. LLM clients chain
`get_document_info` → `list_pages` → `list_variables` to learn the
document's design-token vocabulary before issuing node mutations
that reference variables via `$ref:name`.
`crates/openpencil-shell-core/src/mcp/tools.rs`:
- `ListVariables { variables: Vec<VariableRecord> }` snapshot
struct.
- `VariableRecord { name, kind, value }` — `kind` is one of
`color` / `number` / `boolean` / `string`; `value` is the
scalar resolved under the active theme (empty string when the
variable doesn't resolve, e.g. themed entries with no
matching axis).
- `list_variables_snapshot(doc)` builds the snapshot once; the
host re-registers on var-table mutations matching the existing
pattern.
- Wire format: a flat `variables` field encodes the list as
`name|kind|value` triplets joined by `;`. Avoids nested JSON
so shell-core stays serde-free; clients split on the two
delimiters. Schema-valid variable names + values can't contain
`;` or `|` (the canonical `.op` loader rejects those chars).
`count` is reported separately for clients that just want a
quick "does this document have variables?" check.
Re-exported from `mcp.rs` as `list_variables_snapshot` +
`ListVariables` + `VariableRecord` so callers stay on
`mcp::list_variables_snapshot(doc)` like the existing
`mcp::document_info_snapshot(doc)`.
Tests (3 added, 30 total mcp module, 278 total shell-core):
- list_variables_reports_count_and_records — 3-variable
fixture (color / number / boolean), asserts kind labels +
resolved value strings (`16` for f64 16.0 without trailing
zeros; `true` for bool).
- list_variables_encodes_for_wire — verifies the `name|kind|
value` format + `;` separator + the `count` field is
independently accessible.
- list_variables_empty_document_returns_zero_count — edge
case: empty var_table → count=0, variables="".
MCP tool surface now: 5 first-party reads (get_document_info,
get_selection, list_pages, get_node, list_variables). Write tools
(insert_node, batch_design, design_skeleton) need a mutable
document handle through the registry, which is a separate design
beat — deferred until the use case requires it.
The previous split commit (764eb9f4) left three files with an
extra blank line after the closing `}` because the sed/awk
extraction pipeline appended one. `git diff --check` flagged:
crates/openpencil-shell-core/src/mcp/parser.rs:344
crates/openpencil-shell-core/src/mcp/tools.rs:278
crates/openpencil-shell-core/src/widgets/property_panel.rs:585
All three normalized to end with exactly one newline after the
final `}`. No semantic change.
Codex stop-gate flagged that a recent edit pushed a file past the
repo's 800-line ceiling. Two files were over from this session:
mcp.rs 1255 lines (from get_node + list_pages +
get_selection + params parser)
property_panel.rs 870 lines (from the export hit-test test)
Both split along clean boundaries, both now under cap.
`mcp.rs` (1255 → 663 lines):
- New `src/mcp/parser.rs` (344 lines) — `parse_tool_call` + every
`extract_*` helper + `parse_flat_object_body` + the local
`extract_field`. Handles the real MCP `tools/call` envelope
AND the legacy flat shape.
- New `src/mcp/tools.rs` (278 lines) — `GetDocumentInfo`,
`GetSelection`, `ListPages`, `GetNode` + their `_snapshot`
factories + `NodeRecord` + the private `count_subtree` /
`walk_node` walkers. Every first-party tool lands here from now
on.
- `mcp.rs` retains: types (`RequestId` / `ToolCall` /
`ToolResponse` / `ToolErrorCode` / `ToolOutcome` / `McpTool`
trait), `ToolRegistry`, `run_stdio`, `response_to_json` + its
private helpers (`id_to_json` / `error_code_to_int` /
`btree_to_json` / `json_escape`), and every existing test.
- Public surface re-exported from `mcp.rs` so callers continue
to write `mcp::parse_tool_call` / `mcp::GetDocumentInfo`:
pub use parser::parse_tool_call;
pub use tools::{ document_info_snapshot, get_node_snapshot,
list_pages_snapshot, selection_snapshot, GetDocumentInfo,
GetNode, GetSelection, ListPages, NodeRecord };
`property_panel.rs` (870 → 585 lines):
- New `src/widgets/property_panel_tests.rs` (289 lines) — all 11
integration tests moved verbatim. Mirrors the
`layer_panel_tests.rs` sibling pattern: bare `#[test]` functions
at file scope (no `mod tests {}` wrapper) + the module
registered as `#[cfg(test)] mod property_panel_tests;` in
widgets/mod.rs.
- `SectionCapabilities::for_kind` bumped from private to
`pub(crate)` so the sibling test can construct
`VisibleSections` for the export-section hit-test.
Tests: 275 shell-core (unchanged) + 54 desktop + 20 shell-native.
No behavior change — both splits are mechanical moves.
OP-owned files under cap now: ✓ shell-core (top: 663 mcp.rs),
✓ shell-native (top: 825 press.rs — pre-existing tech debt, not
this session's fault), ✓ desktop (top: 735 main.rs).
Codex stop-gate flagged: with no selection, the right-rail
`VariablesPanel` was painted (commit 0bd98ae2 wired the paint
step) but `canvas_region` still returned the full viewport width
because its `has_property` gate only checked `property_panel_
visible()` — which is false without a selection. The canvas
paint pass (Step 5) then extended over the right rail and
overpainted the Variables panel, defeating the visibility fix
the previous commit was supposed to deliver.
`crates/openpencil-shell-core/src/document/mutators.rs`:
- New `Document::right_rail_visible() -> bool` is the unified
gate: true when either `property_panel_visible()` is true or
`var_table.variables` is non-empty. Future right-rail widgets
(Components, Themes header, etc.) extend this method instead
of patching every caller.
`crates/openpencil-shell-native/src/widget_host/geometry.rs`:
- `canvas_region` swapped from `property_panel_visible()` to
`right_rail_visible()`. The shrunk-canvas branch no longer
requires a selection — any rail-occupying widget reserves the
column.
Test (1 added, 275 shell-core total):
- `right_rail_visible_tracks_property_panel_and_variables`
covers the four state combinations:
empty doc → rail hidden
var-table-only (no selection) → rail shown (codex repro)
selection-only (no vars) → rail shown (legacy gate)
cleared selection + cleared vars→ rail hidden again
20 shell-native + 54 desktop + 275 shell-core tests pass.
Codex stop-gate flagged that the previous commit (140d5495) shipped
`VariablesPanel` as a widget definition + tests but never wired it
into the host's paint composition, so users couldn't actually see
it. This commit closes that gap.
`crates/openpencil-shell-native/src/widget_host/paint.rs`:
- Step 4 (PropertyPanel) now also computes the right-rail x +
width as locals so the new VariablesPanel paint step reuses
them instead of duplicating the geometry math.
- New Step 4b: when `Document.var_table.variables` is non-empty,
paint a `VariablesPanel::for_document(...)` rectangle anchored
to the right rail. Layout decision:
- No selection: panel pinned at the top of the rail
(TOP_BAR_HEIGHT + 8 px) so it's the primary chrome there.
- Active selection: PropertyPanel owns the rail; Variables
anchors to the bottom (above the status bar). Approximate
because PropertyPanel paints to fill the rail today;
proper stacking lands when the rail grows scrollable
regions or tabs.
Variables stays hidden when `var_table` is empty — no visual
noise for documents that don't use them.
- `VariablesPanel` imported via `widgets::variables_panel::
VariablesPanel` (the module is `pub mod` exported in
widgets/mod.rs).
`crates/openpencil-shell-native/src/widget_host/input.rs`:
- Cleaned up the unused imports left over from the input/keyboard
split (5caa2eb3): `PropertyFocus`, `AIChatHit`, `AIChatPlaceholder`,
`LayerPanel`, `LayoutCx`, `Toolbar`, `Widget`, `TOOLBAR_WIDTH`,
`TOP_BAR_HEIGHT` were all moved to keyboard.rs but still
listed in input.rs's import list. Down to the actually-used
`ChatAnchor` + helpers + Point2D + Rect.
Tests: 274 shell-core + 54 desktop + 20 shell-native all pass.
Visual verification: opening a `.op` file whose `variables` array
is non-empty now shows the panel chrome (header "Variables" label,
active-theme chips, one row per variable with name + resolved
color swatch / scalar label). Edit interactions still pending —
`VariablesPanelHit::Row(idx)` is wired up but the host doesn't
dispatch it yet.
First UI surface for the Variables/Themes feature (TOP-10 #5). The
`Document.var_table` machinery (VariableTable + theme resolution +
fill/stroke refs) has been in place since the canonical loader
landed; the missing piece was a panel widget that exposes it.
`crates/openpencil-shell-core/src/widgets/variables_panel.rs`:
- `VariablesPanel<'a>` borrows `&'a VariableTable` (or constructs
via `for_document(&Document)`). Cheap — no document mutation,
paint reads through the borrow.
- Header section: "Variables" label + a chip row showing each
active theme axis (`mode: dark`, `density: compact`, etc).
Chip width approximated from label length (real text-measure
integration lands when the panel gets hosted — the hit-test
budget is generous enough that off-by-one is fine for v1).
- Variable rows: one per variable in the table. Name on the
left at 12 pt foreground; preview on the right.
`VariableKind::Color` renders a small swatch resolved through
the active theme. Number / Boolean / String render the
resolved scalar as a short label, truncated to 12 chars with
`…` for overflow. Unresolved variables show "—".
- `hit_test(rect, point)` returns `VariablesPanelHit::Row(idx)`
or `VariablesPanelHit::AxisChip(idx)`. Indices are into the
underlying `variables` / chip lists so callers can map back
to the source. Returns None when the point falls outside the
panel rect or in a non-clickable region (header label,
inter-row gaps).
- `intrinsic_height` lets the right-rail host size the panel
based on row count + chip-row presence.
- Widget trait impl: `id` + `access_node` (accesskit Group role,
label "Variables") + `layout` + `paint`.
`VARIABLES_PANEL_WIDTH = 240.0` exported so hosts can use a stable
constant instead of magic numbers.
Tests (7 added, 274 total shell-core):
- row_count_matches_variable_count
- axis_count_reflects_active_theme
- intrinsic_height_grows_with_rows_and_chips (empty → header only)
- hit_test_returns_row_index_for_in_row_click (middle of row 1)
- hit_test_returns_axis_chip_for_chip_click (left edge of chip 0)
- hit_test_returns_none_outside_rect (negative x + below rect)
- axis_chip_table_mirrors_active_theme_btree_order (BTreeMap
iteration order is stable + deterministic — chips paint
alphabetically by axis name)
Editing (add / rename / delete variable; toggle axis value) is
deferred — needs a separate `VariablesPanelAction` enum + host
dispatcher. The view-only surface lets the property panel start
showing `$ref` swatches in a follow-up without blocking on edit UI.