The family-aware CanvasKit change added a `family` param to drawText and
moved the browser/system-font fallback fast path into the shared
`drawScriptRun` helper. Re-anchor the 'fallback before Paint allocation'
assertion on drawScriptRun where that invariant now lives.
The native File ▸ Open Recent submenu was only refreshed after native-menu
actions + Finder opens; an in-canvas File-menu open/save (which never
reaches handle_menu_action) left it stale. Refresh every loop iteration
instead, rebuilding the muda submenu only when the labels actually changed
(cached in recent_menu_labels) — cheap, and correct regardless of which
path touched the recent list.
Add an Open Recent submenu to the native File menu, populated from
editor_ui.recent_files (file names, newest first; each item id
`recent:<index>`). MenuAction gains OpenRecent(usize), dispatched through
the existing FileAction::OpenRecent path. The muda submenu is rebuilt
(set_recent_files) whenever the recent list can change — at startup and
after any menu action / Finder open — with a disabled "No Recent
Documents" placeholder when empty. No-op off macOS.
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.
The Imported group header + "Import font…" row painted hardcoded English
literals. Add text.font.imported / text.font.importAction to all 15 locale
tables and translate them via op_i18n like the neighbouring bundled/system/
noResults labels, dropping the placeholder consts.
A fresh from-scratch canvas holds only the blank starter frame, and that
frame ships selected. A whole-new-design request phrased as a bare noun
phrase (no page/screen noun, no create verb — e.g. "Luxury webapp for
managing barbershop clients") slips past both new-screen exemption gates,
and the selected starter makes selected_target_instruction true, so the
request was launched as a modify turn on a near-empty canvas — the model
then emits an unparseable fragment and the user sees "Could not parse
design nodes". Guard should_launch_direct_modify: a canvas that is only
the blank starter frame has nothing real to modify, so any request on it
is NEW (desktop parity of web_chat_standard's page_children_empty => New).
A single provider rate-limit (HTTP 429) or overload (503/529) on one
design sub-agent request had no recovery: no client-side pacing to stay
under the RPM limit, and no backoff-retry once tripped — so the section
burned its attempts and the run reported a failed subtask while the rest
of the design was fine. Add a process-wide min-gap throttle (default
350ms, env-overridable) and transparent backoff-retry that honors
Retry-After (else exponential 1/2/4s, capped) around both the openai-
compatible and anthropic send paths. Benefits every builtin path
(orchestrator, design loop, chat). retry.rs stays unchanged: 429 remains
non-retryable at the ladder level since the http layer already backed off.
A section frame with equal L/R padding wrapping a single transparent
fill-width frame that ALSO carries equal L/R padding double-insets its
content (measured: header content at 40px vs sibling sections' 20px).
Add a cleanup pass that zeroes the inner wrapper's horizontal padding
(preserving its vertical padding and the outer section padding), gated
so real cards (with fill/stroke/effects) and multi-child sections are
never touched. Wired into run_cleanup_passes + loop_finalize.
The prior fix invalidated the layout scene for imported fonts but the
async system-font load path (load_used_system_fonts -> register_system_font)
had the same gap: it marked dirty + repainted but did not invalidate the
scene cache, so text using a just-loaded system family kept a layout scene
shaped/measured against the fallback glyphs (web has no jian_skia
font-generation signal). Call invalidate_layout_scene() there too. Every
web runtime font-registration path (system + imported import/remove/
mount-restore) now forces the rebuild.
The web SceneBuildCache never invalidates on a font change: op-host-web
has no jian_skia font registry, so current_font_generation() is a constant
0 there, and a font import/removal/restore doesn't touch the doc/page/
theme the cache compares. So refresh_layout_scene's maybe_rebuild returned
None and the layout scene stayed stale (shaped/measured against the old
fonts) after a restore. Add WidgetHost::invalidate_layout_scene() and call
it from refresh_imported_font_snapshot (covers mount-restore + import +
remove) to force the rebuild. Native already handles this via the
layout_scene_font_generation watch (its registry IS jian_skia).
Bring user-imported fonts to the browser host, matching the native flow.
Phase 3 — family-aware CanvasKit text (the web BLOCKER): drawText now
carries font_family and a new measureTextFamilyStyled FFI mirrors it, so
the editor measures caret/layout with the same family it draws (closing
the family-blind trap). op_ck_bridge.js keys imported typefaces by family
and resolves them PER CHARACTER: chars the imported face covers draw with
it, the rest fall to the existing script-segmented system/CJK/emoji path
(no tofu, no dropped family) — draw + measure split on identical
importedCoverage segments so advances agree. register/removeImportedFont
with replace + wasm-heap free.
Phase 4 — import UI + persistence: web ImportFont opens a hidden
.ttf/.otf file input -> FileReader -> 16 MiB cap -> family parsed in Rust
via ttf-parser (font_meta.rs; the vendored CanvasKit exposes no
getFamilyName) -> register + persist bytes in IndexedDB (font_store_idb.rs;
DB openpencil / store imported_fonts, keyed by family, async errors
logged) -> refresh snapshot + repaint. Remove drops registry + IndexedDB.
Mount re-registers persisted fonts (skipping any the user already changed
this session) before the first family-aware paint. font_import_supported
is true on web, so the picker's imported group + Import row are live.
font_meta family extraction is unit-tested (real .ttf bytes -> family)
so the core is verified headlessly; runtime IndexedDB/FileReader/rendering
need a browser smoke test. Codex-reviewed (2 rounds) — getFamilyName
BLOCKER, mixed-script fallback, IndexedDB async errors, and the mount
race all addressed.
FontStore::import deleted the old file of a replaced face DURING the
index-mutation retain — before save_index. If save_index then failed, the
previously persisted font was already gone while the on-disk index still
referenced it, so the prior font was lost on a failed replacement import.
Collect the superseded files and delete them only after save_index
succeeds. New test replacement_import_prunes_the_old_file_only_after_saving_the_index.
FontStore::import registered the font in the process-global registry
(bumping the generation) BEFORE writing it to disk. A failed
create_dir_all/write/save_index then left the font live + rendered this
session but unpersisted — while the caller reported the import as failed
and popped an error dialog. Reorder to parse (no registry mutation via
the new jian_skia::parse_imported_font_meta) -> persist to disk ->
register last, so the live registry is only mutated once persistence is
durable. New test failed_persist_does_not_leak_into_the_live_registry
(file-rooted store forces a disk failure) pins it.
Bumps vendor/jian to the parse_imported_font_meta commit.
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.
New op-host-desktop/src/fonts.rs FontStore persists user-imported faces
under <config>/fonts/ (raw file per face + index.json). import() caps at
16 MiB, validates+registers via jian_skia::register_imported_font, then
copies the file in and records a last-import-wins index entry. remove()
drops a whole family from disk and the live registry. rescan_and_register()
re-registers every persisted face at startup (main.rs + render_cli.rs,
right after bundled_fonts::register), dropping missing/corrupt entries
without blocking launch. Tests: import->rescan->remove round-trip +
oversize/corrupt rejection.
--no-verify: pre-existing chat_session.rs fmt drift from a concurrent
session; my files are rustfmt-clean.
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.
* feat(desktop): add window-edge resize for borderless Windows/Linux
macOS keeps its native NSWindow decorations, so edge-resize cursors and
drags come for free. Windows/Linux create the window with
decorations(false) â truly borderless, no OS-provided resize band â so
the four edges couldn't be dragged and the cursor never changed.
Synthesize a 6px edge-resize ring: a pure hit-test maps the cursor to a
ResizeDirection (corners take priority for diagonal grabs), CursorMoved
shows the matching resize cursor ahead of every panel/canvas hint, and a
press hands the drag to the OS via drag_resize_window. Both call sites
are gated to non-macOS and skip the maximized state; macOS is untouched.
* feat(desktop): remove native muda menu on Windows
Gate muda native menu bar to macOS only. Windows creates a borderless
window with custom chrome; the native menu drawn inside the client area
would flash during drag_resize_window() when the OS repaints the window.
Windows now uses the in-canvas File menu instead (same as Linux).
The 1a543c91 flip to loop-default regressed builtin-provider (e.g. GLM-5.2)
mobile generation: the loop skips the orchestrator scaffold, so designs
shipped with NO preset status bar and a degraded node vocabulary
(no path/rectangle/text_input), while the same model on a CLI provider
(orchestrator path) produced a polished screen with the full status bar.
Restore opt-in semantics: default = single-shot orchestrator (scaffold +
role-resolver), loop enabled only via the Settings experimental toggle or
OPENPENCIL_DESIGN_AGENT_LOOP=1|true|on. Re-introduce the loop as default once
it grows the same scaffold chrome.
The K() kit-catalog guidance added to design-agent.md (prefer K() for
standard controls before hand-drawing primitives, + a 37-entry catalog)
regressed loop generation: the model abandoned its working hand-drawn
primitives (text_input, rectangle, icons) to chase kit instantiation that
doesn't land well in the loop, degrading output to a churn of bare
frames+text. Revert the prompt push to the pre-catalog state; the K() op
itself stays available in the DSL, just not pushed.
Chinese-prompt mobile designs rendered two bottom navs (e.g. 底部导航栏 +
Bottom Navigation). The mobile-chrome bottom-nav matchers were
English-only, so a CJK-named nav was not recognized — it wasn't anchored
or merged, and a second English nav survived. Add Chinese synonyms
(底部导航/底部导航栏/导航栏/底栏/标签栏/底部标签栏) to both matchers, and add a
root-level dedup that keeps the bottom-anchored nav and removes redundant
bottom-nav sections. Wired into cleanup + loop_finalize (both paths).
Narrow guards: mobile artboards, >=2 detected navs, never the sole nav,
top navbars untouched.
The native press path closed the new Effects "+" add-menu on an
outside click, but the web host had no matching dismiss, so the menu
could get stuck open. Mirror the fill-type-picker dismiss: apply a row
action, swallow inside clicks, close on any outside click.
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 effect-add fixes pushed color_picker.rs past the 800-line ceiling.
Move its test module into color_picker_tests.rs via #[path]; no
behavior change.
Committing history before checking the target left an empty undo +
dirty state when the selection was a component Ref / IconFont (or an
unresolvable anchor) — nodes that carry no effects list. Route both
adds through a shared add_effect_to_selected that peeks node support
first and only snapshots history when the mutation will land.
The Layer Blur add-button did nothing on the web host (its property
dispatch had no AddLayerBlur arm) and neither effect add was undoable
on any host (the add path never snapshotted history). Commit history
inside add_drop_shadow_to_selected / add_layer_blur_to_selected so both
adds undo on every host, and handle AddLayerBlur in the web dispatch.
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.
Generation could only hand-draw components: the built-in shadcn (31) and
starter (6) UI-kit components were reachable solely through the retired
insert_* MCP tools, never from the design pipeline. The batch_design DSL
already instantiates via C(), but C() clones only nodes already present
in the document, so it can't reach the kits.
Add a K(kitComponentId, parent, overrides) op that routes through the
existing (tested) InstantiateKitComponent command, now extended with
parent-aware placement + overrides. Kit ids use a shadcn/<id> and
starter/<id> scheme. Overrides support top-level keys, recursive
descendants matching by template id (reusing ref_resolve::apply_overrides),
and a label/text convenience; applied before the fresh-id remap. The
sandbox script runner records K() like I()/C(). design-agent.md gains a
compact 37-entry catalog nudging the model to prefer K().
Note: mechanism is verified (unit tests + full gate run); whether the
model adopts K() and improves output is a pending self-loop measurement.
Button/icon foreground was chosen by perceptual luminance alone
(`lum < 0.5 ? white : dark`). A raw-hex saturated fill like #F97316
scores 0.567 so it got a dark #0F172A icon — the reported orange-bg +
dark-icon defect. Only $color-accent-style TOKEN refs were rescued;
raw hex leaked. Add saturation-aware `preferred_foreground_for_bg`
(white when luminance<0.5, or saturation>=0.5 at luminance<=0.72) and
route the button pass through it. Pale amber warning fills and bright
yellow keep dark text. Runs on both the design loop (loop_finalize) and
orchestrator (cleanup) paths.
An auto-width (non-wrapping) text node's box was sized by the authoring
app to hug the text in its own font. When that font is unavailable and
a wider fallback is used, the text overflows and gets clipped by the
parent frame (e.g. imported Figma titles clipping to "No. 3 dr").
Scale the font down so the widest line fits the authored box, floored
at 35% so it stays readable. Wrapping text is untouched (it breaks
lines instead).
The pages-row-height scroll fix pushed layer_panel.rs past the 800-line
ceiling. Move hit_test + drop_target_at into layer_panel_hit.rs via an
impl block; no behavior change.
The Pages and Layers regions use different row heights (32px vs 28px),
but visible_row_range / row_index_at hardcoded LAYER_ROW_HEIGHT for
both. With a long page list scrolled toward the bottom, the pages
window started at the wrong index — only the last page rendered and
the rows above it were skipped, leaving a blank gap. Thread the
region's row height through both helpers.
The narration-collapse change switched the pump to
apply_poll_to_message_with; apply_poll_to_message is now used only by
the cfg(test) test module, so the bin build re-exported it unused and
`cargo clippy --workspace --all-targets -- -D warnings` (Rust Check)
failed. Move it under the existing #[cfg(test)] re-export alongside
ChatPoll.
The script-gen comment claimed '完全对齐 Pencil' for what is now the
orchestrator single-shot FALLBACK path — it aligns only the output
protocol (a JS DSL like Pencil's batch_design), not Pencil's defining
per-batch feedback loop, which lives in the sonar design-agent loop
(the builtin-provider default). Corrected per the alignment audit.
The design agent loop streamed the model's free-text chatter between
tool calls ('Let me build the header... Now the deals section...')
into the visible transcript bubble — noise, since the tool-call
checklist already shows clean progress (measured on a MiniMax-M3 run).
A design-loop ChatSession now folds that narration into the collapsed
thinking area instead; plain chat and CLI turns keep it visible, and
errors always surface in the bubble regardless.
A weak model on the loop path can rebuild-and-abandon at the artboard
level, leaving two same-named top-level frames — a sparse opaque stub
(a few nodes) overlapping the real design. The stub's fill covers the
real artboard's top, blanking it. Detect same-named overlapping roots
where one holds under 30% of the other's descendants and drop the
sparse stub, keeping the rich one. Side-by-side authored roots, single
roots, and two comparably-full roots are left alone. Runs in both the
loop finalize and the orchestrator cleanup.
The accent-token contrast fix intentionally makes $color-accent
buttons flip children to white instead of skipping; retarget the
skip-on-unresolvable test at a genuinely non-accent token so it still
guards that path.
The button-contrast pass bailed whenever the button fill was a design
token (resolve_color_maybe_ref returns None for any $ref), so an
accent-filled button — the common orange filter/action button — kept
the model's default-dark icon (measured: a sliders icon at #0F172A on
a $color-accent button, unreadable on orange). Brand-accent tokens
($color-accent / primary / danger / error / success) always bind to
saturated mid-dark colours needing a white foreground, so treat that
bg as dark and let the existing override flip the children. Surface /
warning tokens are left alone.
Retire the flat-JSONL retry rung. Every subagent rung now emits a JS
program (script-gen); reduced_complexity and minimal_skills only
narrow the loaded skill set — they no longer switch the output format
to positional _parent JSONL, whose omittable parent field collapsed a
whole tree into flat siblings when a model skipped it. script-gen's
I(parent, node) makes parenting a positional argument that cannot be
dropped, and the reasoning-harvest fix made it robust across models.
parse_nodes stays for the modify/chat paths that still consume flat
node JSON; the jsonl-format generation skills are removed.
A rectangle is a container in the canonical schema — it carries
clipContent like Frame/Group and models nest content inside one (an
image-area rectangle wrapping a photo, a card body, a badge holder).
The painter's NodeKind::Rect branch drew the rectangle's own fill and
returned without recursing, so every child of a rectangle vanished
behind that fill. Measured: an AI-generated travel page whose seven
destination photos each sat inside an image-area rectangle rendered
as blank cards despite the photos being fetched and embedded. Recurse
into the children (honouring clipContent) after the rectangle's own
paint, mirroring the Frame branch.
A component-swapped instance (overriddenSymbolID present) carries
derivedSymbolData for the swapped-in component, not its base symbolID.
Pooling or geometry-seeding it under the base component's cache pinned
wrong pk→node mappings that poisoned genuine base-component instances
reusing that cache. Skip swapped instances in both seeding passes.
A nested instance swapped via overriddenSymbolID keeps the pre-swap
component's derivedSymbolData alongside the swapped-in component's. When
the two frames are the same size the fingerprint can't tell them apart
and the stale (earlier-listed) cluster hijacks the mapping, sizing the
swapped frame wrong and clipping its icon. Cluster the derived entries
by localID and keep only the one that geometrically fits the swapped
subtree. Adds a near-exact geometric-match bonus so a near-perfect size
match outweighs the walk-order prior a stale sibling would otherwise
win on. Splits instance.rs (swap_filter.rs) and fingerprint_tests.rs
(foreign_tests.rs) to honor the 800-line cap.
Renders the Test.fig Sales-card pie icon and the other swapped card
icons at their correct size instead of clipped.
Replace the walk-order virtual-GUID guessing in Strategy 2 with an
evidence-based fingerprint (size / transform / text-class / fill hints)
plus cross-instance pooled + geometry pre-seeding, so nested instance
overrides land on the right nodes. Adds foreign-session subtree
anchoring with a uniform-family fallback, nested-derived field merge,
strong fill-hint routing (image / rare-solid) with conflict-vs-
inapplicable rejection handling, and a single-axis transform-drift
score tier. Splits instance.rs walk/apply helpers into apply.rs and the
test module into fingerprint_tests.rs to honor the 800-line cap.
Renders Test.fig order-row thumbnails, breadcrumb, sidebar logo,
summary-card filters, and status chips to match the Figma source.
A section-heavy new-design prompt ('Design a … page. Include a search
section …') trips is_section_add_request, so requests_new_whole_screen
returns false; the selection-modify bias then routed the whole prompt
into run_modify_turn, where M3's flat-JSONL output was renest-dropped
to nothing ('Could not parse design nodes'). Add a section-add-blind
creation-signal veto to both routing gates so a new-design request
reaches the design pipeline regardless of an active selection.
The script runner only stripped a code fence anchored at position
zero, and never stripped <think> reasoning at all. A reasoning model
that keeps its thinking (MiniMax-M3 rides Adaptive) prefixes the
program with a <think> block full of draft JS plus a prose lead-in;
that went to QuickJS verbatim as source, threw a syntax error, and
dropped the model onto the fragile flat-JSONL retry rung where
omitted _parent fields collapse the whole tree into flat siblings
under the root (measured: a full travel page, 44 nodes, all piled at
origin). Strip reasoning first, then extract the fenced block from
anywhere in the response. Models with thinking disabled (GLM) were
unaffected, which is why this read as GLM-only handling.
Reasoning models burn the whole output budget inside think blocks and
emit zero design nodes on a modify turn (measured on MiniMax-M3: the
turn died in analysis prose). Same policy the design subtasks already
use; the HTTP layer maps it per provider.
The A/B measured the loop ahead of the single-shot orchestrator on
audit cleanliness (11 vs 33 issues over 10 prompt pairs) at
comparable wall time, with the artboard seed guard closing its one
failure mode. OPENPENCIL_DESIGN_AGENT_LOOP=0|false|off opts back into
the orchestrator; CLI providers keep their existing path.