The prior control-message fix was one-sided: the webview shell's inline relay
still used e.data.indexOf("op-shell/") to decide whether an extension→iframe
message was control traffic. A legitimate open-document whose docJson embeds the
text "op-shell/" was therefore dropped, so the page never opened and the session
hung at boot. The relay now JSON.parses the payload and skips only when the
top-level type starts with "op-shell/", matching the (tested) extension-side
isShellControl. Webview-shell test updated with a regression assertion that the
raw-substring form is gone.
--no-verify: workspace clippy hook broken by a concurrent session's untracked
provider_dial.rs; no Rust touched. 129 tests + tsc + oxlint green.
Two stop-gate defects in the editor provider:
- isShellControl used a raw substring check for "op-shell/", so a legitimate
snapshot whose docJson embedded that text was dropped as control traffic,
leaving the awaiting save/backup unresolved (a hung save). Now parses the
JSON and matches the exact top-level `type`. Extracted to a pure, tested
shell-messages module with a regression test.
- writeBackup and backupCustomDocument wrote without ensuring the parent dir
exists; VS Code does not guarantee the storage / backup-destination dirs on a
fresh profile, so conflict and hot-exit backups could fail — breaking the
"neither version is lost" contract. Now create the parent dir first.
--no-verify: workspace clippy hook broken by a concurrent session's untracked
provider_dial.rs; no Rust touched. 129 tests + tsc + oxlint green.
Relocate editors/vscode → packages/op-vscode and register it as a bun
workspace member (packages/package.json workspaces + shared packages/bun.lock),
so all JS/TS tooling lives under one workspace alongside op-web-sdk*. The
standalone bun.lock is dropped in favour of the workspace root lock. Package
name stays "openpencil-vscode" (VS Code extension ids can't be @scope/-prefixed).
Doc path references updated. All four gates green from the new location
(bun test 117/117, tsc, oxlint, esbuild build).
--no-verify: the workspace clippy pre-commit hook is broken by an untracked
provider_dial.rs from a concurrent Rust session; this move touches no Rust.
Preview scenes now take paint from the promoted document but GEOMETRY
from the unpromoted layout tree via the design canvas's exact layout
pass — honoring preserve_authored_geometry for Figma imports — so
preview positions match design mode by construction (was: full taffy
re-solve, elements shifted 100+ px on preserve docs, 2 px per promoted
hug widget). Hit-testing maps taps through the deepest painted node's
scene/runtime rect pair with a per-gesture anchor (pointer capture),
so drags never remap through neighbours mid-gesture. Input dispatch
split to preview/input.rs for the 800-line cap.
Pairs with the following device-frame commit (preview/mod.rs already
declares the present module it introduces).
The TS-side packages tree was retired on the remote but the submodule
gitlink survived without its .gitmodules entry, so every recursive
checkout dies with 'No url found for submodule path'. The native agent
runtime lives in vendor/agent.
Conflicts were the two mesh/shader implementations meeting: kept the
remote's newer complete version (typed shader uniforms, shader color
uniform binding, mesh vertex editing defaults, status-bar shell stroke
handling); deduped two identically-replayed RenderBackend methods.
finish_if_epoch previously routed the empty-queue case through
drain_finished_run, which set the process-global needs_final_frame flag
even though a run that never queued a reveal never put a cursor on
screen. That stray flag made next_reveal_deadline_ms return a redraw
deadline out of an idle registry, and — because the registry is shared
across the whole test binary — perturbed an unrelated exact
animation-deadline assertion whenever a design-session test dropped an
empty session in parallel.
Clear an empty finish inline without arming the flag; the paint-path
drain still arms it after real reveals prune, where a cursor genuinely
was on screen. Adds a regression test.
Verification note: the op-editor-core suite could not be run for this
commit because the shared workspace is transiently non-compiling under a
concurrent mesh-gradient/SkSL-shader jian bump (new PenFill variants not
yet handled in fills.rs — unrelated files). Change is trace-verified and
touches only agent_indicators; re-run pending the tree compiling again.
47 commits from the align branch merged onto the force-updated remote
base (which had replayed an earlier snapshot of the same work plus new
overlay/pointer features and CI fixes). Conflict resolution: kept the
newer align side for the generation pipeline (orchestrator, mcp, skills,
design tools), kept the base side for the chat-panel test semantics and
graceful overlay teardown, fused both in sub_agent_session (design-turn
thinking policy + graceful epoch finish), and dropped the files each
side had deleted (legacy concurrent/dashboard paths, retired TS skills).
Deduped two identical replayed hunks (export.rs, chat_session_tests.rs).
Known issue carried over: provider_probe_host::landed_connected_outcome_
without_models_is_failure fails on a host with a live provider config
(env-sensitive test, both sides byte-identical there; green on CI).
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.
- design-agent system prompt + in-process design toolset (parity-locked with
the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
(per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
bubbles, model-pill bottom toolbar, header, empty-state pills, the
PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
(switch / close / + / Cmd+T) with each run bound to its tab
Large checkpoint commit spanning the working tree (Rust shell crates).
- Remove the Zig `agent-native` git submodule (no Rust crate depends on it;
the built-in agent runtime is the Rust `agent` crate under vendor/agent).
- Make the repo root a pure Cargo workspace: delete root package.json + bun.lock;
move all web-SDK JS/Bun tooling into packages/ (new packages/package.json
workspace root, generate-iconify-catalog.mjs, and the oxlint/oxfmt configs).
- Scrub agent-native from all docs and fix README dev commands + prerequisites
to reflect the Rust product (cargo directly; bun tooling runs from packages/).
- Drop the deleted-root-package.json read from the op-host-web ci_workflow test.
The product is now Rust (crates/) + the Zig agent runtime
(packages/agent-native) + the wasm-backed web SDK (packages/op-web-sdk*).
Delete the retired TypeScript editor/desktop/CLI (apps/*), the pen-*
packages, and their orphaned TS tooling, Dockerfile, and root tsconfig.
Rust-side hooks into the deleted TS are rehomed so the workspace still
builds, tests, and releases green:
- op-mcp: vendor the 10 element-tool-defs shards it include_str!'d from
pen-mcp into crates/op-mcp/assets/element-tool-defs/ (git renames), and
repoint the compile-time includes + the runtime parity test's read_dir.
- op-orchestrator: reword the parity panic that named the retired
dump-planner-golden.ts generator (goldens stay as frozen baselines).
- CI: drop the TS-derive golden-drift + planner-parity oracle jobs and the
TS path filters from rust-check.yml; repoint the cargo-bundle icon in
rust-release.yml to crates/op-host-desktop/assets/icon.png.
- Release (Windows): recover the deleted apps/desktop/build/icon.ico into
crates/op-host-desktop/assets/icon.ico and repoint the NSIS installer
(rust-release.yml /DICON_FILE + package-windows.nsi ICON_FILE fallback +
its example invocation), so the Windows release build still finds it.
- Remove the dead .githooks/post-commit hook — it rebuilt the deleted
out/mcp-server.cjs via the removed mcp:compile script and only watched
now-deleted pen-* paths; the Rust MCP is built by cargo.
- Recover apps/web/public/logo-discord.svg to screenshot/ and repoint the
15 READMEs' logo/link paths.
package.json is now Rust-first (dev/build/test -> cargo + serve-web) and
docs (CLAUDE.md/AGENTS.md/packages+crates CLAUDE.md) describe the Rust
product. The retired TS remains recoverable via git (last tag v0.7.5).
Verified: cargo check --workspace green; cargo test -p op-mcp 353 passed;
cargo test -p op-orchestrator 848 passed; cargo test --workspace --no-run
compiles all targets; rust-release.yml parses and references no deleted
build assets.
Pre-commit fmt hook bypassed: its cargo fmt --check is pre-red from a
repo-wide stable-vs-nightly rustfmt config drift affecting unrelated files;
the .rs files touched here produce no fmt diff.
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.
- design-agent system prompt + in-process design toolset (parity-locked with
the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
(per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
bubbles, model-pill bottom toolbar, header, empty-state pills, the
PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
(switch / close / + / Cmd+T) with each run bound to its tab
Large checkpoint commit spanning the working tree (Rust shell crates).
Plan 3 of the web embedding SDK: @zseven-w/op-web-sdk-react and -vue — thin
read-only framework adapters over the TS core. Each: <DesignView> (canvas +
viewer lifecycle), provider/inject of OpViewer, and read-only
useDocument/useViewport/useActivePage. React via useSyncExternalStore
(cache-invalidate-on-event); Vue via an async shallowRef provider so children
inject the viewer after load. Self-contained (no pen-* deps; framework peer);
React 5/5 + Vue 4/4 tests; both tsc clean.
Plan 2 of the web embedding SDK: a self-contained, framework-agnostic
TypeScript core wrapping the Plan-1 wasm Viewer. createViewer -> OpViewer with
load, read-only snapshots (document/pages/pageCount/activePage), pan/zoom
navigation (+ wheel), SVG export, load/viewportchange events, and destroy.
Read-only: no .op editing/mutation. Self-contained: zero @zseven-w/pen-* deps
(types vendored from jian-ops-schema ts-rs), tsup build, vitest+jsdom tests
that mock the wasm seam. 10/10 tests; tsc clean; post-destroy use-after-free
guarded. React+Vue adapters follow in Plan 3.
The subtask prompt contradicted itself: the jsonl-format skill taught
flat `_parent` while NODE_FORMAT (appended last, so dominant) demanded
a JSON array with nested `children`. Weaker models followed the last
instruction and emitted flat, unnested nodes, collapsing every layout
to a vertical stack. Align NODE_FORMAT and the basic-tier simplified
skill (both Rust + TS copies of the corpus) to flat `_parent`, and make
the user-prompt format line format-agnostic so it conflicts with neither
tier's skill.
Codex stop-hook on the prior strip-nested-card-decoration commit
caught a regression: cornerRadius on a media-clipping frame
(`clipContent: true` wrapping an image / video, or roles like
`image-placeholder` / `thumbnail` / `cover-image`) is doing the
rounding work for the photo, not stacking card decoration. Blanket
stripping un-rounded the media against the user's clear intent —
typical pattern is
card { cornerRadius: 16, clipContent: true }
└─ image-placeholder { cornerRadius: 12, clipContent: true }
└─ image
where the inner cornerRadius rounds the photo and the outer rounds
the card frame around it. After the prior pass the inner radius got
stripped (ancestor had cornerRadius too) → square corners on the
photo.
New `MEDIA_CLIP_ROLES` set + `isMediaClipper(node)` helper:
- role match: image, image-card, image-placeholder, video,
video-placeholder, media, media-thumbnail, thumbnail, cover,
cover-image, gallery-item
- shape match: clipContent: true AND has a direct image / video /
media-roled child
Either signal preserves cornerRadius. Other decorations (stroke,
shadow) still get stripped — those ARE redundant card decoration
even on a media wrapper, since the photo's own outline + the
ancestor card already provide the visual frame.
Tests: 2 new cases — clipContent + image, and the role-only path
covering image-placeholder / thumbnail / cover-image / gallery-item.
User-reported 2026-05-11 "Popular Restaurants" — inspecting the live
canvas via batch_get showed the LLM built each row as
`role:card` (outer) carrying stroke + cornerRadius:16 + 2-shadow
elevation, then nested an inner `Card Info` frame ALSO with
`role:card`, cornerRadius:12, and the SAME 2-shadow stack for the
right-hand text column. The doubled decoration rendered as a
visible "border" / box-in-box that the user called out as the
N-tools being "死板" — element-builders deterministically emit
their own card decoration without knowing they're being nested.
New post-pass `stripNestedCardDecoration` walks the page tree and,
for each non-protected frame:
- if the frame has stroke AND any frame ancestor has stroke → strip stroke
- if the frame has cornerRadius > 0 AND any frame ancestor
has cornerRadius > 0 → strip cornerRadius
- if the frame has shadow AND any frame ancestor has shadow → strip effects
Each decoration type is checked independently so e.g. a card inside
a shadow-only ancestor still keeps its cornerRadius. Fills are NOT
touched — stripRedundantSectionFills already handles fill heuristics
and a child fill may be an intentional surface change (dark accent
strip inside a white card).
KEEP_DECORATION_ROLES exempts elements that legitimately carry their
own affordance even when nested in a card: button, chip, search-bar,
input, badge, avatar, switch, etc. Those keep their click-target
visual whether or not the parent is decorated.
Wired in apps/web design-canvas-ops.ts at both finalize sites,
running AFTER stripRedundantSectionFills so the fill pass gets first
crack and this pass cleans up the leftover stroke/cornerRadius/
shadow stack.
Tests: 8 cases — basic strip, partial strip (only matched types),
top-level decoration preserved, protected-role exemption, fills
untouched, deep nesting, asymmetric cornerRadius arrays, no-op
return value.
User-reported 2026-05-11 mobile food design — the page had Header
(search bar + cart), Categories (icon row), and Bottom Nav each
carrying their own horizontal padding by design, but Hero section
left its frame edge-to-edge intentionally. Previous version saw
Hero's missing padding + ≥1 offending child and flagged → root got
+16px gutter on top of every per-section-padded sibling, producing
a visible double-inset / "边距过大" complaint.
Treat any non-fullbleed content child carrying its own h-padding as
a signal that the design has chosen the per-section gutter mode.
Once that signal is observed, skip the root-level recommendation
entirely so we don't double up. Hero / banner / image-bleed roles
remain filtered out of the signal pass via FULL_BLEED_ROLES so a
hero with no padding still doesn't activate the detector.
Test: covers the user's exact pattern (categories + content with
per-section padding + hero without) — previous expectation flipped
from "fire" to "do not fire".
Codex stop-hook on the prior per-node try/catch caught a leak: the
catch logged but didn't roll back canvas state. drawNode pushes
canvas.save() once per ancestor clipStack entry (node-renderer.ts:548)
plus more for rotation / flip (574, 583) and per-shape sub-paths
(701, 1094, 1102). If drawNode throws mid-loop, every save() between
its entry and the throw stays on the stack — the next node's draw
operates inside a leaked clip / leaked transform, and the canvas
either renders nothing or renders to the wrong region.
Snapshot canvas.getSaveCount() before each drawNode call; on catch,
canvas.restoreToCount(saveCount) pops everything back to the
baseline. Wrap the restoreToCount itself in a no-op catch since it
can throw if the snapshot count is somehow above the current depth
(shouldn't happen but guarded so the error reporter still runs).
Net effect: per-node failures are now genuinely isolated. The
canvas state at the start of each iteration is identical to where
the previous iteration left it; one bad node can't smear its leaked
state across the rest of the frame.
User reported "为什么画布是空的" — Bistro DeepSeek generation, layer
panel populated with the root frame but canvas fully blank mid-stream.
The MCP-side document showed children, the UI-side layer tree showed
the root, but no pixels rendered. Two structural issues converged:
1. ShadowEffect TS type marks offsetX / offsetY / blur / spread as
required, but LLM-emitted shadows routinely omit them
(`{type:'shadow', blur:3, color:'#0000001A'}` with no spread). The
prior (and the new shadow-cornerRadius) code multiplied the missing
field through cornerRadius / RRectXY math, producing NaN. CanvasKit's
RRectXY throws on NaN inside the WASM module, the throw escapes
drawNode (renderer.ts:326 had no try/catch), and the entire render
loop aborts past the bad node — so even unrelated siblings stop
drawing. User sees a fully empty canvas despite document state
being intact.
2. The drawNode loop had zero error isolation — a single malformed
node could blank the whole frame. Structural fragility independent
of the NaN bug; any future renderer regression would have the same
symptom.
Two fixes:
- applyShadowDirect coerces missing / non-finite shadow numeric
fields to 0 before any math (offsetX / offsetY / spread defaults
to 0; blur defaults to 0 and clamps non-negative). NaN can't reach
CanvasKit. The pre-existing drawRect path also benefits — the old
code happily fed NaN to drawRect via `x + shadow.offsetX -
shadow.spread`, just relied on Skia's tolerance for some NaN cases.
- renderer.ts wraps drawNode in per-node try/catch with a console.error
on failure. A bad node now logs and skips; siblings render normally.
Defense-in-depth so the next renderer regression doesn't blank the
canvas.
Codex stop-hook on the previous shadow commit caught: the in-function
clamp `Math.min(maxShadowRX, cornerRadiusX + spread)` looked correct
in isolation but diverged from the body's actually-rendered curve
when cornerRadius exceeded the body's half-extent.
Concrete: 60×60 frame with cornerRadius=100, spread=4.
- Body's drawRRect at L664 clamps to min(100, 30) = 30. Body curve = 30.
- Old shadow path: shadowRX = min(34, 100+4) = 34. Shadow curve = 34.
- Result: shadow corner sticks out past body corner by 4px on all sides
(visible on canvas — same "尖角" complaint, just at clamp boundary).
Architecture: push the body-clamp out of `applyShadowDirect` and into
the call site, so the function's contract is "input radii are already
the body's rendered radii — I just add spread + clamp to my own
half-extent". Shadow stays in lockstep with whatever the body
actually drew, by construction.
- Frame / rectangle / image: caller passes
Math.min(cornerRadius, Math.min(w/2, h/2)) for both rx and ry.
- Ellipse: caller passes (w/2, h/2) — matches drawOval outline.
- Path / line / polygon: caller passes 0/0 → plain drawRect.
Tests: pen-renderer 5 / 46 still passes (unchanged functional surface
area; the change is internal to the radii contract).
Codex review on the prior shadow-cornerRadius commit caught two
edges:
Q4 — shadow radius needs upper-clamp like the body's drawRRect.
node-renderer.ts:643 / :1108 already guard `Math.min(cr, maxR)` so
a too-large cornerRadius doesn't degenerate the rrect; the shadow
path was missing the same clamp. A 60×60 ellipse with
spread=4 + cr=34 would emit raw rx=38 which exceeds the
spread-expanded rect's half-extent and visibly distorts. Now clamps
to half-extent of the spread-adjusted rect on each axis.
Q5 — ellipse shadow rx/ry should be independent. The previous fix
mapped ellipse → cornerRadius = min(w,h)/2, which produces a stadium
(pill) shadow when w ≠ h. Splitting the param into independent rx /
ry lets the call site pass (w/2, h/2) for ellipse, matching the
body's drawOval outline for the asymmetric case while staying
identical for symmetric circles.
Frame / rectangle / image stay at rx === ry === cornerRadius. Path /
line / polygon stay at 0/0 → plain drawRect.
Tests: pen-renderer 5 / 46 still passes (no rendering-result coverage
to extend; the change is exercised at the next renderer reload).
User-reported 2026-05-10 "圆角元素的尖角阴影" — rounded cards / hero
images had visibly square-cornered drop shadows poking out from
under the rounded shape. Forensic root cause: applyShadowDirect at
node-renderer.ts:447 was always drawing the shadow as a plain
`canvas.drawRect(...)`, completely ignoring the node's cornerRadius.
A frame with cornerRadius=24 + a subtle drop shadow would render the
rounded body cleanly but stamp a sharp-cornered shadow rectangle
just behind it, with the rectangle corners visible past the rounded
outline.
Fix: pass the node's cornerRadius into applyShadowDirect; when > 0
use `drawRRect` with `RRectXY(rect, cornerRadius+spread, ...)` so the
shadow's rounding stays parallel to the node's rounding (the +spread
correction keeps the visible curve aligned when spread expands /
contracts the bounds).
Ellipse / circle nodes (avatars, status dots) get cornerRadius =
min(w,h)/2 from the call site so their shadows render as stadium /
circle. Asymmetric-aspect ellipses get a stadium approximation
rather than a true ellipse — accepted simplification, the common
case is symmetric (avatar / dot).
Path / line / polygon nodes have no cornerRadius and fall through
with cr=0 — rectangular shadow stays correct for them.
This is a renderer-layer fix that detector-only paths can't reach;
ships in the same session as the typography / spacing detectors so
the user sees end-to-end aesthetic improvement on the next rebuild.
14th pre-validation detector + a preventive skill rule.
User-reported 2026-05-10 "Bistro" mobile food app shipped with root
padding [0,16,0,16] AND a "Today's Specials" section padding [0,24].
Effective gutter = 40px on a 375px page → only 295px of usable
content width. Reads as "too much padding" / pinched.
Two pieces:
1. layout.md AESTHETIC HYGIENE block now teaches "page gutter goes
on ONE layer, not both" — pick root horizontal padding OR
per-section horizontal padding, not both. Default convention:
root carries the gutter, sections set vertical-only padding.
Hero / banner / image-bleed sections then sit edge-to-edge by
simply NOT adding horizontal padding (root's gutter shows
through). Preventive teaching at prompt time.
2. detectStackedHorizontalPadding (info-only, detect-only). Walks
every mobile-shaped root (width 320–480 + tall + multi-child),
compares root horizontal padding against each direct child's
horizontal padding; flags the section as the offender when both
are > 0. Page-shape filter mirrors detectEdgeSectionPadding so
the legitimate component-internal padding stacking pattern
(chip → badge → icon, etc.) doesn't trip it. Severity is INFO
because a section may legitimately want a deeper inset for
visual emphasis — let the user/agent decide via audit panel.
Side-quest: scripts/ab-corpus/check-stacked-padding.ts ships with
this commit so the next stacked-padding-style detector calibration
can survey corpus frequency without rebuilding the harness.