The rim was a polygon STROKE, and the trait's fallback draws a polygon
stroke as one capped line per edge - so every vertex of the densely
sampled arc notched and the rim's width visibly wobbled. Two fixes: the
native backend gains a real single-path, round-joined, antialiased
polygon stroke (every caller benefits), and the cursor's rim is now
FILLED geometry - the silhouette outset by an exact 1.6px, painted white,
then covered by the body. The halo layers clear the rim so the outline
reads crisp against both light and dark designs.
Settings > System gains a pencil-cursor picker - five silhouettes
(classic, rounded, chubby with a pink eraser, crayon, round-nib marker)
drawn as live swatches, rounded as the default, choice persisted in
~/.openpencil/ui.json and restored on launch. Design-loop narration
additionally streams as visible prose instead of folding into the
collapsed thinking area, and every tool call stamps the content offset
where it landed so the transcript can interleave prose with per-call
verb chips.
The pinned checklist duplicated what the transcript already renders
inline (action steps, subtask cards, verb chips, agent narration - the
Pencil-style reading flow), while eating a third of the panel's height.
The transcript is now the single progress surface; the checklist
widget, its hit/scroll wiring on both hosts and its ChatState fields
are removed.
Selecting a node INSIDE a component instance now routes property-panel
writes (color, stroke, size) through descendant overrides on the owning
Ref via virtual child anchors, instead of silently doing nothing.
Scope-time history snapshots are repaired against the routed Ref (both
the ref id and the routed display id) on the Arc-shared snapshot store,
so undo never resurrects a detached instance copy.
Preview now presents ONE framed root inside a fixed device frame —
Phone 390x844 / Desktop 1440x900, inferred from the root width
(<=500 -> Phone) with a floating 3-segment switcher (Phone / Desktop /
Canvas, i18n'd across all 15 locales) overriding inference until exit.
Content keeps authored size (no reflow, no content scaling); overflow
scrolls vertically inside the frame, clamped to the pinned-nav top
bound. A bottom nav (semantics.role=nav bottom-anchored, or the
flush/full-width/<=120px heuristic) pins to the frame bottom in a
second clipped paint pass and the content viewport shrinks past it.
One DeviceFrame struct owns every transform: paint, the screen->scene
inverses (strip + letterbox dead zones resolve at surface-resolution
time), and the per-gesture presentation capture, so drags never flip
surfaces mid-gesture. Device mode fails closed — no wheel / pan /
pinch / modifier-zoom ever reaches the editor viewport (pinch gets its
own host entry, split from the line-wheel path at the desktop call
sites). Reconcile now reports { repaint, switched } so warning-only
passes stop recentering; screen switches re-infer, reset scroll and
rebuild the frame.
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 chat transcript re-laid-out every message twice per paint and again
on every mouse move; the layer panel re-walked the whole tree per frame;
the icon, markdown, and font panels re-filtered per frame while open.
Each now resolves from an owner-scoped cache: the transcript builds once
per frame/event (value-hash key, last-resolver-owns slots, forced owner
rotation at every session mutation), layer rows key on document revision
plus page with rotation at every document replacement, and panel filters
recompute only when their query/content/generation inputs change,
returning shared Rc results.
- Call blur_input when agent settings panel opens (shortcut + click)
on both native and web hosts, so chat.focused becomes false.
- Guard agent_settings focus check with agent_settings_open in
input_active() to prevent stale focus from claiming keyboard.
- Reorder handle_cmd_paste to check non-chat inputs first, with an
additional !chat.focused guard.
Windows CI segfaulted (STATUS_ACCESS_VIOLATION 0xc0000005) because the
design worker thread runs SkiaMeasure layout concurrently with the UI
thread paint/measure, and skia Windows FontMgr (DirectWrite) is not
thread-safe. Wrap NativeBackend::{new,draw_text,enumerate_system_font_families}
in jian_skia::with_font_lock and bump vendor/jian to the commit adding
that global reentrant lock.
Also un-gate chat_intent_host_tests on Windows (the lock makes its
intra-test worker/UI concurrency safe) and harden the flaky
explicit_family_typeface_lookup_is_cached test with a
retry-until-clean-window against concurrent generation bumps.
A fresh starter canvas and a just-opened .op no longer ship the root
frame pre-selected, which both reads cleaner and stops a stray selection
from mis-routing a whole-screen design request into modify.
The FontStore + font-import tests register fonts via jian_skia (skia
FontMgr::new_from_data, DirectWrite on Windows) from cargo's parallel
test-worker threads, which segfaults (STATUS_ACCESS_VIOLATION) in Windows
CI — newly surfaced now that the submodule checkout resolves. Production
resolves fonts on the main render thread; macOS + Linux keep the coverage.
Gated with cfg(not(target_os = windows)) as the least-invasive fix.
Three related hover fixes:
- Font picker "穿透": the open popup is a floating overlay but the
cursor-move handler fell through to the canvas/layer hovers when the
picker hover was unchanged, so a node behind the popup highlighted.
update_font_picker_hover now CONSUMES the move + clears lower-overlay
hover while the cursor is over the popup.
- "Import font…" row had no hover wash: add font_picker_import_hover
state + PropertyPanel::font_picker_import_action_at hit + a
paint_button_feedback_wash on the ImportAction row (threaded through
the paint_font_picker signature).
- Web parity: op-host-web overlay_cursor now updates the Effects add-menu
effect_add_menu_hover (mirrors the fill-type picker branch), so the
Drop Shadow / Layer Blur menu highlights on web too.
The Effects "+" add-menu (Drop Shadow / Layer Blur) painted flat rows
with no hover feedback, unlike the sibling property-panel dropdowns.
Add effect_add_menu_hover to editor_ui (cleared on toggle/close), a
PropertyPanel::effect_add_menu_row_at hit helper, and a native
update_effect_add_menu_hover cursor-move pass (mirroring the export
picker) that highlights the hovered row with the standard muted wash.
Wire user-imported fonts into the Typography font picker and the native
import/remove flow.
- op-editor-ui (wasm32-clean): FontPickerEntry gains `imported`;
font_picker_entries builds Imported -> Bundled -> System groups.
Imported entries carry an inline remove-x; a bottom "Import font…" row
drives import. A new `allow_import` capability (threaded through layout/
hit/paint) omits that row where the host can't import, so web shows no
dead control. Split property_panel_typography.rs into +_paint/+_tests to
stay under the 800-line cap. New actions ImportFont / RemoveImportedFont.
- op-editor-core: editor_ui gains imported_font_families snapshot,
pending_font_import / pending_font_remove request flags, and
font_import_supported capability (default false).
- op-host-native: refresh_imported_fonts rebuilds the snapshot from
jian_skia::list_families; ImportFont/RemoveImportedFont raise pending
requests (family resolved against the same entries list).
- op-host-desktop: font_import_host drains the requests — import opens an
rfd .ttf/.otf dialog (size pre-checked via metadata before read) ->
FontStore::import; remove -> FontStore::remove; both refresh the
snapshot. DesktopApp seeds the snapshot + sets font_import_supported.
- op-host-web: passes the imported list; import/remove are no-ops until
the Phase 4 web file-input (row hidden via the capability flag).
Codex-reviewed (2 rounds) to APPROVED.
Thread the jian_skia font-registry generation through the native render
+ measure caches so a runtime font import reflows an already-open
document instead of keeping stale fallback-font layout.
- op-pen-loader: CachingMeasureBackend drops its memo map, and
SceneBuildCache folds font_generation into its rebuild-decision
inputs, whenever the generation advances (current_font_generation is
cfg-gated on skia-measure; const 0 for the estimate build).
- op-host-native: NativeBackend + the export EXPORT_BACKEND inherit the
refresh for free through their shared FontResolver. widget_host's
refresh_layout_scene now also rebuilds when the generation changed
(tracked in layout_scene_font_generation) since a font import does not
dirty editor_state; the generation is read before the initial scene is
built to avoid a constructor race.
- Tests: end-to-end measure-changes-after-register (native resolver) and
a host-level regression test for the scene-rebuild gate.
Bumps vendor/jian to the mutable imported-font registry commit.
Replace the two effect add-buttons with a single "+" that opens a
Drop Shadow / Layer Blur choice menu. The "+" toggles the menu; a row
click adds that effect and closes; Escape or an outside click
dismisses. Wired on both native and web hosts.
The effects section's "+" only ever appended a Drop Shadow, so there
was no way to add a Gaussian layer blur from the panel. Add a second
add-button (blur-circle) beside the "+" that appends a default
PenEffect::Blur to the selected node, backed by
add_layer_blur_to_selected / push_layer_blur. The two add-buttons emit
non-overlapping hit rects so a click resolves to one effect kind.
Figma "Layer blur" effects were imported into the canonical document
but dropped before rendering (the scene carried only drop shadows), so
blurred background shapes rendered sharp. Carry the layer-blur radius
through the adapter (NodePayload.layer_blur) into an Effect::Blur, and
wrap the node's paint in a Skia blur layer (save_layer + blur image
filter, sigma = radius/2 × zoom) at every paint return path. Bumps the
vendored jian for the scene Effect::Blur variant + painter blur hook.
The palette slot removal shifted the right cluster 28px right, and
the input-height width basis fix (measuring wrap against the real
inner width) made the old probe text wrap and lift the footer band;
probe with a single-line text at the new offsets.
An active canvas selection now biases intent routing to modify (the
selection IS the target — only an explicit new-whole-screen request
or a plain chat question escapes it), restoring select-then-ask
editing. The panel paints a selected-count chip above the input with
a clear affordance, so the armed state is visible. Chat-question CJK
keywords keep questions about a selection conversational, and the
footer hover math reserves the chip row like paint does.
Bump jian for the shared font resolver, path/line flex sizing, and
input-chrome measurement; route the native backend's draw_text and
measure_text_weighted through the resolver so fit_content boxes match
what actually paints (synthetic bold and multi-line heights included).
Preview (Play) mode becomes genuinely interactive: PreviewSession
gains phase-level pointer dispatch (Down/Move/Up/Hover) + wheel, and
the native host routes cursor moves (drag while pressed, hover
otherwise; floating overlays keep their cursor via over_topmost_panel
and the modal/color-picker guard chain), releases, and wheels (canvas
pan/zoom fallback when no onScroll consumes) into the runtime — so
sliders drag and onHoverEnter/onScroll actions fire. Non-bind:value
bindings compile at enter (binding_sites.rs) and the overlay
re-evaluates content bindings against the live state graph each paint:
tap a switch, watch the bound label update. Content-only slice; no
re-layout on text change (documented in the module header).
Note: preview tests require --features gl-host.
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.
The mesh/shader migration only papered over the compile errors: the
canvas dispatched Mesh bodies to a flat first-vertex fill and never
dispatched SceneNode.shader at all, even though the jian Painter trait
already carries fill_round_rect_mesh_gradient / fill_round_rect_shader
with real Skia implementations available. Port jian-skia's Gouraud
vertices + cached-RuntimeEffect paint onto NativeBackend (sibling
mesh_shader.rs, ShaderCache shared with jian-skia so sources compile
once), delegate through NativeFrameBackend, and route the canvas fill
dispatch through the trait methods — shader wins over gradient over
solid. CanvasKit/web inherits the trait's first-vertex / fallback-solid
defaults (documented parity gap, same as jian's own non-native
painters). Pixel-level raster tests prove interpolation actually
happens and a compiled shader beats its fallback.
Four Windows runtime defects from the platform audit:
- The binary stayed in the console subsystem, parking a console window
behind the GUI when launched from Explorer. Release builds now set
windows_subsystem = "windows"; debug keeps stderr tracing visible.
- Background CLI probes (model discovery, provider version checks) and
the vendored Claude SDK's per-turn spawns lacked CREATE_NO_WINDOW,
flashing console windows once the GUI detaches from the console.
- MCP stdio servers naming .cmd/.bat shims (npx and most npm-installed
servers) could not spawn: CreateProcess cannot execute shims and Rust
1.77+ refuses them as program names. vendor/agent now resolves the
command PATHEXT-style against the PATH the server will actually see
(per-server env override wins) and routes only genuine shims through
cmd /c — real executables keep direct spawn semantics.
- cmd /C start truncated URLs at `&` (every OAuth authorize URL). The
URL now travels double-quoted via raw_arg so cmd keeps it literal.