A recursive cleanup pass detects a horizontal row of >=3 vertical
icon+label tabs (a bottom nav bar, incl. a nested tab container) and makes
each tab fill_container + the row space_between, so tabs spread the full
width instead of bunching to one side and overflowing off-screen.
Replace the empty dashed-placeholder plot-area guidance (which left charts
blank) with a simple real bar chart via flexbox (layout=horizontal,
alignItems=end, value-proportional bar heights) instead of the layout=none
manual positioning the model mis-computes. Also add donut/ring concentric-
stacking guidance.
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.
Turn the product-row-overflow self-check from reject-and-retry into
accept-and-auto-fix (fixed-width cards -> fill_container, gap 12), and
skip clipContent scroll rows entirely. Extend the contrast fixer to any
container with a resolvable solid background (not just buttons) so light
text on a light chip flips to a legible foreground. Add a JS bracket
auto-repair in the script runner so a model's missing closing brace no
longer fails the whole subtask.
Geometry-driven post-passes on generated mobile screens: equalize
side-by-side card heights, collapse a bloating fill_container content
section on a fixed-height artboard, pad a clipping horizontal scroll row
so child strokes aren't shaved on any side, expand a collapsed absolute
(layout:none) image wrapper to its image height, and collapse nested
double horizontal padding. Wired into run_cleanup_passes + loop_finalize.
get_guidelines(category=style) returns a resolved style from an own-built
catalog (7 styles x 7 palettes) with OKLCH-based contrast validation, baked
into the design pipeline (loop prompt + orchestrator subagent instruction).
Also raise the per-subtask output budget 8000 -> 12000 so image-rich
data-list sections stop truncating mid-script.
The STATUS_ACCESS_VIOLATION is not confined to op-host-native/-desktop: any
crate whose tests reach a skia FontMgr (DirectWrite) via NativeBackend or
SkiaMeasure crashes when that test binary runs across parallel worker threads
(op-host-services surfaced next). Excluding crates one-by-one doesn't
converge. Instead run the entire workspace with --test-threads=1 on Windows
only; each binary is serialized (cross-process concurrency is safe), covering
every skia-touching crate at once. macOS / Linux stay parallel.
The Windows STATUS_ACCESS_VIOLATION surfaced in the op_host_native test
binary once op-host-desktop was serialized: op-host-native's backend::skia
tests create a skia FontMgr (DirectWrite) and segfault when run across
parallel test-worker threads. Extend the single-threaded run to cover
op-host-native as well as op-host-desktop in both CI workflows.
Windows runs multi-line run: blocks under PowerShell, which reports only the
last command's exit code. Keeping the workspace test and the serialized
op-host-desktop test in one run: block would let a workspace-test failure be
masked by the second command passing. Split into two steps so each fails the
job independently.
Mirror the rust-check.yml fix into the cross-platform host-test step: the
op-host-desktop test binary segfaults on the Windows runner when its native
(skia/DirectWrite) tests run across parallel test-worker threads. Run that
crate single-threaded here too.
op-host-desktop tests build native NativeBackends (skia/DirectWrite fonts);
across cargo's parallel test-worker threads this segfaults on the Windows
runner. Split them out of the parallel workspace run and serialize with
--test-threads=1.
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.
Update the vendor/agent pointer from a6f1897 to the current agent-rs
origin/main tip (ea27441). Verified: cargo check --workspace builds clean
against it; ea27441 is on origin so submodule checkout resolves in CI.
The test module's assert_eq! calls exceeded max_width on one line; rustfmt
wraps them multi-line. Committing the formatted form so cargo fmt --all
--check (the CI gate) passes on the committed tree.
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.