mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
* 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 "<role> <part>". 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 <part>" 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<PenNode> annotations that no longer
compile since PenNode is a tagged union where only some variants
carry stroke). Test helpers now accept an untyped Record<string,
unknown> 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<string> to
Promise<string | ToolContent[]> 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 <di.yan@mail.polimi.it>
* 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 <select> visibly has an option.
Three-part fix:
1. GitPanelAuthForm now fires refreshSshKeys() from a mount effect so a
first-time pull sees keys on disk without requiring a detour through
the overflow menu.
2. A second effect re-seeds sshKeyId to hostKeys[0].id once keys arrive
async after mount, guarded by sshKeyId === '' so it doesn't fight an
explicit user selection.
3. preseedAuthMode in GitPanelRemoteControls now falls back to inferring
the tab from the remote URL scheme (via isSshRemoteUrl) when there is
no stored credential for the host, so SSH remotes land on the SSH tab
instead of token.
* chore(git): ignore .worktrees directory
Allow parallel worktrees for isolated development without polluting the
tracked tree.
* feat(git): add worktree-merge helpers and real-git spike tests for folder-mode conflicts
Adds worktree-merge.ts (~307 lines) with system-git wrappers needed for
Phase 7a folder-mode merge orchestration:
- sysMergeNoCommit: git merge --no-commit --no-ff
- sysListUnresolved: git ls-files -u (all conflict types)
- readMergeHead: filesystem check for .git/MERGE_HEAD
- sysShowStageBlob: git show :1/:2/:3: for index stage reads
- sysRestoreOurs: git checkout --ours -- <file>
- sysStageFile: git add -- <file>
- sysFinalizeMerge: git commit with MERGE_HEAD auto-parents
- sysAbortMerge: git merge --abort (idempotent)
- sysReadHead: git rev-parse HEAD
Adds 10 real-git spike tests in git-sys-real.test.ts that prove each
helper works against a real repo, covering tracked .op conflicts, mixed
.op + README conflicts, finalize, and abort scenarios.
* feat(git): implement folder-mode divergent merge with on-disk state tracking (Phase 7a)
Replaces the Phase 2c 'pull-non-fast-forward' throw for folder-mode divergent
merges with a full merge workflow using system-git:
- engineBranchMergeFolderMode: delegates to worktree-merge.ts helpers;
classifies conflicts as 'conflict' (.op involved), 'conflict-non-op'
(only non-.op files), or 'merge' (clean); reads base/ours/theirs blobs
from git index stages for pen-core semantic merge; restores .op to
readable JSON after sysRestoreOurs
- engineStatus: now checks on-disk MERGE_HEAD in addition to
session.inflightMerge; reports mergeInProgress=true and populates
unresolvedFiles from both sources (survives session close/reopen)
- engineApplyMerge: handles folder-mode by checking non-.op unresolved
files before applying, staging the .op file, and using sysFinalizeMerge;
throws merge-still-conflicted with file paths when non-.op files remain
- engineAbortMerge: widened to run git merge --abort in folder mode when
MERGE_HEAD is present, in addition to clearing session state
Updates the stale Phase 2c test to assert the new behavior (folder-mode
divergent pull enters merge workflow instead of throwing).
Adds 6 new Phase 7a engine tests covering: conflict detection, conflict-non-op
classification, on-disk MERGE_HEAD status detection, applyMerge with folder
conflicts, applyMerge throwing for unresolved non-.op files, and abortMerge
clearing both session and on-disk state.
* chore(git): document git ≥ 2.35 floor for ls-files --format=%(path)
Add an inline comment on sysListUnresolved noting that the --format
option requires git ≥ 2.35 (Feb 2022) so future readers are not
surprised by the compatibility requirement.
* test(git): close Phase 7a spike gate — add missing scenarios 3 and 4
Issue 1 (spike gate violation): the plan required 4 spike scenarios
but only 2 were covered. Add the remaining two:
Scenario 3 — rename conflict: verifies that when the feature branch
renames the tracked .op file, sysListUnresolved reports the original
path and stage 3 blob is absent. The engine-level assertion confirms
engineBranchMerge returns { result: 'conflict-non-op' } in this case
(stage 3 missing → semantic merge impossible → user resolves in terminal).
Scenario 4 — dirty working tree gate: documents that sysMergeNoCommit
throws engine-crash or enters conflict state when the tracked file has
uncommitted changes that the merge would overwrite. Content is never
silently lost. The renderer-side workingDirty gate remains the right
place to enforce cleanliness.
Issue 2 — noop path: adds the missing test for engineApplyMerge's
folder-mode { noop: true } branch (merge already committed externally).
Issue 3 — Option A contract: adds a test verifying that engineApplyMerge
throws merge-still-conflicted with a "call status() first" message when
called without a prior status() call after panel reopen (MERGE_HEAD on
disk but no in-memory inflightMerge).
* docs(git): record file-size debt for git-engine.ts (I3)
Phase 7a grew git-engine.ts to ~1982 lines (~2.5× the 800-line limit).
engineBranchMergeFolderMode stayed here rather than moving to
worktree-merge.ts because it needs session state (repoSession,
setInflightMerge) and ref-resolution helpers; worktree-merge.ts is
intentionally a pure shell-wrapper boundary with no session coupling.
* fix(git): collapse dead-code branch in engineBranchMergeFolderMode (I4)
Both arms of the if (!baseBlob || !oursBlob || !theirsBlob) check
returned { result: 'conflict-non-op' }; the inner guard on
nonOpConflicts was unreachable. Collapse to a single return with a
unified comment that names the rename-conflict case (stage :3: null)
explicitly. Also removes the now-unused nonOpConflicts declaration.
* test(git): make sysMergeNoCommit tests self-sufficient for git identity (I6)
setupDivergentRepo now runs git config user.name/email immediately after
git init so the real-git spike tests pass on machines without a global
git config — some git versions read identity during merge --no-commit
bookkeeping. Also adds a JSDoc note to sysMergeNoCommit documenting
this identity dependency for future maintainers.
* feat(store): add finalizeError and conflict-state reconciliation (Phase 7b)
- add finalizeError: string | null to conflict GitState variant
- exitTrackedFilePicker action: returns to ready when rebinding, closes
repo session and returns to no-file when no trackedFilePath yet
- applyMerge catches merge-still-conflicted and surfaces it inline via
finalizeError instead of transitioning to the generic error card
- resolveConflict clears finalizeError when user resolves a conflict
- buildConflictState helper accepts optional finalizeError param
- 10 new store tests covering all new paths (65 total)
* feat(panels): add shared git-panel log loader hook
- create use-git-panel-log-loader.ts: reads state.repo.currentBranch
instead of hardcoded 'main', fires on state.kind and currentBranch changes
- replace hardcoded loadLog({ ref: 'main' }) effects in git-panel-ready.tsx
and git-panel-conflict.tsx with useGitPanelLogLoader
- add polling skeleton to git-panel-conflict.tsx: fires refreshStatus()
every 3s while unresolvedFiles.length > 0, with in-flight guard and
error-stops-polling semantics
- 5 new log loader tests
* feat(panels): add inline history diff block for expanded rows
- create git-panel-history-diff.tsx: loads computeDiff(parentHash, hash)
on expand, renders loading/error/initial-commit/summary+patches states
- replace diffComingSoon placeholder in git-panel-history-list.tsx with
GitPanelHistoryDiff for both milestone and autosave expanded rows
- add git.history.diff.* i18n keys (English only; Phase 7c sweep for others)
- add git.conflict.banner.* and git.picker.back* i18n keys (Phase 7b)
- update history-list test: add computeDiff mock, update diffComingSoon
assertion to initial-commit check
- 7 new history-diff tests
* feat(panels): upgrade git conflict banner with progress and finalize UI
- replace abort-only shell with full status header: title, progress
counter (resolved X / Y), non-.op file list, dynamic primary button,
inline finalizeError display
- primary button logic: "Apply merge" when .op conflicts unresolved,
"Continue" when all .op resolved and non-.op files still pending
- abort button always visible (unified label git.conflict.abort)
- inline finalizeError strip for merge-still-conflicted errors
- 10 banner tests covering all new paths
- update git-panel-conflict tests for new button key names and add
finalizeError/computeDiff fields to mock state
* feat(panels): add back affordance to tracked-file picker
- add exitTrackedFilePicker to picker component: renders "Back" label when
trackedFilePath is set (rebinding from ready) and "Cancel" label when
null (first post-open/clone screen)
- button always calls exitTrackedFilePicker which owns the navigation logic
in the store (back → ready, cancel → close repo + no-file)
- 4 new picker tests covering back label, cancel label, and call contract
* fix(i18n): add Phase 7b i18n keys to all 14 non-English locales and fix test type errors
Adds git.conflict.banner.*, git.picker.back/backClose, and git.history.diff.*
keys to de/es/fr (localised) and hi/id/ja/ko/pt/ru/th/tr/vi/zh-tw/zh (English
placeholders, Phase 7c will localise). Fixes circular type reference in
git-panel-conflict-banner.test.tsx, removes unused React imports in two test
files, and types the computeDiff mock patches as unknown[] to satisfy tsc.
All 1353 tests pass; npx tsc --noEmit is clean.
* fix(panels): surface git conflict polling errors inline
Promotes pollError from a ref to React state so the first refresh
failure triggers a re-render. A small destructive-tone banner appears
between GitPanelConflictBanner and the history list, keyed by
git.conflict.banner.pollError (new i18n key). Polling does not retry
after the first error, matching the existing stop-on-first-error contract.
Adds a fake-timer test that asserts the error UI appears and refreshStatus
is not called a second time.
* fix(store): preserve in-memory conflict resolutions across refreshStatus
Polling refreshStatus every 3s was calling buildConflictState which
re-hydrated the conflict bag from the wire format, erasing any
resolution choices the user had recorded via resolveConflict. The
same re-hydration also reset finalizeError to null, hiding the
"apply merge failed" banner after each poll cycle.
When the store is already in conflict state and the backend still
reports mergeInProgress, skip the re-hydration and only update
unresolvedFiles (which can change as the user resolves non-.op
files externally). The .op conflict bag itself does not mutate
during a merge session — only renderer-side resolution state
layered on top of it by resolveConflict needs to persist.
Adds three regression tests covering: resolution preservation,
finalizeError preservation, and unresolvedFiles update.
* chore(store): correct git-store.ts line debt comment to 831 lines
* chore(i18n): remove dead git.history.diffComingSoon key
The last consumer was removed in Phase 7b. Key was orphaned across
all 15 locale files without any TSX reference.
* perf(panels): narrow use-git-panel-log-loader selector to primitive fields
The previous selector subscribed to the entire state object, so every
polling refreshStatus cycle (every 3s) caused a re-render even when
neither state.kind nor currentBranch had changed. Subscribe to two
primitive selectors instead so Zustand's Object.is comparison skips
spurious re-renders.
* fix(panels): add cancelled guard to conflict polling effect
A post-unmount resolve from the in-flight refreshStatus() call could
fire setPollError on an already-unmounted component (React 18 strict
mode or fast panel toggle). The cancelled flag ensures the catch
handler is a no-op after cleanup returns.
* feat(renderer): add renderNodeThumbnail helper for node conflict cards
Exports a graceful null-returning async helper that renders a single
PenNode to a PNG data URL using CanvasKit + OffscreenCanvas, falling
back to null in test/SSR environments where CanvasKit is unavailable.
* feat(panels): add conflict-formatters helpers and inline JSON editor component
Pure helpers for safe JSON parsing, node validation, pretty-printing,
and truncation; plus a local-state textarea component used in conflict
cards for manual resolution — no store sync on keystroke.
* feat(panels): add node conflict card with async thumbnail rendering
Card shows our/their node thumbnails via renderNodeThumbnail, choose
buttons with resolution state tracking, and a toggleable JSON editor
for manual node overrides.
* feat(panels): add field conflict card with three-way value display
Shows base/ours/theirs JSON values in pre blocks with choose buttons
and a toggleable JSON editor for arbitrary value overrides.
* feat(panels): add conflict list with bulk actions and wire into conflict panel
Dispatcher routes node/field conflicts to correct cards; list interleaves
them with a progress summary and bulk all-ours / all-theirs actions that
loop over unresolved items without new IPC.
* feat(store): reload tracked file and refresh log after successful applyMerge
makeReloadAfterApply reads state after the ready transition to reload
the .op file from disk and refresh log/status; also calls refreshStatus
on merge-still-conflicted so the conflict state stays accurate.
* test(panels): add full coverage for Phase 7c conflict UI components and store
47 new tests: renderNodeThumbnail null-fallback contract, JSON editor
validation, node/field conflict cards, conflict list bulk actions, plus
3 new git-store tests for applyMerge reload cascade and 3 pre-existing
tests updated to mock refreshStatus status correctly.
* i18n: full 15-locale sweep for Phase 7 conflict UI and diff block
Adds git.conflict.list.*, git.conflict.item.*, git.conflict.card.*,
git.conflict.editor.* keys to all 15 locales; replaces Phase 7b
English placeholders for git.conflict.banner.*, git.history.diff.*,
and git.picker.back/backClose with native translations in all 14
non-English locales.
* fix(panels): order git conflict list by document tree position (Phase 7c)
Walk the current document depth-first to emit node conflicts in the same
sequence as the layer panel. Doc-field conflicts follow sorted alphabetically
by path; orphan node conflicts (deleted on theirs side) are appended last.
Adds orderConflicts() pure helper to conflict-formatters.ts. Replaces the
stale "nodes-first / future phase" comment and adds document-order,
orphan-handling, and field-interleaving test coverage.
* fix(renderer): resolve \$variables in renderNodeThumbnail
Apply resolveNodeForCanvas (via getDefaultTheme) after ref resolution and
before flattenToRenderNodes, mirroring the same step in renderer.ts. Without
this, nodes with fill: '\$color-primary' rendered the raw string instead of
the resolved RGB value in the git conflict ours/theirs thumbnail cards.
Adds two tests confirming the code path executes without throwing when the
document carries variable definitions and themed variables.
* chore(store): correct git-store.ts line debt to 848 lines
Phase 7b comment claimed 831 lines and expected Phase 7c to fix it.
Phase 7c extracted makeReloadAfterApply but applyMerge's reload orchestration
and noop handling added lines back, landing at 848. Update the debt comment
to reflect the current count and defer further extraction to Phase 8+.
* fix(panels): pass real document to renderNodeThumbnail (Phase 7c C1)
fakeDoc stub had no variables, themes, or children — every $variable
reference silently failed to resolve and ref nodes rendered as
placeholders. Replace with the live document from useDocumentStore so
resolveNodeForCanvas and resolveRefs have full context.
* fix(panels): handle multi-page documents in orderConflicts and renderNodeThumbnail (Phase 7c C2)
orderConflicts walked document.children which is empty for multi-page
docs — all node conflicts fell through to orphan ordering, defeating
the tree-order spec fix. Now walk each page's children separately with
pageId in scope so conflict matching is (pageId, nodeId) scoped.
renderNodeThumbnail also used document.children for ref resolution;
replace with getAllChildren which handles both single-page and
multi-page layouts. Adds multi-page ordering tests.
* fix(panels): hoist conflict list useMemo above early return (Phase 7c I3)
useMemo after an early return violates rules-of-hooks. Drop it entirely
— orderConflicts is an O(n) walk over a modest conflict list so
memoisation adds complexity without measurable benefit.
* test(renderer): verify resolveNodeForCanvas is called (Phase 7c I6)
Previous Gap-2 tests only asserted result === null, which cannot
distinguish a successful variable-resolution pass from a silent throw.
Add vi.spyOn tests that confirm resolveNodeForCanvas is invoked with
the node, document variables map, and the active theme derived from
getDefaultTheme.
* refactor(store): drop unused applyMerge result variable (Phase 7c I7)
noop: true and noop: false both produce the same outcome (transition
conflict → ready, reload tracked file, refresh log). Drop the let
result binding and void result lint suppressor — the await is all
that is needed.
* fix(git): mark reopened mid-merge state and render abort-only banner (I2)
Engine: `engineStatus` panel-reopen branch now sets `reopenedMidMerge: true` and
filters the tracked `.op` file out of `unresolvedFiles` so it no longer appears
as a misleading non-op file in the banner.
Types: `GitStatusInfo` and `GitState.conflict` gain `reopenedMidMerge: boolean`.
`buildConflictState` accepts the flag. `refreshStatus` enters conflict state even
when `unresolvedFiles` is empty and `conflicts` is null (the degraded reopen path).
Banner: `GitPanelConflictBanner` hides the primary Continue/Apply button and shows
a localized warning message when `reopenedMidMerge` is true — abort is the only
valid action in the degraded state.
i18n: `git.conflict.banner.reopenMessage` added to all 15 locales (14 placeholders).
Tests: engine test asserts `reopenedMidMerge=true` and tracked file absent from
`unresolvedFiles`; store test asserts ready→conflict promotion for the reopen path;
banner tests assert abort-only UI and message presence.
* fix(store): handle conflict-non-op result in mergeBranch (I3)
`mergeBranch` previously fell through to `syncAfterHeadMove` when the engine
returned `conflict-non-op`, which reloaded the document and history — wrong
because the merge is still in flight. Now mirrors the `pull` implementation:
call `refreshStatus()` directly so the store promotes ready → conflict with
the correct `unresolvedFiles` list.
Test: asserts that `loadOpFileFromPath` is NOT called and the store lands in
`conflict` state with the non-op files listed.
* chore(i18n): clean stale git placeholder copy and add audit test (Phase 8a)
Delete dead keys git.placeholder.trackedPicker and git.placeholder.readyState
(zero references in src outside locales). Rewrite git.conflict.description to
reflect the shipped Phase 7 conflict-resolver UX instead of advertising it as
coming. Add git-locale-audit.test.ts to catch future stale-placeholder drift.
* style(i18n): prefer startsWith over regex caret in git-locale-audit test
* chore(i18n): propagate git copy cleanup to 14 non-english locales (Phase 8b)
Remove dead git.placeholder.trackedPicker and git.placeholder.readyState keys
from all 14 non-English locale files, and retranslate git.conflict.description
from the new English prose introduced in Phase 8a.
* style(panels): refine git panel visuals and relocate trigger to title bar
Replace the heavy form-like commit composer with an integrated card,
introduce a timeline rail for the history list, add icons to the
overflow menu items, unify the remote-settings and SSH-keys inputs with
the new card style, and give the empty state a decorative icon tile
with hoverable cards. Move the git trigger from the top-bar right
section to the center next to the file name as a pill that shows the
current branch, and swap the popover's Radix arrow for a centered
custom arrow that points at the trigger after aligning the popover to
center.
* fix(ci): configure git identity and bump Zig to 0.15.2
- Add global user.name/user.email and init.defaultBranch=main so
system-git-gated tests in apps/desktop/git/__tests__ can run
merge/commit and clone bare remotes that only push main.
- Bump mlugg/setup-zig from 0.14.0 to 0.15.2 across all workflows;
agent-native uses unmanaged ArrayList APIs that need Zig 0.15+, so
ensure-agent-native.cjs was silently failing and agent_napi.node
was missing at test time.
* chore(agent): advance submodule to v0.1.0, fetch prebuilt by submodule SHA
The agent submodule's Zig source uses Zig 0.15+ ArrayList APIs and had
several latent issues that only surfaced once CI started exercising
the release path (libc dependency, missing PIC, Windows napi linking,
e2e message-store leaks). Those are all fixed upstream in 77f5300,
which is the commit tagged as v0.1.0 with prebuilt .node binaries for
darwin-arm64, darwin-x64, linux-x64, and windows-x64.
ensure-agent-native.cjs now:
1. Uses the bundled / previously-built binary if present.
2. Reads the submodule commit, walks ZSeven-W/agent tags until it
finds the one whose commit SHA matches, and downloads the asset
for the current host. Tag-matched lookups guarantee the prebuilt
matches the source you actually pinned — releases/latest would
silently drift when someone bumps the submodule without tagging.
3. Falls back to `zig build napi` if no matching release exists
(and the local machine has Zig installed).
The previous ci.yml git-identity step stays in place — it is still
needed for the desktop/git system-git-gated tests that shell out to
`git merge`.
* fix(figma): raise .fig import ceilings to unblock large design systems
Issue #94: the 150MB compressed / 300MB decompressed caps were tight
enough to reject legitimate Figma exports from real design systems.
Keep the zip-bomb defence but bump the ceilings an order of magnitude:
MAX_COMPRESSED_SIZE: 150MB → 1GB
MAX_UNZIPPED_SIZE: 300MB → 2GB
MAX_IMAGE_SIZE: 150MB → 512MB
Error messages now format the active limit from the constant so the
string never drifts from the value again. Stale 100/50MB comment in
the security test file updated to match.
* chore(agent): advance submodule to 7a30996 with glibc-pinned linux prebuilt
v0.1.0 was republished after pinning Zig to x86_64-linux-gnu.2.17 so
the linux-x64 NAPI addon embeds "libc.so.6" as its SONAME instead of
the host's absolute libc linker-script path ("/lib/x86_64-linux-gnu/
libc.so"), which Node's dlopen rejects with "invalid ELF header".
Dockerfile: bump the bundled Zig from 0.14.0 to 0.15.2 so the
source-build fallback still compiles when building images for an arch
we don't publish prebuilts for yet (linux-arm64). The URL path shape
changed between releases, hence the renamed directory segments.
* chore(release): bump version to 0.7.0
---------
Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Di <di.yan@mail.polimi.it>
126 lines
3.6 KiB
Bash
Executable file
126 lines
3.6 KiB
Bash
Executable file
#!/bin/bash
|
|
# Publish all @zseven-w packages to npm with auto-incrementing beta version.
|
|
#
|
|
# Usage:
|
|
# bun run publish:beta # auto-increment beta number
|
|
# bun run publish:beta 5 # force beta.5
|
|
#
|
|
# Publishes: pen-types → pen-core → pen-figma → pen-renderer → pen-sdk → openpencil CLI
|
|
# All under the "beta" dist-tag, so `npm install` won't pick them up by default.
|
|
# Install with: npm install @zseven-w/openpencil@beta
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
BASE_VERSION=$(jq -r .version "$ROOT/package.json")
|
|
FORCE_NUM="${1:-}"
|
|
|
|
# --- Guard: block beta publish if release version already exists on npm ---
|
|
RELEASE_CHECK=$(npm view "@zseven-w/pen-types@${BASE_VERSION}" version 2>/dev/null || true)
|
|
if [ -n "$RELEASE_CHECK" ]; then
|
|
echo "ERROR: Release version ${BASE_VERSION} already exists on npm."
|
|
echo "Publishing a beta for an already-released version creates conflicting dependencies."
|
|
echo "Bump the version first (e.g. bun run bump 0.5.2), then publish beta."
|
|
exit 1
|
|
fi
|
|
|
|
# Packages in topological order
|
|
PACKAGES=(
|
|
packages/pen-types
|
|
packages/pen-core
|
|
packages/pen-figma
|
|
packages/pen-renderer
|
|
packages/pen-mcp
|
|
packages/pen-sdk
|
|
apps/cli
|
|
)
|
|
|
|
# --- Determine beta number ---
|
|
if [ -n "$FORCE_NUM" ]; then
|
|
BETA_NUM="$FORCE_NUM"
|
|
else
|
|
# Query npm for the latest beta of this base version.
|
|
# npm view returns a string (1 version) or array (multiple), or errors (404) if not found.
|
|
RAW=$(npm view "@zseven-w/pen-types" versions --json 2>/dev/null || true)
|
|
LATEST=$(echo "$RAW" | jq -r --arg base "$BASE_VERSION" '
|
|
if type == "object" and .error then empty # npm 404 error object
|
|
elif type == "array" then
|
|
map(select(type == "string" and startswith($base + "-beta."))) | last // empty
|
|
elif type == "string" and startswith($base + "-beta.") then .
|
|
else empty
|
|
end
|
|
' 2>/dev/null || true)
|
|
|
|
if [ -n "$LATEST" ]; then
|
|
PREV_NUM=$(echo "$LATEST" | sed "s/${BASE_VERSION}-beta\.//")
|
|
BETA_NUM=$((PREV_NUM + 1))
|
|
else
|
|
BETA_NUM=0
|
|
fi
|
|
fi
|
|
|
|
BETA_VERSION="${BASE_VERSION}-beta.${BETA_NUM}"
|
|
echo "Publishing version: $BETA_VERSION"
|
|
echo ""
|
|
|
|
# --- Set beta version in all package.json files ---
|
|
MODIFIED_FILES=()
|
|
for pkg in "${PACKAGES[@]}"; do
|
|
f="$ROOT/$pkg/package.json"
|
|
if [ -f "$f" ]; then
|
|
# Backup original
|
|
cp "$f" "$f.bak"
|
|
MODIFIED_FILES+=("$f")
|
|
|
|
# Set version and replace workspace:* refs
|
|
jq --arg v "$BETA_VERSION" '
|
|
.version = $v |
|
|
if .dependencies then
|
|
.dependencies |= with_entries(
|
|
if .value == "workspace:*" then .value = $v else . end
|
|
)
|
|
else . end |
|
|
if .devDependencies then
|
|
.devDependencies |= with_entries(
|
|
if .value == "workspace:*" then .value = $v else . end
|
|
)
|
|
else . end
|
|
' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
|
|
fi
|
|
done
|
|
|
|
# --- Restore on exit ---
|
|
cleanup() {
|
|
echo ""
|
|
echo "Restoring original package.json files..."
|
|
for f in "${MODIFIED_FILES[@]}"; do
|
|
if [ -f "$f.bak" ]; then
|
|
mv "$f.bak" "$f"
|
|
fi
|
|
done
|
|
echo "Done."
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# --- Compile CLI ---
|
|
echo "Compiling CLI..."
|
|
(cd "$ROOT" && bun run cli:compile)
|
|
echo ""
|
|
|
|
# --- Verify CLI ---
|
|
node "$ROOT/apps/cli/dist/openpencil-cli.cjs" --version
|
|
echo ""
|
|
|
|
# --- Publish ---
|
|
for pkg in "${PACKAGES[@]}"; do
|
|
dir="$ROOT/$pkg"
|
|
name=$(jq -r .name "$dir/package.json")
|
|
echo "Publishing $name@$BETA_VERSION ..."
|
|
(cd "$dir" && npm publish --access public --tag beta) || echo " ⚠ Failed (may already exist)"
|
|
echo ""
|
|
done
|
|
|
|
echo "================================"
|
|
echo "Published: $BETA_VERSION"
|
|
echo "Install: npm install @zseven-w/openpencil@beta"
|
|
echo "================================"
|