From c877352ad0b98493596e496393b15918faca5822 Mon Sep 17 00:00:00 2001 From: Kayshen Xu Date: Sat, 11 Apr 2026 23:25:13 +0800 Subject: [PATCH] V0.7.0 (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent): fix race condition causing 'no text output' on successful tool calls The done event arrives via SSE before the executor's .then() callback updates toolCallBlock status from 'running' to 'done'. Checking status === 'done' at that moment finds nothing, showing the wrong message. Fix: check if any tool calls exist (length > 0) instead of their status. If the agent made tool calls and finished normally, the work was done. * fix(ai): normalize built-in provider base urls * fix(agent): preserve thinking steps in done handler, don't show empty fallback The done handler called updateLastMessage(accumulated) which overwrote the previously displayed thinking steps. Now: - If tool calls + no text: show "Design generated successfully" with thinking prefix - If thinking + no text + no tools: keep thinking steps, no fallback message - If nothing at all: show "Agent completed with no text output" * chore(agent): add diagnostic logging for agent event stream * feat(agent): update submodule with tool_use SSE parsing support * fix(agent): handle tool_use events in zigEventToSSE, suppress incomplete content_block_start Two issues: 1. content_block_start with tool_name was forwarded as tool_call with empty args {} (partial_json not yet accumulated). Client dispatched generate_design with no prompt → error. 2. The complete tool_use event from Zig engine was not handled by zigEventToSSE → mapped to 'unknown' type → client ignored it. Fix: add tool_use branch to zigEventToSSE (maps to tool_call with full args), suppress content_block_start tool calls (return empty string). * feat(agent): make team mode mandatory when concurrency >= 2, force parallel designers * feat(ai): add coding plan provider presets * fix(ai): sync provider preset URL normalization from upstream Includes canonicalize helpers, legacy URL migration, and region support for Zhipu, GLM Coding, Kimi, Bailian, StepFun presets. * test(ai): add preset URL chat/completions endpoint verification Validates all 13 openai-compat provider presets produce correct final URLs when combined with agent-native's /chat/completions suffix. * feat(ai): extend RoleDefaults type with fill, stroke, effects * feat(ai): add visual defaults to role definitions (fill, stroke, shadow) * feat(ai): add hexLuminance and hasFill helpers for visual post-pass * chore(agent): bump agent-native submodule to 2a57ee9 * feat(ai): add button foreground contrast post-pass fix * feat(ai): add section background alternation post-pass fix * feat(ai): add orphan container contrast post-pass fix * feat(ai): add input sibling fill/stroke consistency post-pass fix * feat(ai): enrich agent tool instructions with design quality brief Also fix test mock for canvas-text-measure and TS cast in button foreground contrast. * feat(ai): infer semantic role from node name for models that don't set role Weak models (e.g. minimax) often output nodes without the `role` property, causing all visual defaults to be skipped. This adds name-based inference that maps common node names (Button, Card, Search, Nav, etc.) to semantic roles before applying defaults. * feat(ai): intercept raw design JSON from agent and re-route through orchestrator Some models (e.g. minimax-m2.5) ignore tool-calling instructions and output design JSON directly as text instead of calling generate_design. This bypasses the orchestrator pipeline (spatial decomposition, sub-agents, role resolution). When the agent stream ends without any tool activity but the accumulated text contains design JSON, extract the user's original prompt and re-generate through generateDesign() for proper quality. * feat(store): add panelWidth, panelHeight, isMaximized to AI store * fix(ai): detect mobile intent and force 375x812 canvas in orchestrator re-route When the agent intercept re-routes through generateDesign(), detect mobile keywords (mobile, 手机, 移动端, app, ios, android, etc.) in the user prompt and force canvas size to 375x812 instead of using the viewport size. * fix(ai): clean up inline nodes before orchestrator re-route When feedText() already inserted nodes during agent text streaming, remove them before calling generateDesign() to avoid duplicate designs on canvas. * refactor(ai): remove feedText() from agent text streaming Agent text output should never insert nodes inline — if the agent outputs design JSON as text, the done handler re-routes through generateDesign(). Removes feedText() call and the now-unnecessary cleanup code. * i18n: add keys for chat panel redesign, file menu, and unsaved dialog * feat(ai): redesign quick actions to 2x2 card grid with descriptions * feat(store): add recent files persistence utility * feat(editor): add UnsavedChangesDialog component * refactor(ai): simplify agent prompt and remove orchestrator re-route Shorten AGENT_TOOL_INSTRUCTIONS to 4 clear rules that weak models can follow: use tools, never output JSON, summarize after tool calls, add style direction. Remove the complex interceptor that re-called generateDesign() when the agent output raw JSON — the goal is to make the agent reliably call the tool in the first place. * feat(ai): move send/attach buttons into textarea, simplify bottom bar * feat(editor): add FileMenu dropdown component with recent files * fix(ai): strengthen generate_design result to prevent double calls The tool result now explicitly tells the agent "Do NOT call generate_design again — the task is done" to prevent weak models from retrying after a successful generation. * feat(ai): upgrade chat panel to freely resizable window with maximize * fix(ai): retry sub-agent once on socket close or empty response When a sub-agent stream fails (e.g. provider socket closed unexpectedly), retry the subtask once before throwing. This handles transient connection issues with providers like ark/volces that occasionally drop SSE streams. * feat(editor): integrate file menu dropdown and recent files into top bar * fix(ai): move send/attach buttons back to bottom bar with cleaner layout * feat(editor): replace window.confirm with UnsavedChangesDialog * refactor(editor): move file menu to left toolbar dropdown, clean up top bar * fix(editor): add chevron indicator to file menu button * feat(electron): add Save As, Open Recent to native File menu with recent files sync * style(editor): redesign UnsavedChangesDialog with icon, glow ring, and stacked layout * style(editor): redesign unsaved dialog with dark glass morphism and animated glow border * fix(ai): keep SSE ping alive during builtin provider streaming streamViaBuiltin() cleared the ping timer on the first SSE event, leaving no keep-alive for the rest of the stream. If the provider paused for >10s (thinking, slow generation), Bun's idle timeout killed the connection. The ping timer is already cleared in the finally block — removing the premature clearInterval. * fix(editor): add light/dark theme support to UnsavedChangesDialog * fix(ai): remove conflicting design knowledge from agent system prompt The agent prompt combined AGENT_TOOL_INSTRUCTIONS ("FORBIDDEN: do not output JSON") with pen-ai-skills design knowledge ("you MUST output JSON"). These contradictory instructions caused weak models to ignore tool calling. Agent mode now only gets tool instructions — design knowledge is loaded by the orchestrator sub-agents. * style(editor): refine unsaved dialog with staggered entry, grain texture, and breathing glow * fix(ai): abort builtin engine after 60s of no SSE events When a provider returns 200 OK but never sends SSE data, the Zig readChunk blocks forever. Add a 60s timer that calls abortEngine() to unblock the Zig thread, allowing the stream to end gracefully and the orchestrator to retry or fail fast. * fix(ai): skip orchestrator planning API for builtin providers Builtin providers (minimax, etc.) consistently return empty responses for the long planning system prompt. Skip the planning API call and use the heuristic fallback plan directly — same result without the 60s timeout wait and wasted API call. * feat(ai): add plan_layout + batch_insert tools for builtin provider design For builtin providers (API key direct), the agent now uses plan_layout and batch_insert tools instead of generate_design. This avoids the orchestrator's multi-step API calls that unreliable providers can't handle. Flow: 1. Agent calls plan_layout(prompt) → creates root frame, returns sections 2. Agent generates PenNode JSON itself 3. Agent calls batch_insert(parentId, nodes) → inserts with role defaults No extra API calls — the agent's single LLM connection handles everything. CLI providers still use generate_design → orchestrator pipeline. * fix(ai): remove design knowledge from builtin agent prompt to prevent JSON output * fix(ai): prevent duplicate plan_layout calls with layoutCreated guard * fix(ai): builtin generate_design creates layout frame instead of orchestrator For builtin providers, generate_design now delegates to plan_layout (creates root frame) and returns the layout info to the agent. The agent then generates PenNode JSON and calls batch_insert — no orchestrator, no extra API calls. CLI providers still use the full orchestrator pipeline. * fix(ai): minimize generate_design tool result to reduce token usage * fix(ai): limit builtin agent to 5 turns to prevent token overflow * fix(ai): keep long-running sse streams alive * fix(ai): stop duplicate builtin design runs * fix(ai): restore typecheck after rebase * fix(ai): align builtin single-agent tooling * fix(ai): guard builtin layout loops * feat(ai): builtin agent uses full generate_design pipeline with streaming - Remove normalizeKnownMalformedOpenAICompatURL — URL version detection now handled in agent-native Zig layer - Builtin agent uses same generate_design orchestrator pipeline as CLI (StreamingDesignRenderer, breathing glow, skills) - Fix streamViaBuiltin event parsing — was checking evt.type instead of evt.stream_event.type, silently dropping all text/thinking events - Add max_output_tokens/max_context_tokens passthrough in agent endpoint - Fix tool completion status — add .then() handler to update tool block to 'done' after execution (was stuck on 'running' forever) - plan_layout reuses existing root frame via getActivePageChildren() - batch_insert: breathing glow with random agent identity, streaming insertion (3-node batches), max 9 nodes per call - Mobile section element hints prevent Header/Main Content duplication - Default background #FFFFFF, suppress thinking text in progress steps - maxTurns unified to 20 for all providers * fix(agent): lower default max_output_tokens to 16384 Update agent-native submodule — 200K default caused 400 errors on providers with lower token limits (Volcengine, MiniMax, etc.). * fix(ai): restore generate_design tool for builtin agent Remote merge introduced getBuiltinLeadToolDefs() which filtered out generate_design, reverting builtin agents to plan_layout+batch_insert. Use getDesignToolDefs() for all agents to ensure the full orchestrator pipeline is used. * fix(ai): prevent duplicate content in mobile fallback plan and restore builtin planning - Use single subtask for mobile fallback plan instead of Header/Main Content split that caused both sub-agents to generate overlapping UI elements - Remove login-screen-specific element hints that misled food/general app generation - Always attempt AI planning for builtin providers (with 30s/60s fast timeout) instead of unconditionally skipping — callOrchestrator already has internal fallback for unparseable responses - Respect user abort during planning — check abortSignal after streaming ends to prevent fallback plan from continuing canvas mutations * fix(ai): clean up root frames when sub-agent execution fails Remove root frames created in Phase 2 before re-throwing, so the canvas doesn't strand empty frames when orchestration fails after canvas setup. * fix(ai): only remove empty root frames on sub-agent failure Check whether root frames have children before removing them, so partial output from earlier successful subtasks is preserved. * fix(ai): detect scaffold-only frames in failure cleanup Snapshot descendant count after Phase 2 setup and compare on failure. Only remove root frames whose descendant count hasn't grown beyond scaffold nodes (status bar, dashboard columns), preserving frames that received real sub-agent content. * fix(ai): restore default frame instead of deleting it on failure When the root frame replaced the default empty frame (DEFAULT_FRAME_ID) and sub-agents fail, restore it to its empty state rather than removing it, so the canvas always retains a usable frame. * fix(ai): fully replace default frame on failure instead of shallow merge Remove and re-add the default frame so stale layout, gap, fill, and scaffold children from the failed generation run are fully cleared. * style(editor): upgrade provider form with shadcn Select, always-visible API format toggle * fix(editor): hide API format toggle for Anthropic and OpenAI presets * feat(editor): auto-switch baseURL on API format change, make baseURL always editable Add dual-format URLs for providers that support both OpenAI and Anthropic: - OpenRouter: /api/v1 (OpenAI) / /api (Anthropic) - DeepSeek: /v1 (OpenAI) / /anthropic (Anthropic) - MiniMax: /v1 (OpenAI) / /anthropic (Anthropic) * style(editor): redesign provider form with compact layout, field icons, and segmented controls * fix(editor): lock baseURL for Anthropic and OpenAI presets * fix(editor): only show API format toggle for providers that support both formats * refactor(editor): use presetConfig.altType to determine API format toggle visibility * feat(editor): add Anthropic format support for Zhipu (open.bigmodel.cn/api/anthropic) * feat(editor): add Anthropic format support for GLM Coding Plan * feat(editor): add Anthropic format support for Kimi (api.moonshot.cn/anthropic) * feat(editor): add Anthropic format support for Bailian/DashScope * feat(editor): add Anthropic format support for DouBao and Ark Coding Plan * feat(editor): add Anthropic format support for ModelScope * feat(editor): add Bailian Coding Plan preset with dedicated endpoint and Anthropic support * feat(editor): add altRegions for region-aware Anthropic URL switching Providers with regions now correctly switch altBaseURL by region: - MiniMax: minimaxi.com / minimax.io - Zhipu: bigmodel.cn / z.ai - GLM Coding: bigmodel.cn / z.ai - Kimi: moonshot.cn / moonshot.ai - Bailian: aliyuncs.com / dashscope-intl - Bailian Coding: coding.dashscope / coding-intl.dashscope * fix(ai): remove hardcoded blue fill from button role defaults Button role was injecting #2563EB fill on any frame inferred as a button, including container frames named "Button Group" or similar. This caused mysterious blue backgrounds on non-blue themed designs. - Remove fill from all three button role branches - Add word-boundary matching for button name inference - Add container-suffix exclusion to prevent "Button Group" etc. from being inferred as button role * feat(types): add codegen types and wire DTO types to pen-types Migrate canonical codegen type definitions (Framework, PlannedChunk, CodePlanFromAI, ExecutableChunk, CodeExecutionPlan, ChunkContract, ChunkResult, CodeGenProgress, ContractValidationResult) from pen-codegen to pen-types to establish pen-types as the authoritative source before pen-codegen package removal. Add new wire DTO types for MCP/CLI responses: - NodeSnapshot: depth-limited node snapshot for wire transfer - ExecutableChunkPayload: hydrated chunk payload with truncated nodes - ResolvedDepContract: dependency contract with null support Add comprehensive type tests covering FRAMEWORKS constant, NodeSnapshot variants, and ResolvedDepContract nullability. * feat(core): add sanitizeName and nodeTreeToSummary utilities Migrate utility functions from pen-codegen to pen-core for broader reuse: - sanitizeName: converts strings to PascalCase - nodeTreeToSummary: recursively renders node trees for AI prompts Add comprehensive tests for both utilities. * feat(mcp): migrate validateContract from pen-codegen Move the validateContract function into pen-mcp to support the chunked codegen pipeline. The types (ChunkResult, ContractValidationResult) are already re-exported from @zseven-w/pen-types (Task 1 completed). - Create packages/pen-mcp/src/utils/validate-contract.ts - Add re-export to packages/pen-mcp/src/index.ts - Add 4 test cases validating PascalCase, code presence, and SFC detection * feat(mcp): implement read_nodes handler with depth-limited subtree fetching Adds handleReadNodes() in pen-mcp that supports depth=-1 (full tree), depth=0 (node only), and depth=N (N levels of children), reusing the existing readNodeWithDepth utility. Includes 7 Vitest tests. * feat(editor): implement App-side codegen plan state store Adds in-memory plan store with createPlan, getPlan, submitChunkResult, assemblePlan, and cleanPlan. Validates duplicate chunkIds, empty nodeIds, unknown/circular dependencies, and missing nodes. Hydrates ExecutableChunkPayload with NodeSnapshot[] and resolved dep contracts. TTL-based expiry (30m). * fix(codegen): export PlanState interface and narrow statusOverride type - Export PlanState interface for public API (getPlan returns PlanState | undefined) - Narrow submitChunkResult statusOverride parameter from ChunkStatus to 'failed' | 'skipped' (only these two values have defined semantics in the validation logic) * feat(mcp): add HTTP endpoints for read_nodes and incremental codegen pipeline Adds five Nitro route handlers under /api/mcp/: - POST read-nodes: returns node snapshots with depth-limited children - POST codegen/plan: validates and stores a CodePlanFromAI, returns execution plan - POST codegen/submit: submits a ChunkResult and returns next ready chunk - GET codegen/assemble/[id]: assembles final output from completed chunks - DELETE codegen/plan/[id]: cleans up plan state early All routes support live canvas (getSyncDocument) and offline file (openDocument) modes. Adds @zseven-w/pen-mcp as a workspace dependency of apps/web. * refactor(mcp,web): dedup readNodeWithDepth and validateContract imports Move readNodeWithDepth export from inline copy in read-nodes.post.ts to pen-mcp for DRY, and use pen-mcp's validateContract in codegen-plan-store instead of maintaining duplicate implementation. - Export readNodeWithDepth from pen-mcp/utils/node-operations - Remove inline readNodeWithDepth from apps/web/server/api/mcp/read-nodes.post.ts - Add type cast (as unknown as NodeSnapshot) for pen-mcp readNodeWithDepth result - Replace validateContractFn with import from pen-mcp in codegen-plan-store.ts All 36 tests pass (4 validate-contract + 7 read-nodes + 11 plan-store + 14 security) * feat(mcp): add codegen HTTP client handlers Implement four thin HTTP client wrappers for codegen pipeline: - codegen_plan: POST /api/mcp/codegen/plan — initialize plan - codegen_submit_chunk: POST /api/mcp/codegen/submit — submit chunk result - codegen_assemble: GET /api/mcp/codegen/assemble/:planId — assemble code - codegen_clean: DELETE /api/mcp/codegen/plan/:planId — cleanup state All handlers use await getSyncUrl() to discover running App, throw on missing sync URL (except codegen_clean which gracefully returns ok). Uses encodeURIComponent for URL path parameters. No business logic, purely HTTP clients for App endpoint integration. * feat(mcp): register codegen tools in server and replace export_nodes Add codegen-routes.ts with definitions and dispatcher for all 5 new tools (read_nodes, codegen_plan, codegen_submit_chunk, codegen_assemble, codegen_clean). Replace EXPORT_* wiring in server.ts with CODEGEN_* and update pen-mcp public API in index.ts to export the new handlers instead of handleExportNodes. * feat(cli): add read-nodes and codegen pipeline commands Add op read-nodes to query document nodes via the App HTTP API, and op codegen:plan/submit/assemble/clean commands to drive the incremental codegen pipeline. All five commands are stateless HTTP clients against the /api/mcp/* endpoints. Also update help text to document the new commands. * refactor(codegen): migrate all pen-codegen consumer imports (Task 12) Replace all @zseven-w/pen-codegen imports in consumer files with their canonical new homes: types → pen-types, sanitizeName/nodeTreeToSummary → pen-core, validateContract → pen-mcp, generator functions → pen-sdk (which retains one pen-codegen re-export until Task 13 deletes the package). - code-panel.tsx: Framework, CodeGenProgress, ChunkStatus, FRAMEWORKS → pen-types - code-generation-pipeline.ts: types → pen-types, sanitizeName → pen-core, validateContract → pen-mcp - codegen-prompts.ts: types → pen-types, nodeTreeToSummary → pen-core - code-generation-pipeline.test.ts: CodePlanFromAI, PlannedChunk → pen-types - design-engine.ts: remove pen-codegen import + delete generateCode() method - design-engine.test.ts: delete generateCode test - pen-sdk/index.ts: codegen re-export split into types (pen-types) + functions (pen-codegen) - apps/web/src/services/codegen/*.ts: delete all 9 generator proxy files - apps/cli: migrate export.ts to pen-sdk; add pen-sdk dependency, drop pen-codegen * fix(cli,sdk): fix Task 12 spec violations — delete export.ts, remove pen-codegen from pen-sdk - Delete apps/cli/src/commands/export.ts (replaced by op read-nodes + op codegen:*) - Remove `case 'export':` dispatcher and help text from apps/cli/src/index.ts - Remove @zseven-w/pen-sdk dep from apps/cli/package.json (only used by deleted export.ts) - Remove generator function re-exports from pen-sdk/src/index.ts (codegen section deleted) - Remove @zseven-w/pen-codegen dep from pen-sdk/package.json pen-sdk now has zero imports from @zseven-w/pen-codegen, unblocking Task 13. * chore(codegen): remove pen-codegen package entirely All consumers have been migrated to pen-types (types) and pen-core (utility functions) in Tasks 1-12. Delete packages/pen-codegen/ and remove the workspace dependency from apps/web and pen-engine. * chore(mcp): remove pen-codegen from CI, scripts, desktop, and delete old export tool files Cleans up all remaining references to the deleted pen-codegen package from esbuild alias flags (mcp:compile, cli:compile, desktop dev.ts), publish scripts, and the GitHub Actions publish workflow. Deletes the superseded export-routes.ts and export-nodes.ts MCP files that were replaced by codegen-routes.ts in Task 10. * fix(ai): inline validateContract in code-generation-pipeline to avoid pen-mcp browser import Importing validateContract from @zseven-w/pen-mcp pulled in document-manager transitively, which uses node:fs/promises and broke Vite's browser bundle: "Module node:fs/promises has been externalized for browser compatibility". Inline the 11-line pure function instead — the original plan listed inlining as an acceptable option for this client-side consumer. * fix(panels): force builtin provider in code panel for builtin: models modelGroups reports 'anthropic' or 'openai' as the provider for builtin providers (based on bp.type), but streamChat needs 'builtin' to route the request through streamViaBuiltin on the server. Without this, models like minimax were sent to streamViaAgentSDK (Claude Code) which rejected them with "There's an issue with the selected model". Mirror the same override that ai-chat-handlers.ts already applies, so the code panel uses the same provider routing as the chat panel. * fix(mcp): fall back to IPv6 for live sync url discovery Vite 6+ resolves `localhost` to `::1` only on macOS, leaving IPv4 127.0.0.1 unreachable. The MCP document-manager previously only probed IPv4 hosts, so every batch_get / open_document / get_variables call failed with "fetch failed" even though the dev server was running. Add [::1] to SYNC_BASE_URLS and race all probes in parallel via Promise.any so the first reachable host wins immediately instead of serially waiting for IPv4 timeouts. * fix(ai): harden post-streaming pipeline against layout and phone mockup bugs Weaker sub-agents (MiniMax M2, GLM, Kimi) were producing two classes of structural failures that the post-streaming passes did not handle: 1. Missing layout on container frames — children collapsed to (0,0) because computeLayoutPositions skips frames with no explicit layout. 2. Fake "Phone Mockup" wrappers — the sub-agent pasted phone bezel properties (cornerRadius 32, width 260, black fill, horizontal layout) onto its own root frame and stuffed an entire duplicated section tree inside, compressing everything into a 40px-wide column. The root cause for #2 was that `jsonl-format.md` (loaded by every generation sub-agent) and a line in `orchestrator-sub-agent.ts` both injected the phone-mockup spec unconditionally. On mobile flows where the whole design IS already a phone screen, this prompt fragment has no legitimate use and confuses the model. Fixes: - Add normalizeTreeLayout in pen-core: writes explicit `layout` for frames missing it (prefers inferLayout, falls back to `vertical`), strips stale x/y from auto-layout children. Skips the fallback when any non-overlay child carries explicit x/y — preserves intentional absolute positioning (phone mockup internals, hero overlays). - Add unwrapFakePhoneMockups in pen-core: detects fake phone bezels by name or visual signature (cornerRadius 28-40 + width 240-320) and either promotes children to the parent (unwrap) or strips the fake visuals if the fake wrapper is the root itself (sanitize). - Remove the phone mockup line from jsonl-format.md; gate the in-code version behind a subtask-label keyword guard. - Add an explicit NO-PHONE-MOCKUP-WRAPPER instruction for mobile sub-agents so M2 doesn't self-wrap. - Fix stale-reference bug in applyPostStreamingTreeHeuristics: after resolveTreePostPass calls updateNode, the original rootNode reference is detached; refetch via getNodeById before running normalizeTreeLayout. - Fix the same bug inside resolveTreePostPass itself: every internal updateNode call was followed by direct mutations on a `root` parameter that Zustand had already replaced. Track `currentRoot` and refresh it after each updateNode. - Force a canvas resync after the mutation passes via forcePageResync: in-place mutations on store-owned nodes do not publish; without this the canvas stays stale on sub-agent retry paths that skip the implicit updateNode flush. Use forcePageResync (not a hand-rolled shallow doc spread) because canvas-document-sync subscribes to active page children identity, not doc identity. All correctness guarantees are covered by unit tests in pen-core (normalize-tree-layout.test.ts 13 cases, unwrap-fake-phone-mockup.test.ts 16 cases). * fix(ai): require cover ratio for icon fallback fuzzy matching The icon path resolver matched "heart" inside "heartRate-waveform" via findSubstringFallback and replaced the waveform geometry with the lucide heart icon, leaving the real waveform invisible. The root cause was that both prefix and substring fallbacks accepted any matched key of >= 4 characters, regardless of how little of the name it actually covered. - findPrefixFallback now requires the matched key to cover at least 50% of the normalized name. "arrowdowncircle" -> "arrowdown" (60%) still works; "heartratewaveform" -> "heart" (29%) is rejected. - findSubstringFallback is now suffix-match only (not "anywhere"), with the same 50% ratio guard. "badgecheck" -> "check" still resolves, but "starheartbadge" -> "heart" no longer hijacks an unrelated compound name. Legitimate chart icons (chart, barchart, analytics, activity, trendingup) still resolve via exact lookup and are unaffected by the stricter fallback. * fix(ai): drop redundant section-level fills from sub-agent output Sub-agents (MiniMax M2 especially) hedge by hardcoding a "safe dark" hex (#0A0A0A, #111, etc.) on every section root they emit. That fill then completely covers the page root's intended background color — #1a1a2e in the health-tracker case — breaking theme switching and creating visible seams between sections. Two-layer fix: 1. Prompt guardrail in orchestrator-sub-agent.ts — tell the sub-agent NOT to set `fill` on its section root, and show the actual inherited background color so the model has no reason to hedge. 2. Post-pass cleanup stripRedundantSectionFills in pen-core — for each direct child of the page root frame, drop the fill if: - the child has no role or a structural role (section/row/column/ stack/container/hero/footer/cta-section/etc.), AND - the fill matches the root fill exactly OR is one of the common "safe dark" hexes sub-agents reach for. Cards, buttons, chips, badges, inputs, phone mockups, status bars, banners and other protected roles are never touched. Unknown roles are also preserved (conservative default). The pass is wired into both applyPostStreamingTreeHeuristics (streaming path, runs on the outer parent frame via getParentOf so each sub-agent's section root is visible at the strip scope) and sanitizeNodesForInsert/ Upsert (batch path). All mutations are covered by the existing forcePageResync call so the canvas re-renders without a stale frame. * fix(ai): narrow section-fill strip scope to the true page root Follow-up on b4d35bb. The previous wiring ran stripRedundantSectionFills in two places it should never have touched: - applyPostStreamingTreeHeuristics called it on both `parentOfRoot` AND `freshRoot`. When a sub-agent emits an inner section (e.g. bottomNav-root), freshRoot is that inner section — NOT the page root — and its direct children are components, not page sections. The strip pass then misidentified those components as structural sections and could erase their intended fills. - sanitizeNodesForInsert/sanitizeNodesForUpsert called it on every cloned node from batch/MCP APIs. Those nodes can be anything (a card, a button, a component, a page), so the path cannot guarantee the node is a page root. Stripping there risks wiping a card's own dark header fill, etc. Fixes: - In applyPostStreamingTreeHeuristics, pick exactly ONE target: use parentOfRoot if it exists (the sub-agent's root was inserted as a child of a page frame), otherwise use freshRoot (the sub-agent's root IS the page frame via replaceEmptyFrame). Never both. - Remove the strip call from sanitizeNodesForInsert/Upsert entirely and explain in a comment why it would be unsafe there. - Document the scope contract on the function itself: "callers MUST only pass the true page root frame." - Add a regression test `is strictly non-recursive: never touches grandchildren even when caller mis-targets a card` to bound the blast radius if a future caller ever mis-targets the function. * fix(ai): restore section-fill strip coverage for non-streaming apply paths Follow-up on 320ba10. Narrowing the scope of stripRedundantSectionFills correctly took it out of sanitizeNodesForInsert/Upsert (which operate on arbitrary cloned nodes that may be cards or components). But the batch and non-streaming apply paths that DO land on the page root — applyNodesToCanvas, upsertNodesToCanvas, upsertPreparedNodes, extractAndApplyDesignModification — were left without any strip coverage, silently regressing MCP batch_design page inserts and any other non-streaming generation flow back to the original "section root hardcoded #0A0A0A covers up #1a1a2e page background" bug. Fix: - Add a finalizePageRootAfterApply() helper that reads DEFAULT_FRAME_ID from the store (the canonical page root), runs stripRedundantSectionFills on it, and forcePageResync-s when any fill was actually stripped. - Call it at the end of every non-streaming apply path, including the replaceEmptyFrame branches so a single-frame batch insert is covered. - No-op when no page root exists yet (safe on first insert). This preserves the card/component safety of 320ba10 while restoring strip coverage for legitimate full-page applies. * fix(ai): make section-fill cleanup page-aware for non-first pages Follow-up on 36eb7c0. finalizePageRootAfterApply looked up the page root via getNodeById(DEFAULT_FRAME_ID), which only works for Page 1 — every subsequent page created via addPage() gets a fresh nanoid for its root frame, so the lookup returned undefined and the cleanup was silently a no-op on pages 2..N. Rewrite the helper to read the active page's top-level children via getActivePageChildren(doc, activePageId) and run stripRedundantSectionFills on each top-level frame. Publishes once if anything changed. Handles multi-root pages (comparison mockups, etc.) by iterating. Still a no-op when the active page has no frame children yet, which is the safe first-insert behavior. * fix(ai): make full apply pipeline page-aware via primary-frame lookup Follow-up on 8d0a481. Fixing section-fill cleanup for Page 2+ was not enough — the whole non-streaming apply pipeline still assumed the page root frame was always DEFAULT_FRAME_ID. addPage() gives every page after the first a fresh nanoid for its initial frame, so on Page 2+: - applyNodesToCanvas / upsertNodesToCanvas / upsertPreparedNodes / extractAndApplyDesignModification were looking up getNodeById(DEFAULT_FRAME_ID) to decide the parent for inserts; that lookup returned undefined, so parentId fell through to null and new nodes got attached to the page top level as siblings of the existing root frame instead of being parented inside it — structurally wrong for multi-page documents. - isCanvasOnlyEmptyFrame() also queried DEFAULT_FRAME_ID, so it always returned false on Page 2+ and the replace-empty-frame fast path never fired. - replaceEmptyFrame() hardcoded DEFAULT_FRAME_ID as the update target, so calling it on Page 2+ would silently mutate Page 1's root frame instead of the page the user was actually editing. - generationRootFrameId was initialized to DEFAULT_FRAME_ID and reset back to it in resetGenerationRemapping(), baking the same Page 1 assumption into the streaming path. Introduce a small `getActivePagePrimaryFrameId()` helper that reads getActivePageChildren() and returns the id of the first top-level frame on whatever page is currently active. Replace every misuse of DEFAULT_FRAME_ID as "the current page root" with this helper: - isCanvasOnlyEmptyFrame() now inspects the active page's first top-level frame directly (no id lookup at all). - replaceEmptyFrame() looks up the target via getActivePagePrimaryFrameId(), updates that frame, and returns the target id so streaming can track it as the generation root. - apply/upsert parentId lookups use the helper. - resetGenerationRemapping() seeds generationRootFrameId with the helper result, falling back to DEFAULT_FRAME_ID only for the degenerate "active page has no frame yet" case. - insertStreamingNode's replace-empty-frame branch threads replaceEmptyFrame's returned id into generationRootFrameId. Remaining DEFAULT_FRAME_ID references are all legitimate: the import, init fallbacks, and explanatory comments tracking remapping history. * fix(ai): stop reading streaming generationRootFrameId from non-streaming paths Follow-up on d284fdc. The non-streaming apply paths (applyNodesToCanvas, upsertNodesToCanvas, animateNodesToCanvas) each ended with const rootId = getGenerationRootFrameId(); if (rootId) scanAndFillImages(rootId).catch(() => {}); generationRootFrameId is module-level state owned by the STREAMING path. In a non-streaming apply it is either the module init value (DEFAULT_FRAME_ID) or leftover state from a previous streaming generation — neither is guaranteed to exist on the currently-active page. On Page 2+ specifically, this pointed at nothing on the active page and scanAndFillImages would walk a stale/nonexistent subtree. Likewise, adjustRootFrameHeightToContent() fell back to generationRootFrameId when called without an explicit frameId — and that is exactly how all three non-streaming apply paths call it. Switch both call sites to getActivePagePrimaryFrameId() so the non-streaming post-processing always targets the frame on the page the user is actually editing: - applyNodesToCanvas (both branches) - upsertNodesToCanvas - animateNodesToCanvas - adjustRootFrameHeightToContent (fallback chain) expandRootFrameHeight is left unchanged — it is only called from the streaming path, where generationRootFrameId is actively maintained by insertStreamingNode / replaceEmptyFrame. * fix(ai): stop icon resolver and role inference from hijacking data-viz paths The M2.7 health-tracker retry exposed two related regressions where the post-processing passes were overreaching on descriptive path and frame names: 1. applyIconPathResolution was running on every `path` node and trying to match its name against ICON_PATH_MAP. Descriptive geometry names share substrings with icon keys and got hijacked: - "Heart Rate Chart" → lucide:circle (isIconLikeName fallback) - "Chart Fill" → lucide:bar-chart-2 (prefix "chart" 55%) - "Steps Progress" → lucide:circle (fallback) - "Calories Progress" → lucide:circle (fallback) - "Distance Progress" → lucide:circle (fallback) The resolver also injected a fake `d` and left stroke.color="none", so the progress arcs disappeared entirely and the rings visual collapsed into a single opaque black disc (three concentric dark tracks showing through). Fix: the `path` type is for custom geometry; `icon_font` is the canonical icon type. Only run icon resolution on a path whose name carries an explicit icon/logo/symbol/glyph marker. Uses camelCase- aware word splitting so "SearchIcon" / "MenuIcon" / "search-icon" / "BrandLogo" still qualify, while "Heart Rate Chart" and friends do not. 2. role-resolver name pattern /\bcard\b/i fired on "Card Header" and applied the card role defaults (white fill, shadow, cornerRadius). The "Card Header" node then rendered as a solid white strip across the top of the activity rings card. Fix: add a ROLE_PART_WORDS guard (header/body/footer/title/content/ image/action/etc.) alongside the existing CONTAINER_SUFFIXES guard. A "Card Header" is a PIECE of a card, not a card — it must not inherit role=card defaults. Also tighten stat-card, pricing-card, feature-card, and card to honour the same skip-containers flag. Verified with an inline script covering 41 cases (20 icon guard, 21 role inference), including camelCase, kebab, snake, and space variants for legitimate icons plus all the failing descriptive names from the retry dump. * fix(ai): make role part-word guard position-sensitive Follow-up on 5e2e6f9. The ROLE_PART_WORDS guard I added rejected any name containing a part word anywhere, which misfired on "Icon Button": - button pattern matches - skipContainers=true - ROLE_PART_WORDS.test("icon button") is true (icon is a part word) - continue → fall through to /\bicon\b/ → returns 'icon' ❌ The intent of the guard is to catch "Card Header" / "Button Label" — structural pieces named " ". But "Icon Button" means "a button OF THE ICON VARIETY", not "an icon inside a button". Word order matters: part words are only piece-markers when they appear AFTER the role word. Switch the loop from pattern.test to pattern.exec so we know where the role keyword sits, then only apply ROLE_PART_WORDS against the substring AFTER the match. "Card Header" still skips (header after card). "Icon Button" correctly returns 'button' (no part word after button). Symmetric cases also work out: "Button Icon" falls through card/button, hits the icon pattern and returns 'icon', which is the right answer for "an icon frame inside a button context". Inline coverage extended to 30 role cases including Icon/Primary/ Submit/Text/Image/Media modifier variants on both button and card. * fix(ai): scope role part-word guard to the FIRST word after role keyword Follow-up on d45f1a5. The previous pass switched to a suffix scan so "Icon Button" (part word before role) kept its button role, but the suffix scan still used a substring regex — it fired on any part word anywhere in the tail. That misclassified realistic prepositional variants: "Card with Icon" → after="with icon", 'icon' matched → skipped → fell through to /\bicon\b/ → role='icon' ❌ "Button with Image" → similar → role='icon' ❌ "Card with Header" → skipped → role=null ❌ These names describe the role AS A WHOLE ("a card that has an icon", "a button with an image"), not a piece of it, and should keep their role. Switch the guard to examine only the FIRST word token of the suffix: "Card Header" → first="header" → part → skip ✓ "Card with Icon" → first="with" → keep ✓ "Button with Label"→ first="with" → keep ✓ "Card - Header" → first="header" → part → skip ✓ (punct stripped) "Icon Button" → suffix empty → keep ✓ Store ROLE_PART_WORDS as a Set (with a small firstWordToken helper that uses /\w+/ to skip leading whitespace and punctuation) instead of a regex, so the semantics are unambiguous: "is the first word of the suffix one of these exact tokens". Inline coverage extended to 38 role cases, adding 6 prepositional variants (with icon / with image / with header / with label) for both button and card, plus two punctuation cases (Card - Header, Card: Body). * fix(web): resolve React dynamically in test setup instead of hardcoded path apps/web/src/__tests__/setup-react.ts loaded the native CJS React via a hardcoded absolute path pointing at a previous developer's home directory (/Users/kayshen/Workspace/ZSeven-W/openpencil/node_modules/ .bun/react@19.2.4/node_modules/react). That path exists nowhere except on the original machine, so every apps/web vitest run outside that machine failed immediately with "Cannot find module" before any test could load — silently disabling the entire web test suite for everybody else on the team. Swap the hardcoded path for a plain `require('react')`, which resolves through node's own module lookup and finds whatever copy of React is actually installed. Everything else in the setup file (the ReactSharedInternals proxy bridge that lets hook-using code share a single dispatcher with react-dom) is unchanged — we just stopped insisting on a machine-specific path to find React. With this fix the apps/web test suite actually runs again. Verified role-resolver.test.ts and icon-resolver.test.ts both execute without the ResolveMessage error. * test(ai): committed regression suite for role inference + icon marker fixes Codex flagged that the recent role-resolver / icon-resolver heuristic fixes (commits 5e2e6f9, d45f1a5, f842853) shipped without committed regression coverage — validation happened only in a throwaway inline script under /tmp/test-icon-role-fixes.mjs that would never run again. If a future refactor reverted those fixes, nothing in the repo would catch it. Lift every case from the inline script into committed vitest tests: role-resolver.test.ts — 35 new cases in the "name-based role inference" describe block: * 14 cases for "Card " structural pieces (Card Header / Body / Footer / Title / Content / Image / Media / Label / Action / Actions / Meta / Caption / Description / Wrapper) — none may resolve to role=card or inherit the white default fill+shadow. * 3 punctuation cases ("Card - Header", "Card: Body", "Card / Footer") — first-word scan must still skip punctuation to find the part word. * 9 modifier-before-role cases (Icon Button, Primary Button, Submit Button, Text Button, Image Card, Icon Card, Media Card, User Card, Product Card) — must still become button / card because the modifier sits BEFORE the role word, not after. * 7 prepositional variants ("Card with Icon", "Button with Image", etc.) — first-word-after-match guard must not trip on part words separated by "with". * 2 fall-through cases ("Card Icon", "Button Icon") — card / button skip via part word, the subsequent /\bicon\b/ pattern takes over and role=icon. icon-resolver.test.ts (new file) — 20 cases: * 12 data-viz geometry names that must NOT be resolved ("Heart Rate Chart", "Heart Rate Waveform", "Steps/Calories/ Distance Progress", "Chart Fill", "Bar Chart", "Line Chart", "Sparkline", "Activity Curve", "Custom Illustration", "Decorative Shape"). For each, verifies the original d and undefined iconId are preserved. * 5 legitimate icon names in every separator convention ("Search Icon", "SearchIcon", "search-icon", "search_icon", "Chart Icon") — all still resolve. * 1 unknown-name-with-marker case ("Brand Logo") — still recognized as an icon and overwritten (the placeholder + async queue path). * 1 bare geometry name ("Bar Chart") — no marker, left alone. * 1 non-path node type — no-op. Both files run under the now-fixed apps/web vitest env (see the prior commit that unhardcoded the React resolution path). Total suite: apps/web role-resolver 73 tests, icon-resolver 20 tests, both green. * fix(ai): make firstWordToken skip numeric indices and 1-char tokens "Card 1 Content" / "Card 2 Header" / "Button 3 Label" — sub-agents routinely stick a numeric index between the role word and the part word. The /\w+/ token scan I was using grabbed the numeric "1" as the first suffix token, decided it was not a ROLE_PART_WORD, and happily returned role=card on the wrapper. The card default fill (#FFFFFF) and shadow then covered the title text inside, which is why the Upcoming workout cards in the latest retry showed no "Morning Run" or "Yoga Session" labels — just the white card background overlay. Switch the regex to /[a-z]{2,}/i so the scan skips any non-alpha token (digits, punctuation) and any single-letter fragment, landing on the first real word. "Card 1 Content" now exposes "content" as the first token → part word → skipped, role not inferred. "Icon Button" still keeps its button role (empty suffix after the match). Added 7 numeric-index regression cases to role-resolver.test.ts. Total file is now 80 committed cases, all green. * fix(ai): normalize sub-agent stroke/fill schema violations Dumping the latest M2.7 retry showed that the activity rings and heart-rate chart line were all invisible, even though the AI had generated reasonable geometry. The root cause was three overlapping schema violations that the renderer silently dropped: 1. `stroke` as an array — the AI wraps it in [] as if it were `fill`. `stroke.thickness` is then undefined, canvas gives up, and the stroke is never drawn. Every activity ring had this. 2. Fill-shaped stroke — the inner value is { type: 'solid', color } instead of { thickness, fill }. `strokeWidth` is written as a top-level node field in CSS/SVG style rather than as `stroke.thickness`. This is what the heart-rate chart line had. 3. CSS keyword colors in fill — `fill: [{type: 'solid', color: 'none'}]` or `'transparent'`. Neither is valid PenFill; CanvasKit ignores them or falls back to an unspecified default. The 8-digit transparent hex `#00000000` is fine and is explicitly kept. Add a new pen-core pass `normalizeStrokeFillSchema` that recursively repairs all three in place: - Unwrap array-wrapped strokes. - Migrate fill-shaped stroke objects to proper PenStroke, pulling any top-level `strokeWidth` into `stroke.thickness` (default 2 if missing), moving the color into `stroke.fill[0]`, and deleting the stray strokeWidth field. - Drop `{color: "none"}` and `{color: "transparent"}` fill entries from both `fill` and `stroke.fill`. If the resulting stroke has no fill left, remove the whole stroke. Covered by 13 pen-core unit tests including two end-to-end repros of the M2.7 activity-ring and heart-rate-chart-line failures, plus defensive tests for valid hex fills, 8-digit transparent hex, object-shaped (non-array) strokes, and recursion into children. Wired in at the start of applyPostStreamingTreeHeuristics (streaming path) and sanitizeNodesForInsert/sanitizeNodesForUpsert (batch path), so every apply route sees a clean schema before role resolution and layout passes touch anything. * fix(ai): preserve explicit no-fill intent when normalizing CSS keyword fills Follow-up on 62b24bd. When every entry in a node's fill array was an illegal CSS keyword ("none" / "transparent"), normalizeNodeFill was calling `delete rec.fill` to remove the field. That was wrong: rec.fill = [{type:'solid', color:'none'}] ↓ normalize ↓ rec.fill = undefined (field deleted) ↓ canvas render ↓ DEFAULT_FILL gray (canvas-object-factory fallback) But the AI's intent was the opposite — "no fill, transparent shape" — which is critical for things like activity rings (hollow interior with only a stroke) and chart line backgrounds. Deleting the field silently flipped those from transparent to opaque gray. Replace the delete path with an explicit #00000000 transparent hex so the renderer sees the intent and honours it. Only applies when EVERY original entry was illegal; mixed arrays (one illegal, one legal) drop the illegal ones and keep the real colors untouched. stroke handling is unchanged — a stroke whose fill array is empty really is meaningless (nothing to draw) and is still removed entirely. Updated the M2.7 heart-rate chart line end-to-end regression test to assert the transparent hex instead of the old "undefined or empty" contract, plus a new mixed-array test covering the partial-cleanup path. pen-core suite: 189 committed tests, all green. Also cleaned up a handful of stale TypeScript casts in the test file (PenNode['stroke'] / Partial annotations that no longer compile since PenNode is a tagged union where only some variants carry stroke). Test helpers now accept an untyped Record bag — appropriate for a normalizer test suite whose inputs are intentionally schema violations. * fix(ai): split fill normalization by shape vs foreground node type Follow-up on 239f635. The transparent-hex replacement for illegal CSS keyword fills (none / transparent) was applied uniformly to every node type, but text.fill and icon_font.fill are FOREGROUND colors, not shape backgrounds: text.fill = [{color: "none"}] → AI meant "default text color" ↓ previous normalizer ↓ text.fill = [{color: "#00000000"}] → invisible text ↓ canvas render ↓ text is transparent — content lost Split the behavior by node type: FOREGROUND types (text, icon_font) fill is the foreground color. An illegal "none" almost certainly means "I forgot to pick a color" — delete the field so downstream layers (role defaults, button contrast heuristic, style inheritance) can populate something visible. Setting it transparent here would freeze the content invisible. SHAPE types (frame, rectangle, ellipse, path, group, ...) fill is a background. "no fill" is a valid hollow-shape intent (activity rings, chart line backdrops). Keep the explicit #00000000 replacement so canvas-object-factory does not fall back to the default gray. Added 6 TDD cases to normalize-stroke-fill-schema.test.ts: - text with sole "none" fill → fill deleted - text with sole "transparent" fill → fill deleted - icon_font with "none" → fill deleted - text with legitimate hex → unchanged - text with mixed [none, #00FF00] → drops illegal, keeps #00FF00 - sanity guard across frame/ellipse/path → still gets #00000000 pen-core suite: 195 committed tests, all green. * fix(ai): hasFill must return false for transparent fills so contrast can paint Follow-up on ef92774. Splitting the stroke/fill schema normalizer by node type resolved the text/icon_font case, but left a third broken scenario Codex caught immediately: a path node used as an icon inside a button. The chain was: 1. AI emits stroke-style line icon: type=path, fill=[{color:"none"}], stroke={thickness:2} (no stroke color) 2. normalizeStrokeFillSchema replaces "none" with the explicit transparent hex: fill=[{color:"#00000000"}] This is correct for activity rings / chart lines — deleting the field would let the renderer fall back to the default gray. 3. fixButtonForegroundContrast walks the button children: if (hasFill(child)) skip ← hasFill sees the transparent entry else if stroke exists but has no fill → paint stroke else if no stroke → paint fill The naive hasFill counted the transparent entry as "has fill" and took the skip branch, so the button icon never got a visible color and rendered invisible on canvas. The right fix is in hasFill itself — the function's semantic contract is "does this node have a VISIBLE fill?", and a fully-transparent fill (either CSS keyword "transparent"/"none", or any 8-digit hex with 00 alpha) draws nothing. Widen the invisibility check so every downstream heuristic that relies on hasFill gets the same truthful answer without each call site having to guard itself. Added 5 unit tests for hasFill (#00000000, generic 00-alpha hex, "transparent" keyword, "none" keyword, plus a sanity case showing partially-transparent #FF000080 still counts as visible) and 2 end-to-end tests for resolveTreePostPass's button contrast pass against path icons whose fill is #00000000: - stroke-style icon (stroke without color) → stroke color assigned - fill-style icon (no stroke) → fill color assigned apps/web role-resolver: 87 committed tests, all green. * fix(ai): split hasFill into two predicates so transparent fills are respected Follow-up on b7639da. Widening hasFill to treat transparent fills as "no fill" fixed the button foreground contrast case but broke the opposite group of callers — heuristics that rely on hasFill for overwrite PROTECTION: - fixOrphanContainerContrast: wrapped "if hasFill(node) return" around the white-fill-and-shadow assignment. With the new hasFill, any card whose author chose fill=#00000000 would lose its transparency and get painted white with drop shadows. - fixSectionAlternation: filtered sections by \!hasFill to decide which ones get the alternating background. Explicit transparent sections would wrongly enter that pool and get repainted. - fixInputSiblingConsistency: filtered "inputs with fill" — a transparent input would be silently excluded from sibling consistency alignment. These two use-cases have opposing needs: overwrite-protection callers "Has the AI declared ANY fill entry, visible or not? If so, don't touch it — transparent is a deliberate choice too." contrast-paint callers "Will this draw a visible color? If not, I need to paint one in." Split the predicate in two: hasFill(node) Restored to the original semantics — any non-empty `fill` array counts. Used by fixOrphanContainerContrast, fixSectionAlternation, fixInputSiblingConsistency, and the `fixButtonForegroundContrast` parent guard is UPGRADED to hasVisibleFill below (a transparent button has no bg to compute contrast against anyway). hasVisibleFill(node) [new] Rejects fully-transparent solid fills (#00000000, any #RRGGBB00, "transparent", "none"). Used by fixButtonForegroundContrast to decide "does this child need a color painted in?" and on the button parent itself to skip transparent buttons. Rewired fixButtonForegroundContrast to hasVisibleFill at all check sites (parent + three child branches). All other callers keep the original hasFill and are unchanged. Regression coverage: - hasFill: added explicit "returns TRUE for transparent hex / CSS keyword" tests so a future "helpful" refactor can't re-widen it. - hasVisibleFill: 8 new tests covering every transparent form and a partially-transparent sanity case (#FF000080 → true). - fixOrphanContainerContrast: new case asserting a transparent card keeps its fill and gains no shadow. - fixSectionAlternation: new case asserting a transparent hero section survives alternation unchanged. apps/web role-resolver suite is now 94 committed tests, all green. * fix(ai): hasVisibleFill respects the PenFill.opacity field Follow-up on e37fb2e. hasVisibleFill only looked at the solid fill's color string and missed the PenFill.opacity field entirely. A fill like {type:'solid', color:'#FFFFFF', opacity:0} — or a linear gradient with {opacity:0} — still reported as visible, so the button foreground contrast pass would incorrectly skip painting a readable color onto a child whose fill is 100% transparent via the opacity channel. Extract a shared isFillInvisible helper and run the opacity check BEFORE the type-specific color check, so the rule applies uniformly across every PenFill variant (solid, linear_gradient, radial_gradient, image). Negative opacity is treated defensively as zero. hasFill stays untouched — opacity=0 is still a deliberate author declaration, and the overwrite-protection callers (fixOrphanContainerContrast, fixSectionAlternation, fixInputSiblingConsistency) must keep respecting it. Tests added to role-resolver.test.ts: hasVisibleFill: - solid with opacity: 0 → false - linear_gradient with opacity: 0 → false - solid with opacity: -0.5 → false (negative treated as zero) - solid with opacity: 0.5 → true (non-zero partial transparency) - solid with no opacity field → true (default opaque) hasFill: - opacity-0 fill → true (deliberate author choice, protect it) resolveTreePostPass — transparent fill overwrite protection: - fixOrphanContainerContrast does NOT overwrite a card whose fill is {color:'#FFFFFF', opacity:0} apps/web role-resolver: 101 committed tests, all green. * feat(editor): add global PNG/JPEG/WEBP/PDF canvas export Replaces the post-Fabric.js export stubs with a working CanvasKit-based pipeline that can export both individual layers and the entire document to PNG / JPEG / WEBP / PDF, no external dependencies. Why: - Per-layer Export button in the property panel was a no-op stub. - Global export dialog was a no-op stub. - Save As (Cmd+Shift+S) was silently overwriting in-place because it shared a single handler with Cmd+S. How: - New global-export utility renders any page through MakeSWCanvasSurface + canvas.toDataURL — most reliable cross-build path. PDF is built inline as a minimal raster PDF (one JPEG per design page via /DCTDecode), no library required. - Per-layer export reuses the same SkiaEngine pipeline against the selected node's subtree. - Export dialog rewritten with PNG/JPEG/WEBP/PDF + scale options and a multi-page hint when format=PDF on multi-page docs. - File menu entry "Export image..." in both web FileMenu and Electron native menu. Electron entry uses registerAccelerator: false so the hint shows without OS-level interception (the keystroke is handled by the renderer's window keydown listener, which avoids HMR/IPC fragility). - exportDialogOpen lifted into canvas-store so menus, shortcuts, and the dialog itself agree on a single source of truth. - Cmd+Shift+S now explicitly takes the save-as branch (file picker) instead of falling through to in-place save. - New i18n keys (fileMenu.exportImage, export.pdfMultiPage) added to all 15 locales. * fix(ai): set ELECTRON_RUN_AS_NODE environment variable for CopilotClient in connect-agent Updated the CopilotClient initialization in connect-agent.ts to include the ELECTRON_RUN_AS_NODE environment variable. This change ensures compatibility when running the SDK in Electron, allowing it to handle the .js entry correctly without rejecting it as a stray argument. * fix(editor): make Cmd+Shift+E export shortcut bullet-proof Why: Cmd+Shift+E was still not opening the export dialog for the user even after registerAccelerator:false on the Electron menu entry. The single shared bubble-phase window listener can be silenced by any upstream stopPropagation, by an Electron menu accelerator that's still registered (if main process wasn't restarted), or by a layout/IME state that mutates e.key. How: - Pull Cmd+Shift+E out of the shared window keydown handler and give it its own dedicated listener. - Register on `document` in capture phase so it fires before every other JS keydown handler in the page. - Match on `e.code === 'KeyE'` (layout/IME-independent) with a fallback to `e.key.toLowerCase() === 'e'`. - Call setExportDialogOpen(true) instead of toggle, so even if the Electron IPC path also fires the dialog still ends up open instead of cancelling itself out. - stopImmediatePropagation() to prevent the bubble-phase handler from also reacting. * feat(store): add typed documentEvents emitter for save signal * fix(editor): rebind global export shortcut to Cmd+Shift+P Cmd+Shift+E was being silently swallowed at the OS level (likely a macOS Chinese IME or third-party tool) before any JS keydown handler could fire — confirmed by the user reporting that even a capture-phase document listener produced zero log output. P (mnemonic for Print/PDF/Picture) is unused everywhere else in the codebase and is not intercepted by common IMEs. Updated the renderer's capture-phase listener (KeyP), the Electron native menu accelerator hint, and the web file-menu shortcut display. * refactor(store): use Partial<> in documentEvents to drop cast * test(store): cover documentEvents emitter behavior * feat(store): add consolidated save() and saveAs() actions * refactor(store): make save() fallback to saveAs() explicit * test(store): cover save()/saveAs() paths and saved-event emission * refactor(editor): top-bar save callbacks delegate to store.save()/saveAs() * chore(agent): init agent-native submodule and zig in CI The packages/agent-native submodule was not being checked out in CI, causing `bun install --frozen-lockfile` to fail on the nested packages/agent-native/napi workspace. Enable submodule checkout in all relevant workflows and install Zig 0.14.0 so the postinstall hook can build the NAPI addon from source. The Dockerfile also pulls the whole submodule into the builder stage and installs Zig there. * refactor(hooks): use-edit-shortcuts Cmd+S delegates to store.save() * refactor(hooks): electron menu save actions delegate to store.save() * refactor(shared): save-dialog handleSave delegates to store.saveAs() * fix(electron): disable reload and devtools in packaged builds End users should not be able to reload the window or open DevTools from a shipped app. Hide the View menu items, disable DevTools at the webPreferences level, and swallow Chromium's built-in keyboard chords (Cmd/Ctrl+R, Cmd/Ctrl+Shift+R, F5, F12, Cmd/Ctrl+Shift+I/J/C, Cmd+Opt+I) via before-input-event. Dev mode is unaffected. * chore(electron): exclude dev.ts from desktop tsconfig dev.ts is a Bun-only orchestrator that uses ESM import.meta.dirname and imports from packages/pen-ai-skills/. It's run directly via `bun run` and never emitted to out/desktop/, so the CJS module + rootDir constraints of the electron main/preload bundle don't apply. Excluding it makes `tsc --noEmit -p apps/desktop/tsconfig.json` pass clean. * chore(panels): drop unused getBuiltinLeadToolDefs import It was imported alongside getDesignToolDefs but never referenced — TS6133 unused-import warning. * chore: add .prettierignore for submodule and generated files oxfmt picks up .prettierignore alongside .gitignore. Skip the packages/agent-native git submodule (owned by a separate repo) and the auto-generated apps/web/src/routeTree.gen.ts so they don't trip `bun run format:check` in CI. Submodule files showed up only after CI started initializing submodules. * style: apply oxfmt across the repo Pure formatting pass — no semantic changes. Files had drifted from oxfmt's settings (printWidth 100, single quotes, semis) over time and `bun run format:check` was failing in CI. Generated by `bun run format`. * chore: ignore .omx local directory * feat(pen-core): scaffold merge module skeleton * feat(pen-core): define merge module type contracts * feat(pen-core): add merge-helpers indexing and equality utilities * fix(ai): honor newRoot in plan_layout instead of reusing existing frame handlePlanLayout had a guard that rejected a second plan_layout call unless `newRoot: true` was passed, but the create-vs-reuse branch below the guard always picked up the first frame on the page — so even with `newRoot: true` the call returned the same rootFrameId and never added a new frame. Skip the existingFrame lookup when newRoot is set. The accompanying test was failing in CI for a related reason: the beforeEach used createEmptyDocument(), which already ships a default root-frame, so every "creates a root frame on the first plan_layout call" assertion was actually walking the reuse branch. Reset the active page's children in beforeEach so the tests exercise the code paths their names describe. * fix(sdk): mock pen-engine via package path in design-canvas test The DesignCanvas test was pinning a vi.mock to an absolute filesystem path rooted at the original author's machine (/Users/kayshen/Workspace/...). On any other checkout — including CI — the path didn't resolve, the mock silently became a no-op, the real attachCanvas ran, and CanvasKit tried to fetch /canvaskit/canvaskit.wasm under jsdom and aborted. Mock the package sub-path export '@zseven-w/pen-engine/browser' that DesignCanvas actually imports from. * test(pen-core): cover merge-helpers indexing and equality * feat(pen-core): implement diffDocuments two-way diff * test(pen-core): cover diffDocuments add/remove/modify/move * style(pen-core): apply oxfmt to merge module files The new merge module files (merge-helpers, merge-helpers.test, node-diff) landed without going through `bun run format`, breaking format:check in CI. Pure oxfmt output, no semantic changes. * feat(pen-core): implement merge node classification and 7-grid * feat(pen-core): implement merge tree rebuild from decisions Document NodeDecision invariants (Task 8) and implement rebuildDocument (Task 9): groups decisions by (pageId, parentId), walks ours' page scaffold, and recursively assembles the merged tree sorted by (index, id). * chore(hooks): auto-fix format drift via bun run format Replace the staged-file `oxfmt --check` invocation with the same scripts CI uses (`bun run format:check` / `bun run format`). On a check failure the hook now runs the formatter, re-stages the originally staged files, and continues — so new files added without going through the formatter no longer slip past local commits and surface as CI red. lint stays fail-loud and is unchanged. * feat(pen-core): implement merge for variables, themes, scalar fields, pages-order * chore: bump workspace version to 0.6.1 Auto-generated by the .githooks/pre-commit version-sync step from the v0.6.1 branch name. * test(pen-core): cover mergeDocuments 7-grid + reparent + doc-fields * feat(pen-core): re-export merge module from package root * feat: add Git integration and update dependencies - Introduced Git API types and methods in the desktop app for enhanced version control functionality. - Updated package versions in bun.lock and package.json, including isomorphic-git and sshpk. - Adjusted Electron API to include Git-related methods. - Modified desktop app's main and preload scripts to support Git operations. - Added GitButton to the web app's top bar for user interaction with Git features. - Updated TypeScript configuration to accommodate new Git-related paths. * feat(ai): pure diagnostics layer with WCAG-aware detectors and severity contracts Extract design pre-validation logic from apps/web into a reusable pure- function diagnostics layer in @zseven-w/pen-ai-skills, then layer the quality fixes that came out of debugging real LLM-generated dark theme designs on top. New module: packages/pen-ai-skills/src/diagnostics/ types.ts — Issue type with category / severity / property / suggested fix shape detectors.ts — 4 pure detector functions: detectInvisibleContainers detectEmptyPaths detectTextExplicitHeights detectSiblingInconsistencies plus detectAllIssues orchestrator with nodeId+property dedup index.ts — barrel re-export apps/web/src/services/ai/design-pre-validation.ts is now a thin wrapper that calls detectAllIssues() against the live document store and applies warning-severity issues only. The pre-validation pipeline is the only side-effecting layer; the detectors are completely pure so MCP debug tools and tests can reuse them safely. Detector quality fixes baked into the initial implementation: detectInvisibleContainers - Uses WCAG relative-luminance contrast ratio (default threshold 1.10) instead of max RGB channel diff. Channel diff treats #FAFAFA-vs-#F1F1F1 (light, invisible) the same as #111111-vs- #1A1A1A (dark, distinguishable to a dark-adapted eye); WCAG ratio is luminance-based and gets both regimes right. - Dark-on-dark issues (parent luminance < 0.1) are downgraded to 'info' severity so the suggested #E2E8F0 light-gray border is never silently auto-applied to a dark theme card. Pre-validation skips info severity; debug tools still surface the issue. detectSiblingInconsistencies - Two-pass design with property-specific grouping: * STRICT pass groups by ${type}:${role || '__none__'} and checks role-dependent properties (height, fontSize). Same-role siblings get the tightest consistency check; chrome elements like a tab-bar (role=bottom-tab-bar) never compare against sections (no role) because they're in different groups. * LOOSE pass groups by ${type} only and checks role-independent design-system tokens (cornerRadius). Catches outliers in singleton-role compositions (web landing pages where every section has a unique role and the strict pass would skip every group as <3 members). - LOOSE pass issues emit at 'info' severity (detect-only, not auto-fixable) because cross-role comparison can match a structurally distinct sibling like a rounded chrome element. - Divider/spacer roles are skipped entirely from both passes. Pre-validation - applyFixes() now skips 'info' severity and protected status-bar removals. - runPreValidationFixes() returns the count of fixes ACTUALLY applied, not the count detected. Test coverage: 26 diagnostics unit tests (in pen-ai-skills) + 7 end-to-end pre-validation behavior tests (in apps/web). * feat(mcp): add debug tools — validation_report, logs_tail, screenshot — behind feature flag External LLMs (Claude Code, Codex, Gemini CLI) need an autonomous debug loop for pen-ai-skills design generation: read canvas state, run detectors, capture screenshots, tail logs — all without asking the user to take screenshots and describe issues by hand. This batch adds all three debug tools, isolated from the main MCP surface by the OPENPENCIL_DEBUG_TOOLS=1 env flag so they don't pollute normal generation flows. debug_validation_report Calls detectAllIssues() (from the new diagnostics layer) on either the live canvas or a .pen file, returns the issue list grouped by category. Pure read — no side effects, no auto-fixes. Lets the debug loop see exactly what the pre-validation pipeline would flag. debug_logs_tail Tails the Nitro server log at ~/.openpencil/logs/server-YYYY-MM-DD.log with API keys and Authorization headers redacted. Supports grep filter and sinceMs cutoff. Runs from the MCP process so it works even when the web app is only accessed via the headless dev server. debug_screenshot Returns an MCP image content block (PNG) of either the full document or a specific node bbox, captured from the live renderer. The image flows back through the SSE RPC channel built in the next batch (Phase 2 SkiaEngine readback), but the tool wiring lives here so the route surface is in one place. Supporting infrastructure: - Shared log-utils with sensitive-value redaction (Authorization, Bearer tokens, x-api-key headers, ?key= query params). Migrated apps/web/server/api/ai/chat.ts off its inline copy to use the shared module — single source of truth for redaction patterns. - batch_get gains a `resolve_refs` option that recursively walks children and resolves $variable refs via resolveNodeForCanvas, so debug callers can see the concrete values Skia actually receives instead of raw $color-1 strings. - debug-routes module is registered conditionally by server.ts only when process.env.OPENPENCIL_DEBUG_TOOLS === '1'. Without the flag, the debug tools simply do not appear in the tool list — zero surface area for normal users. - handleToolCall return type widened from Promise to Promise so debug_screenshot can return an image content block instead of a stringified payload. Test coverage: 4 new test files (log-utils, node-operations resolveRefs, debug_validation_report, debug_logs_tail). * feat(canvas): SkiaEngine readback + SSE RPC for live debug screenshots The MCP debug_screenshot tool needs an actual image, not the empty buffer the design-screenshot.ts stub used to return after the Fabric → Skia migration. This batch builds the full readback path: extend SkiaEngine with a render-settle algorithm and a region capture API, then wire a request-response RPC channel from the MCP server back through the Nitro API to the renderer in the active browser tab. SkiaEngine.captureRegion(bounds) Synchronous draw of a node-bbox region into a PNG byte buffer via surface.makeImageSnapshot(). Bounds are validated up front (numeric x/y/w/h required, throw with a clear message otherwise) so callers can't accidentally feed in declared sizing strings like "fill_container". SkiaEngine.waitForSettled(timeoutMs) Resolves when two consecutive sync render passes both leave dirty= false AND every pending font/image load has resolved. Uses a sync loop driven by an explicit `dirty=false; this.render()` per pass rather than RAF, because background tabs throttle RAF and the old implementation hung the 5s timeout when the tab wasn't focused. Concurrent-capture safety: a `_captureRefcount` integer (not boolean) suppresses the agent-overlay self-loop in render() during capture so the dirty flag can actually settle. Refcount lets parallel captures share the suppression without one of them prematurely re-enabling the loop. Pending-load gating relies on the new pendingCount/flushPending APIs on SkiaImageLoader and SkiaFontManager — without these the capture could fire mid-load and produce a half-rendered PNG. apps/web/src/services/ai/design-screenshot.ts Replaces the post-Fabric stub with a real implementation that delegates to SkiaEngine.captureRegion. design-validation.ts now routes screenshot capture through the same path. SSE request-response RPC (Nitro ↔ renderer): server/utils/mcp-screenshot-rpc.ts Pending-request map keyed by request id; resolves on POST to /api/mcp/screenshot-response. server/api/mcp/screenshot.post.ts POST endpoint the MCP debug_screenshot tool calls. Allocates a request id, sends a `screenshot:request` SSE event to the active client, awaits the response on the pending map, returns the PNG. server/api/mcp/screenshot-response.post.ts The renderer's reply endpoint — resolves the corresponding pending promise. server/utils/mcp-sync-state.ts + active-ping endpoint Track lastActiveClientId via per-client focus/visibility ping so the MCP server can target the actually-active browser tab even when several clients are connected. selection.post.ts also forwards sourceClientId so the active tracker stays warm. src/hooks/use-mcp-sync.ts Renderer-side: handles the `screenshot:request` SSE event by looking up the node bbox via engine.spatialIndex.get(nodeId) for computed pixel coordinates (not declared sizing), calling captureRegion, and posting the PNG back. Adds focus/visibility listeners that fire active-pings. Bug fixes baked into the initial implementation (caught by Codex review while iterating): - Background-tab RAF suspension would deadlock waitForSettled — fixed by sync render loop. - Agent overlay markDirty self-loop prevented settle during capture — fixed by _captureRefcount suppression. - handleScreenshotRequest read declared sizing strings as bounds — fixed to use spatialIndex computed pixel values. - captureRegion silently returned full frame on bad bounds — fixed to throw fail-loud, with validation hoisted before refcount/try block so the test path actually exercises the throw. Test coverage: 4 new test files (font-manager + image-loader pending APIs, skia-engine capture, mcp-screenshot-rpc, mcp-sync-state active client tracking). * fix(core,ai): layout engine and prompt fixes from real-design debugging Three independent layout problems uncovered while debugging an LLM- generated fitness app, all rooted in either the engine treating ambiguous structures the wrong way or the LLM not having clear rules to follow. Fixed at the engine and prompt level so future generations don't reproduce them. normalize-tree all-frame heuristic Bug: AI emits a layout-less parent containing structured nested- frame children (a ring + value-wrap composition, a hero + floating card, a badge stack on top of an avatar). Children have no x/y because the AI assumed (0,0) would just work. The previous normalize-tree rule would silently rewrite the parent to layout= 'vertical', stacking the frames into a list and clipping anything past the parent's bounds. Fix: when EVERY non-overlay child of a layout-less parent is a frame (>= 2 such children), treat it as an overlay container and leave layout undefined so the renderer respects each frame's own positioning. Mixed-type stacks (frame + rect, frame + text) still get the vertical fallback because those are typically content stacks where verticalization is the right call. Two new tests lock in: all-frame children stay as overlays, and mixed-type stacks still verticalize. layout.md ring/circle anti-pattern Bug: LLM emits `ellipse + sibling text` to represent an Apple- Activity-Ring style indicator. Ellipse can't have children in the PenNode schema, so the text ends up stacking above or below the ring instead of in the center. Fix: documented the canonical pattern in the layout skill — `frame(width=80, height=80, cornerRadius=40, stroke={...}, fill=[], layout='horizontal', alignItems='center', justifyContent='center')` with the text as a child. Also explicitly forbids the donut-hole trick (two ellipses, smaller one painted with parent bg color) and the layout='none' + nested absolute-positioned children attempt, both of which render unreliably in OpenPencil's flex-layout model. layout.md horizontal row width math Bug: LLM emits 3 activity rings at 100×100 with 24px gap inside a card with 24px padding on a 375px mobile page. Total 348px > 279px inner card width → the third ring is silently clipped on the right edge. The layout engine doesn't shrink fixed-px items; it clips. Fix: added the explicit formula total = N × item_width + (N − 1) × gap inner = parent_width − padding_left − padding_right plus worked examples for 2/3/4-item mobile rows so the LLM has the numbers ready, plus an explicit anti-pattern referencing the rings overflow. layout.md no-fixed-position spacers Bug: same fitness app ended in a 62px empty "Bottom Spacer" frame after an inline bottom-tab-bar — the model was reserving space as if the tab-bar were `position: fixed`. OpenPencil has no fixed/sticky positioning; the bar is already part of the page flow. Fix: documented the anti-pattern with the exact bug shape so the LLM stops emitting these dead frames. skills/phases/generation/layout.md budget bumped 1500 → 1700 to fit the additions. * fix(ai): theme-aware role defaults so dark pages stop getting white navbars Root cause of the 'glaring white navbar on a dark fitness app' bug: the navbar / card / input / divider role defaults in role-definitions/index.ts hardcoded #FFFFFF (CARD_FILL), #F8FAFC (INPUT_FILL), #E2E8F0 (INPUT_STROKE / divider). When an LLM generated a dark theme but omitted the fill on a navbar (because it expected the dark page bg to show through), applyDefaults() filled in #FFFFFF and stamped a bright white bar across the top of the design. Theme detection resolveTreeRoles() now propagates a `theme: 'dark' | 'light'` value through RoleContext. Detection reads the page root fill (luminance < 0.3 = dark) and falls back to 'light' for backward compatibility with all existing light-theme designs. detectThemeFromNode is exported with an explicit warning in its docstring: callers MUST pass the actual page root, NOT whatever sub-tree the resolver is currently walking. A card or navbar with no fill of its own would mis-detect to 'light'. Theme-aware role defaults navbar / card / stat-card / pricing-card / feature-card / testimonial / input / form-input / search-bar / table-header / divider all use new helpers (cardFill, inputFill, inputStroke, navbarFill, navbarBottomBorder, dividerFill) that return the right color for the active theme. Dark palette: card / input fill = #1A1A1A navbar fill = #111111 (matches page; differentiated by border) input stroke = #2A2A2A navbar bottom border = #1F1F1F divider = #2A2A2A applyDefaults overwrite-protection still holds — any LLM-supplied fill is left alone, even when theme-aware defaults would pick something else. Live page-root lookup at sanitize time sanitizeNodesForInsert / sanitizeNodesForUpsert receives an arbitrary PenNode (a card, a navbar, a sub-section) — NOT the page root. Calling resolveTreeRoles(node, …) without a theme would read the sub-tree root's missing fill and fall back to light, silently re-introducing the white-navbar bug for direct MCP call paths. Fix: a new detectActiveDocumentTheme() helper looks up the LIVE active-page primary frame via getActivePagePrimaryFrameId() (NOT the cached generationRootFrameId module variable, which is stale or default for non-streaming applies — same precedent already exists in upsertNodesToCanvas at line ~464). Both sanitize functions compute the document theme once per batch and pass it explicitly through resolveTreeRoles. Test coverage: 8 new role-resolver tests (light/dark/protection/ fallback/sub-tree-without-fill/explicit-override-beats-auto-detect) + 3 new design-canvas-ops-theme end-to-end tests (upsertNodesToCanvas → store-readback for dark, light, and overwrite-protection paths). The end-to-end tests deliberately bypass resetGenerationRemapping() to exercise the direct-MCP call path where the bug lived. * feat(editor): improve asset loading and editing workflows (#91) Co-authored-by: Di * fix(ai): prevent broad skill matches from bloating prompts Generation prompts were pulling unrelated form and codegen skills because keyword matching used substring checks and codegen skills were always eligible. Use word-boundary matching for ASCII keywords, narrow form-ui triggers, and gate codegen skills behind the explicit isCodeGen flag so normal design generation keeps focused context. Constraint: CJK keywords still need substring matching because word boundaries do not apply Rejected: Keep short keywords like input/email/mobile | they match unrelated prompts and inflate context Rejected: Load codegen rules for all generation | design JSON generation should not carry code output instructions Confidence: high Scope-risk: moderate Tested: Targeted pen-ai-skills resolver tests before reword Not-tested: Full skill registry integration against all prompts * fix(ai): keep mobile planning on model-driven paths longer MiniMax/basic-tier planning was falling back too quickly and mobile app homepages could pull landing-page planning guidance. Add compact planner retries, style-guide shortlisting, near-miss JSON repair, and sub-agent prompt compaction so focused app screens get a model plan before heuristic fallback. Constraint: Basic providers can spend long periods thinking before first JSON output Rejected: Disable fallback entirely | still needed when providers return no usable plan Rejected: Keep homepage landing-page predesign for mobile apps | it pollutes single-screen app plans Confidence: high Scope-risk: moderate Tested: Targeted planner/sub-agent tests before reword Not-tested: Live MiniMax end-to-end run after reword * fix(ai): preserve dark generated UI through sanitize passes Generated dark mobile screens were being damaged by role defaults and post-pass rewrites: cards could inherit white fills, page-chrome roles could be inferred inside cards, placeholder icons stayed generic, and activity rings could become filled discs. Add theme-aware input-first sanitization, anti-pattern rewrites, icon repair, and ring-fill guards with regression coverage. Constraint: Streaming and MCP insert paths sanitize arbitrary subtrees before the live page root may be readable Rejected: Trust LLM output without structural rewrites | repeated provider outputs produced broken rings and chart layouts Rejected: Paint orphan rounded containers unconditionally | stroked rings are decorative geometry, not cards Confidence: high Scope-risk: broad Tested: Targeted role/icon/sanitizer tests before reword Not-tested: Visual regression suite across all design archetypes * fix(renderer): render stroked generated shapes without phantom fills AI-generated rings and imported stroke data need the core/renderer pipeline to preserve stroke intent. Normalize SVG-style stroke attributes including dash offset, keep composition primitives out of vertical fallback, map unsupported baseline alignment to bottom alignment, and render stroke-only shapes with transparent fallback fills instead of opaque interiors. Constraint: CanvasKit still requires a paint object even for transparent fill fallback Rejected: Treat all layout-less rectangles as overlay primitives | list/card stacks rely on vertical fallback Confidence: high Scope-risk: moderate Tested: Targeted core and renderer tests before reword Not-tested: Full renderer visual snapshot suite * fix(agent): keep long design runs diagnosable and alive Long generate_design tool calls can outlive visible SSE activity, so keepalive pings now refresh agent session activity before stale cleanup can drop the pending tool callback. The agent request log also mirrors the effective upstream prompt/tool shape, while the native HTTP client only prints successful request bodies behind an explicit opt-in. Constraint: External tool execution may block model events for minutes while the UI still needs the session Rejected: Increase stale-session TTL only | pings already represent an active stream and are a more precise heartbeat Rejected: Always log successful provider request bodies | leaks prompt and conversation payloads Confidence: medium Scope-risk: moderate Tested: Targeted SSE keepalive test before reword Not-tested: Full agent-native Zig suite and live provider retry * test(web): backfill git-store coverage for retry/close/status/require paths * feat(git): implement directory selection and author identity probe - Added IPC handler for opening a directory dialog to select Git repository folders. - Introduced `getSystemAuthor` function to retrieve user name and email from system Git config. - Updated Electron API to include `openDirectory` method for directory selection. - Enhanced Git IPC handlers to support author identity retrieval. - Implemented autosave logic in the Git engine to prevent unnecessary commits when content has not changed. * feat(editor): keep branch deletion inside the git panel flow Phase 5 needs the renderer to warn before deleting unmerged branches and to retry with force after confirmation. The existing IPC surface could only do a single best-effort delete, so the UI could not implement the approved design honestly. Also replaces the four hardcoded loadLog({ ref: 'main' }) refreshes with a currentLogRef() helper so the history list follows the actual current branch after commit/promote/switch/merge. Constraint: Phase 5 must not introduce new dependencies Rejected: Hide delete for unmerged branches | violates the approved UX Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not remove branch-unmerged without replacing the confirm-then-force path in the UI Tested: desktop git iso/engine tests, web git-store tests Not-tested: manual folder-mode delete flow in Electron * feat(editor): render the phase 5 branch picker shell Phase 5 starts by replacing the disabled branch placeholder with a real picker container and explicit list-mode entry points for create and merge. This keeps the shell reviewable before the mutating flows are wired in. Constraint: Conflict state must stay non-interactive until merge resolution ships Rejected: Keep the placeholder until all branch actions are done | hides shell-level regressions until too late Confidence: medium Scope-risk: narrow Reversibility: clean Directive: Keep the delete affordance as a real sibling button; do not nest interactive controls inside the branch-row select button Tested: branch picker shell tests, ready/panel smoke tests Not-tested: Electron visual polish * feat(editor): keep branch creation and switching inside the git dropdown The panel already knew how to create and switch branches in the store, but the header still rendered a disabled placeholder. Task 3 turns the picker from a shell into a working create/switch flow: the list mode dispatches switchBranch directly and closes on save-required so the existing panel save alert takes over; the create view runs local empty/duplicate validation and then delegates to the store (which already refreshes branches internally). Constraint: Phase 5 must preserve the existing save-required gate Rejected: Auto-switch immediately after create | the approved design only guarantees creation from current HEAD Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep branch action errors local to the picker unless the store must change global state Tested: branch picker component tests (old + 4 new), ready/panel smoke tests Not-tested: Electron manual switching with an externally modified repo * feat(editor): wire delete-confirm and merge flows in the git panel Phase 5 Task 4 replaces the remaining picker stubs. Delete goes through a confirm dialog that upgrades to a force-delete retry only when the store returns branch-unmerged (other errors surface as inline messages without offering force). Merge uses the existing mergeBranch store action; the conflict transition is observed via state.kind === 'conflict' and handled by the picker's single conflict early-return. Also hoists a narrowed activeRepo alias so nested handlers no longer need a non-null assertion. Constraint: Force delete must only appear after branch-unmerged Rejected: Show force delete for any delete error | retrying force on engine-crash cannot help Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Do not add a second conflict-disabled path; the top-level early return is the single source of truth Tested: branch picker component tests (4 new: delete happy-then-force, delete non-unmerged error, merge happy, merge save-required) Not-tested: Electron manual merge conflict handoff * feat(i18n): localize phase 5 branch picker across 15 locales Adds the 16 git.branch.* keys the picker needs (list/create/merge/delete flows + noCommits fallback) and removes the orphaned git.header.branchComingSoon placeholder tooltip key, now that Task 2 replaced the header placeholder with the real picker. Constraint: All 15 locales must ship together — no English-only fallback Rejected: Ship English only and let other locales fall back | inconsistent UX across regions Confidence: medium Scope-risk: narrow Reversibility: clean Directive: Do not reintroduce branchComingSoon unless the picker stub returns Tested: targeted vitest suite (iso/engine/store/picker/ready/panel), typecheck Not-tested: Electron manual walkthrough (deferred to pre-release QA) * chore: apply oxfmt drift across workspace Phase 5 surfaced ~90 files with pre-existing oxfmt drift that the pre-commit hook kept re-fixing. Landing the formatter sweep as a single chore commit so future phases start from a consistent baseline. * feat(desktop): expose origin remote get/set IPC for the clone wizard Add a renderer-visible remote metadata/config contract: remoteGet reads .git/config only and remoteSet upserts or removes the single 'origin' remote via writeRemoteOrigin in git-iso. The wizard in Phase 6a uses the returned GitRemoteInfo to update renderer state in a single round-trip. * feat(store): add clone wizard state machine and remote metadata actions Phase 6a's renderer surface: extend RepoMeta with cached origin metadata, expose enterCloneWizard / cancelCloneWizard / refreshRemote / setRemoteUrl, and route recoverable clone errors back into wizard-clone with an inline error field so the form can keep its state on retry. setRemoteUrl mutates state immediately from the IPC return value to avoid stale UI after save. * feat(editor): ship the Phase 6a clone wizard form and empty-state entry Replace the wizard-clone placeholder with GitPanelCloneForm: URL field, destination picker via electronAPI.openDirectory, auth-mode inference, anonymous-clone support for HTTPS, and an inline error block for the recoverable failure codes the store leaves in wizard state. Re-enable the clone card on the empty-state and translate every new key into the 15 locales. * fix(store): keep clone wizard mounted across the clone round-trip Previously cloneRepo unconditionally transitioned to `initializing`, which unmounted GitPanelCloneForm and wiped the user's URL/dest/token inputs. On a recoverable failure (8 codes in CLONE_INLINE_ERROR_CODES) the user saw the inline error banner over an empty form and had to re-type everything — defeating the whole point of inline retry. Fix: when cloneRepo is launched from inside the wizard, stay in `wizard-clone` and flip a new `busy` flag instead of teleporting through `initializing`. The form now reads `busy` from the store (local useState removed, so the stale-component `setBusy(false)` in `finally` disappears too). CLI-driven clones still go through `initializing` because no form is mounted. Also reclaims headroom on git-store.ts (at the 800-LoC cap) by extracting a `dropSaveRequired` helper for the duplicated `saveRequiredFor` destructure in `cancelSaveRequired` and `retrySaveRequired`. Tests: - New regression test asserts URL/dest/username/token inputs still hold the typed values after a recoverable auth-failed failure. - New store test asserts cloneRepo from the wizard never transitions through `initializing` mid-IPC. - New parametrized `it.each(CLONE_INLINE_ERROR_CODES)` test validates all 8 inline error codes render the bundled en locale string (not the GitError message, not the t() defaultValue fallback) — a typo in any locale key now fails loudly instead of shipping silently. - Replaces `await Promise.resolve()` patterns in the clone-form tests with `waitFor`. * fix(desktop): stop swallowing real I/O errors in writeRemoteOrigin The `git.deleteRemote(...)` call was wrapped in a bare try/catch with the comment "idempotent absent". That was wrong on two counts: 1. isomorphic-git implements `deleteRemote` as `GitConfigManager.deleteSection('remote', 'origin')`, which is a filter over parsed config entries. It does NOT throw when the section is absent — the call is naturally idempotent. 2. The only way `deleteRemote` can actually throw is via `GitConfigManager.save` (EACCES, ENOSPC, read-only fs). Those are real I/O failures; swallowing them would show the user "origin removed" in the UI while `.git/config` still held the old URL on disk. Removing the inner try/catch lets real errors propagate through the outer catch and surface as a GitError to the renderer. The existing "removes origin and is idempotent" test already covers the double-remove case; I added a comment explaining why the absence of a try/catch is load-bearing here so a future refactor doesn't re-add one. * feat(store): ship Phase 6b pull/push cascades and head-move helper Extracts syncAfterHeadMove into git-store-helpers so pull, switchBranch, and mergeBranch all refresh status + branches, reload the tracked .op file, and refresh the log through a single path. Adds full 4-way pull result handling (fast-forward / merge / conflict / conflict-non-op) and threads status().unresolvedFiles into a new conflict state field so the renderer can recover from non-.op pull conflicts before Phase 7. * feat(editor): wire real pull/push controls and shared auth retry form Replaces the disabled Phase 5 pull/push placeholders in the git panel header with a dedicated GitPanelRemoteControls subtree that owns the remote-action state machine. Pull auth-required / auth-failed and push auth-failed both open the new shared GitPanelAuthForm (token + SSH modes with a "remember this credential" toggle). Push-rejected surfaces an inline "pull first" recovery strip so the user can one-click retry. The conflict banner grows a non-.op unresolved-files list with continue / abort so the Phase 6b pull conflict-non-op path has a recovery surface before Phase 7 lands manual resolution. * feat(i18n): localize Phase 6b remote controls and auth form across 15 locales Adds the git.pull.*, git.push.*, git.auth.*, and git.conflict.nonOp.* keys to every locale so the shared auth form, the push-rejected recovery strip, and the non-.op unresolved-files banner all render in the user's language. * fix(store): delegate pull conflict-non-op to refreshStatus The manual gitClient.status + buildConflictState block in pull's conflict-non-op branch bypassed refreshStatus's Step 1 repo-meta update, leaving currentBranch / ahead / behind / workingDirty stale after a non-op merge landed. refreshStatus already promotes ready to conflict with the correct unresolvedFiles via its mergeInProgress branch — trust it instead of duplicating the state build on the pull side. Also drops the helperCurrentLogRef alias in favor of inlining currentLogRef(get()) at the two call sites, and collapses the ready/conflict nested branches inside refreshStatus since both call buildConflictState with identical arguments. git-store.ts drops from 787 to 771 lines. * refactor(editor): consolidate remote auth classification in one helper The stated goal of classifyRemoteAuthError was to keep pull/push auth classification in a single place, but the component kept its own inline isPullAuthCode / isPushAuthCode predicates and the helper was exported dead. Wire the helper into git-panel-remote-controls for both pull and push, collapse the duplicate PULL_AUTH_ERROR_CODES / PUSH_AUTH_ERROR_CODES into a single REMOTE_AUTH_ERROR_CODES (the promised Phase 6c divergence never materialized), and add a unit test that pins the helper contract across all three auth codes plus non-auth / non-GitError values. Also hides the remote controls entirely while the repo is in conflict state — pull would fail deterministically against an in-flight merge and push would try to push a half-merged tree. The conflict banner already owns the recovery UI, so the controls returning null is the cleanest handoff. Drops the dead GitError branch in extractMessage (isGitError already covers the instanceof path) and the now-unused GitError import. * feat(editor): add phase 6c remote settings and ssh keys overflow views Expand the git panel header overflow popover into a local state machine that swaps between the menu view, a remote-settings subview that edits the origin URL with an inline clear-confirm step and clear-saved-auth affordance, and an ssh-keys subview that manages the key lifecycle (generate, import via electron openFile, delete, copy public key) with provider links for github/gitlab and iso-engine SSH gating guidance. * feat(i18n): sweep phase 6c remote/ssh keys across 15 locales Add the new git.remote.*, git.ssh.*, and git.header.overflow* keys for the phase 6c subviews, and retire the four dead "coming soon" placeholders (git.placeholder.cloneWizard, git.empty.cloneComingSoon, git.header.pullComingSoon, git.header.pushComingSoon) that the shipped phase 6a/6b UI replaced. * fix(editor): harden phase 6c ssh keys view polish items - Track the copy-flash timer in a ref and clear it on unmount so a late setTimeout can't call setCopiedKeyId on a stale component. - Feature-gate navigator.clipboard.writeText (undefined in non-secure contexts) and surface a new localized git.ssh.copyUnsupported hint across all 15 locales when the Clipboard API isn't available. - Add aria-live="polite" to the "Copied" status span so assistive tech announces it after it inserts post-mount. - Mirror the remote-settings iso error mapping: both generateSshKey and importSshKey catch blocks now translate ssh-not-supported-iso into git.ssh.isoUnsupported instead of the raw engine message. * chore(funding): add FUNDING.yml to support GitHub sponsorship * fix(editor): resolve ssh auth form dead-end for first-time pull attempts A user who opens a repo with an SSH remote, never visits the overflow → SSH Keys subview, then tries to pull lands in a dead-end: the pull fails, the auth form opens on the token tab (wrong), and switching to SSH shows a "no keys" hint because the store never fetched the keys on disk. Even if they do manage to reach the SSH picker, the useState lazy initializer captured an empty list at mount, so sshKeyId stays '' and submit fails validation with "select a key" while the