Commit graph

2074 commits

Author SHA1 Message Date
Kayshen-X 4210fccabb test(web): update paint-order guard for the drawScriptRun refactor
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.
2026-07-05 19:18:47 +08:00
Kayshen-X 3b19a16055 fix(desktop): keep Open Recent in sync after in-canvas file actions
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.
2026-07-05 18:44:28 +08:00
Kayshen-X b7a55b6831 feat(desktop): File ▸ Open Recent submenu in the native macOS menu bar
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.
2026-07-05 18:37:39 +08:00
Kayshen-X 9c99e20e97 fix(panels): font-picker hover bleed-through + Import-row hover + web effect-menu hover
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.
2026-07-05 17:55:13 +08:00
Kayshen-X bf955e4dc2 fix(panels): hover highlight for the Effects add-menu rows
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.
2026-07-05 17:55:12 +08:00
Kayshen-X 5755719696 i18n(panels): localize the imported-font picker labels across 15 locales
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.
2026-07-05 17:55:11 +08:00
Fini ed50b1b5b8 fix(ai): never route a pristine-starter canvas into direct-modify
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).
2026-07-05 16:52:02 +08:00
Fini 768ea37d7b fix(ai): add request throttle + 429/503 backoff-retry to builtin http provider
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.
2026-07-05 16:52:01 +08:00
Fini 20606cd632 fix(orchestrator): collapse nested horizontal padding in structural wrappers
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.
2026-07-05 16:52:00 +08:00
Kayshen-X b4036617da fix(web): rebuild layout scene on system-font load too
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.
2026-07-05 14:21:53 +08:00
Kayshen-X 3964009221 fix(web): rebuild layout scene on font import/restore
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).
2026-07-05 14:21:52 +08:00
Kayshen-X a218115010 feat(web): user font import — family-aware CanvasKit + import UI + IndexedDB (phases 3-4)
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.
2026-07-05 14:21:51 +08:00
Kayshen-X 00c6938c26 fix(desktop): prune superseded font file only after the index is saved
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.
2026-07-05 14:21:50 +08:00
Kayshen-X 53da54ab8b fix(desktop): persist imported font before mutating the live registry
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.
2026-07-05 14:21:49 +08:00
Kayshen-X 892705cf79 feat(panels): font-picker import UI + native import/remove dispatch (phase 2b)
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.
2026-07-05 14:21:48 +08:00
Kayshen-X 1c65e51aee feat(desktop): native imported-font persistence + startup rescan (phase 2a)
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.
2026-07-05 14:21:47 +08:00
Kayshen-X 2b3c883b71 feat(renderer): font-generation invalidation for imported fonts (phase 1)
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.
2026-07-05 14:21:46 +08:00
leinaldo 2aa788dadf feat(desktop): window-edge resize for borderless Windows/Linux (#171)
* 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).
2026-07-05 11:12:19 +08:00
Fini 5328f69db4 revert(ai): default builtin providers to the orchestrator; make the loop opt-in
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.
2026-07-05 04:34:50 +08:00
Fini 7ae6721157 fix(ai): drop the 'prefer K()' kit-catalog push from the design-loop prompt
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.
2026-07-05 04:01:09 +08:00
Fini c734f98dfb fix(orchestrator): recognize CJK-named bottom nav + dedup duplicate bottom-nav sections
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.
2026-07-05 02:40:10 +08:00
Kayshen-X 2e8bf40f47 fix(editor): dismiss the Effects add-menu on outside click on web
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.
2026-07-04 21:52:27 +08:00
Kayshen-X 3480abaa52 feat(editor): merge effect adds into one "+" with a Shadow/Blur menu
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.
2026-07-04 21:47:35 +08:00
Kayshen-X a77ecf5c71 refactor(editor): split color_picker tests into a sibling file
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.
2026-07-04 20:59:22 +08:00
Kayshen-X 34257a65c5 fix(editor): skip history snapshot when the effect target can't take one
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.
2026-07-04 20:52:51 +08:00
Kayshen-X c143f599b0 fix(editor): make effect adds undoable + wire Layer Blur on web
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.
2026-07-04 20:43:12 +08:00
Kayshen-X d187eb724d feat(editor): add a Layer Blur button to the effects section
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.
2026-07-04 20:32:30 +08:00
Kayshen-X db20f1890c feat(editor): render imported Gaussian layer blur
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.
2026-07-04 20:05:42 +08:00
Fini ab2a32a806 feat(mcp): add K() batch_design op to instantiate built-in UI kits
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.
2026-07-04 19:51:59 +08:00
Fini 1db44c035c style(desktop): order re-exports so cargo fmt --check passes 2026-07-04 19:08:15 +08:00
Fini 61a5f1cc40 fix(orchestrator): give saturated accent fills a white foreground
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.
2026-07-04 19:08:14 +08:00
Kayshen-X cbeed1ce3a feat(editor): shrink auto-width text to fit its box on font fallback
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).
2026-07-04 19:04:12 +08:00
Kayshen-X eb853f6ccf refactor(editor): split layer-panel hit-test into a sibling file
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.
2026-07-04 18:58:45 +08:00
Kayshen-X ec45fd99c1 fix(editor): use page row height for pages-region scroll window
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.
2026-07-04 18:50:36 +08:00
Fini 7536627056 fix(desktop): gate test-only re-export so clippy -D warnings passes
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.
2026-07-04 18:47:58 +08:00
Fini 0471f95f6f docs(orchestrator): stop overstating Pencil alignment on the fallback path
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.
2026-07-04 18:29:43 +08:00
Fini 66b4cf8409 feat(editor): collapse the design loop's model narration
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.
2026-07-04 18:23:43 +08:00
Fini 2973c32dd0 feat(orchestrator): remove abandoned duplicate root artboards
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.
2026-07-04 18:16:19 +08:00
Fini ea3a759e39 test(orchestrator): update the stale unresolved-ref button test
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.
2026-07-04 18:07:10 +08:00
Fini 3f6f9d157d fix(orchestrator): flip dark icons to white on accent-token buttons
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.
2026-07-04 17:49:10 +08:00
Fini 2e21d94ff6 feat(ai): make script-gen the only subagent output protocol
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.
2026-07-04 17:31:22 +08:00
Fini eaa979d01c fix(renderer): paint the children of rectangle containers
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.
2026-07-04 17:25:58 +08:00
Kayshen-X 9471b7b1ab fix(figma): skip swapped instances in derived pre-seeding
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.
2026-07-04 16:48:14 +08:00
Kayshen-X 7885e25ab4 fix(figma): drop stale pre-swap derived so swapped icons render fully
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.
2026-07-04 16:37:48 +08:00
Kayshen-X eacc2ccbf0 fix(figma): evidence-based instance override routing + strong-fill fingerprint
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.
2026-07-04 16:37:47 +08:00
Fini 05e3326336 chore(ai): drop the now-unused loop opt-in flag parser
Flipping the loop to default-on left parse_loop_flag (the truthy
parser) with only test callers; parse_loop_off carries the opt-out.
2026-07-04 16:26:14 +08:00
Fini 9de2249164 fix(ai): stop a stray selection hijacking a new-design prompt
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.
2026-07-04 16:26:13 +08:00
Fini 0dd7277b78 fix(mcp): harvest the script past reasoning and prose preamble
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.
2026-07-04 16:04:47 +08:00
Fini aa6857365a fix(ai): disable reasoning on direct-modify turns
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.
2026-07-04 15:42:50 +08:00
Fini 29a5801621 feat(ai): make the design agent loop the default generation path
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.
2026-07-04 15:42:49 +08:00