Commit graph

170 commits

Author SHA1 Message Date
Kayshen-X
e8ed1985b9 docs: wrap MseeP badge in Assessments section above License 2026-04-29 22:05:00 +08:00
Kayshen-X
29e0bdaeeb docs: move MseeP badge to bottom of all README versions 2026-04-29 22:00:00 +08:00
MseeP.ai
bd56eee0a0
Add MseeP.ai badge to README.md (#124) 2026-04-29 09:50:57 +08:00
Kayshen-X
902cc6d227 chore(release): merge v0.7.5 pre-release into main
Brings in three issue fixes from the 0.7.5 branch:

- fix(ai): unwrap fetch error.cause for actionable network failures (#121)
- fix(ci): always source-build agent-native and bundle to napi/ root
- docs: drop op export from CLI docs and clarify pen-mcp usage (#116, #117)

Release: https://github.com/ZSeven-W/openpencil/releases/tag/v0.7.5
2026-04-26 19:23:56 +08:00
Kayshen-X
5e6b7475a0 fix(ci): always source-build agent-native and bundle to napi/ root
The Zig NAPI provisioner had a silent failure mode that affected any
matrix entry without a matching ZSeven-W/agent prebuilt: the source-
build fallback dropped `agent_napi.node` at `zig-out/napi/...`, but
electron-builder only ships `packages/agent-native/napi/`. The addon
was therefore absent from the produced .exe / .dmg / .AppImage, and
every chat call died at the dynamic `@zseven-w/agent-native` import.

- Drop the prebuilt-download path; always build from source on the
  runner (mlugg/setup-zig is already provisioned for every workflow)
- Always copy the built binary into `napi/agent_napi.node` so
  electron-builder packages it
- Honor `ZIG_TARGET` to cross-compile (mac-x64 on arm64 runners now
  produces an x86_64 binary instead of a wrong-arch arm64 one)
- Add `OPENPENCIL_REQUIRE_AGENT_NATIVE=1` strict mode plus a
  dedicated "Verify agent-native binary" step in build-electron.yml
  so missing binaries fail the workflow loudly
- Add `OPENPENCIL_SKIP_AGENT_NATIVE=1` for publish-cli.yml, which
  never ships the addon and shouldn't pay for the build
2026-04-26 19:20:48 +08:00
Kayshen-X
f61ca2072b fix(ai): unwrap fetch error.cause for actionable network failures
Custom OpenAI-compatible providers surfaced Node's opaque
`TypeError: fetch failed` whenever the upstream HTTP call failed
(#121) — DNS, TLS handshake, connection refused, timeout — all
collapsed to the same useless string. The actual reason was already
on `error.cause` as a SystemError but never reached the user.

Add `formatFetchError()` that walks the cause chain (including
AggregateError emitted when undici tries multiple A records and each
attempt fails) and prefixes the SystemError code so users see
`ENOTFOUND: getaddrinfo ENOTFOUND api.foo.com` or
`ECONNREFUSED: connect ECONNREFUSED 127.0.0.1:443` instead of
`fetch failed`. Wire it into the model-list proxy (most common
trigger from the AI Settings dialog) and the builtin chat stream.

Closes #121
2026-04-26 19:20:32 +08:00
Kayshen-X
c4e5359596 docs: drop op export from CLI docs and clarify pen-mcp usage
The `op export` command was removed in 0.7.x but the README still
advertised it (#116). The pen-mcp README also documented an
`npx @zseven-w/pen-mcp` quick-start that never worked because the
package ships TypeScript source against workspace-only deps with no
`bin` entry (#117).

- Strip `op export` references from all 15 root and 15 cli READMEs
- Sync AGENTS.md, CLAUDE.md, apps/cli/CLAUDE.md to match the codegen-
  pipeline reality (no standalone export command anymore)
- Rewrite pen-mcp README's quick-start: explain the package ships as
  part of the OpenPencil app and external clients connect over HTTP

Closes #116
Closes #117
2026-04-26 19:20:14 +08:00
Fini
a5952bc88b fix(ai): swallow image-search network failures into the existing fallback
`fetchFromOpenverse` and `fetchFromWikimedia` were missing try/catch,
so a ConnectTimeoutError on `api.openverse.org` (frequent on networks
that can't reach Openverse) bubbled up to nitro's default handler and
turned a single image-search lookup into a HTTP 500 for the whole
design generation flow. The handler already treats `null` (Openverse)
and `[]` (Wikimedia) as the documented fallback signals — wrap the
fetches and return those on any throw, plus an explicit 8s
AbortSignal.timeout so the wait is bounded.
2026-04-26 07:30:00 +08:00
Fini
a727632a63 fix(ai): refresh DeepSeek defaults to v4 model series
`/models` now returns only deepseek-v4-pro and deepseek-v4-flash;
deepseek-chat / deepseek-reasoner sunset 2026-07-24 and the
deepseek-v3.2 hard-coded in the ark-coding fallback list never
existed. Both v4 models default to thinking enabled and the API
toggles via `{"thinking":{"type":"disabled"}}` — keep
`thinkingMode: 'disabled'` so the app's fast/non-thinking default
stays intact (server reasoning paths honor it; the Zig openai-compat
path doesn't emit the toggle yet, so calls through that path still
get provider-default thinking until it's wired). v4-pro promoted to
full tier; legacy aliases pinned to an exact RegExp so future
deepseek-* variants don't inherit a forced disabled mode.

Bandaid for the unwired toggle: v4-pro gets `timeoutMultiplier: 2`
because its default-on reasoning blows past the orchestrator's
planning timeout on long system prompts (observed in dev: planning
phase falls back, sub-agent then succeeds — UX degraded but
functional). Drop the multiplier once the Zig path actually sends
`thinking:{type:disabled}`.

Don't add a BUILTIN_MODEL_LISTS.deepseek entry — DeepSeek exposes
/v1/models, so let `fetchProviderModels` pull the live catalog
through `/api/ai/provider-models` instead of pinning a snapshot
(the ark-coding `deepseek-v3.2` ghost above shows what those
snapshots drift into).
2026-04-26 06:30:00 +08:00
Fini
a1a03bab33 chore(release): bump to v0.7.4 2026-04-17 19:20:19 +08:00
Fini
025ab6c4db style(ai): oxfmt reflow agent-tool-executor.ts 2026-04-17 19:20:18 +08:00
Fini
b1a63eb885 chore(agent): bump agent-native to v0.4.0 release 2026-04-17 19:04:15 +08:00
Fini
a805c73855 chore(agent): bump agent-native to upstream v0.3.0 merge
Absorbs upstream v0.2.0 + v0.3.0 (openai-compat tool_calls streaming,
HTTP error diagnostics) while keeping the MiniMax Anthropic-compat
placeholder quirk. End-to-end MiniMax tool_use verified post-merge.
2026-04-17 18:55:44 +08:00
Fini
66fa0064be chore(agent): bump agent-native for Windows N-API symbol export 2026-04-17 00:20:57 +08:00
Fini
17d2f5d744 chore(agent): bump agent-native for auto-detected MiniMax quirk 2026-04-17 00:18:49 +08:00
Fini
52d96a9c57 chore(agent): bump agent-native for MiniMax tool_use 400 fix
Picks up c5d9e2a in the submodule: single-space placeholder text block
when an assistant turn's content is tool_use-only, fixing intermittent
HTTP 400s from MiniMax-M2 and similar reasoning-first models.
2026-04-16 21:27:13 +08:00
Fini
cb7fa00f5b fix(types): re-export isBadgeOverlayNode as deprecated alias
The previous commit renamed isBadgeOverlayNode → isOverlayNode without
a compat export, which would break external consumers of the published
@zseven-w/pen-core package on upgrade. Add a deprecated alias so the
old import name keeps resolving. JSDoc flags the behavior change —
the alias no longer matches role:'badge'|'pill'|'tag', since those are
inline-component roles and must flow in auto-layout.
2026-04-16 21:09:56 +08:00
Fini
1c0cb8c7ef fix(canvas): require explicit role:'overlay' for layout-flow escape hatch
isBadgeOverlayNode matched role:'badge'|'pill'|'tag' and pulled those
children out of their parent's auto-layout, rendering them at (0,0) of
the parent and stacking them on top of siblings. But in this repo
badge/pill/tag are inline-component roles (see role-resolver NAME_EXACT_MAP
and strip-redundant-section-fills PROTECTED_ROLES) — they're meant to
flow in layout like any other child.

Rename to isOverlayNode and narrow to role:'overlay'. Add matching
"Layout-escape roles" guidance in role-definitions.md so generation
prompts can reach the new opt-in. Inline roles now flow correctly;
true floating decorations (notification dots, corner ribbons) still
have a dedicated marker.
2026-04-16 21:07:40 +08:00
Fini
572f71a1a8 feat(ai): detect sibling overlaps in snapshot_layout and guide fixes
snapshot_layout now emits an `overlaps` array listing sibling pairs whose
rendered bounds intersect, so text-only agents can diagnose stacking bugs
without a screenshot. When the shared parent has `layout: "none"` the
reason string points at the real cause (absolute x/y stacking) instead
of letting models hedge with height/padding tweaks. Handler prompt adds
a matching diagnosis workflow so agents fix the parent layout rather
than resizing the overlapping children.
2026-04-16 21:07:29 +08:00
Kayshen Xu
0154873fe9
V0.7.3 (#112)
* fix(ai): stop white section bands on dark-themed pages

- role-resolver: skip fixSectionAlternation when parent fill luminance < 0.5, so we no longer paint #FFFFFF/#F8FAFC over a dark root
- strip-redundant-section-fills: add SAFE_LIGHT_HEXES so stale whites from earlier runs (or weak-model hedges) are cleaned up on the sink side
- regression tests for both layers

* feat(ai): design.md-driven background + sidebar color pipeline

- orchestrator-sidebar-color: extract sidebar surface picker; prefer design.md palette role (sidebar/panel/surface) over catalog style-guide legacy cell
- orchestrator-planning: force rootFrame fill from design.md background when a user spec is provided, so sections don't inherit a bright catalog default
- orchestrator-prompt-optimizer: infer design.md background + neutral theme fallback for sub-agent prompts
- orchestrator-sub-agent / ai-prompts: tell sub-agents to leave section root fills unset when design.md drives the palette
- design-md-style-policy: surface-colors policy block keeps MCP and web pipeline aligned
- add planning + prompt-optimizer regression tests

* chore: ignore .omx/ directory

* Enable local OS fonts with vector rendering and proper permission handling (#110)

* docs(readme): update cover screenshot

* fix(renderer): enable local OS fonts with vector rendering and proper permission handling

* test(renderer): refactoring names and creating vi.stubGlobal for the navigator as it's not available in the test environment.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Daniel Chettiar <danielc@snapwork.com>

* feat(types): add AppendContext and SubTask.existingSectionLabels

* feat(ai): add detectAppendIntent for continue/append prompts

* feat(ai): detect append intent before generate_design dispatch

* feat(ai): add applyAppendContextToPlan helper

* feat(ai): reuse existing content-root in append mode

* feat(ai): sub-agent APPEND MODE preamble for existing siblings

* docs(ai): teach horizontal scroll card-row pattern

* chore(ai): enable incremental-add skill in generation phase

* fix(canvas): render synchronously on resize to prevent white flash

Setting canvas.width/height clears the pixel buffer to transparent.
resize() previously only marked dirty, leaving the canvas transparent
until the next RAF and showing the container bg-muted through for one
frame whenever the flex layout shifted (e.g. RightPanel mount on first
selection after idle). Rendering inline after recreateSurface fills
the new surface before the browser paints, closing that window.

* style: apply oxfmt formatting drift across web and renderer files

Non-semantic line-break and wrapping adjustments picked up by oxfmt.
No behavior changes.

* fix(mcp): run codex via shell on Windows to handle .cmd shims

Since Node 18.20/20.12 (CVE-2024-27980) execFileSync refuses to spawn
.cmd/.bat files directly and throws EINVAL. On Windows route through
execSync with shell resolution so PATHEXT picks whichever shim exists
(codex.exe / codex.cmd / codex.ps1).

* feat(editor): anchor paste to selected container or sibling

Pressing Cmd/Ctrl+V now inserts pasted nodes into the selected
container (if it can hold children) or immediately after the selected
node as a sibling, falling back to the root when nothing is selected.
Previously every paste landed at document root, which broke expected
behavior when working inside nested frames.

* docs(ai): expand horizontal scroll card-row example in overflow skill

Flesh out the inline JSON example so the generation-phase skill shows
the full clipContent + nested fit_content row pattern, instead of a
truncated snippet that left model output inconsistent.

* style(lint): clear 7 oxlint warnings from recent commits

- orchestrator-planning.test.ts: narrow fill-array type to
  Array<{...}> | undefined and use ?.[0] instead of unchecked [0]
  so optional chain does not throw on short-circuit
- mcp-install.ts: drop `?? {}` fallbacks when spreading
  config.mcpServers; spread of undefined in an object literal
  is a no-op (ES2018+)

* style(lint): clear remaining 15 oxlint warnings across repo

Removes pre-existing warnings not related to any single feature:

- no-useless-fallback-in-spread (6): drop `?? {}` when spreading
  possibly-undefined records (document-store-variable-actions,
  pen-mcp/tools/{variables,theme-presets}, variable-theme-manager)
- no-useless-spread (2): replace `[...iterable]` with `Array.from`
  in for-of snapshots (document-events, agent-indicator), keeping
  the re-entry-safe copy intent explicit
- no-control-regex (2): use `\P{ASCII}` unicode property escape
  instead of `[^\x00-\x7F]` to express "non-ASCII" without
  referencing U+0000 (opencode clients)
- no-new-array (1): `Array.from({ length }, () => '..')` in
  document-assets
- no-unused-vars (3): drop unused catch params (agent.ts,
  code-generation-pipeline) and unused globSync import
  (patch-srvx-bun)
- no-useless-escape (1): `[[{]` instead of `[\[{]` in
  chat-message-content regex

* ci(publish): check-before-publish and derive package names from manifests

Previous form was `npm publish || npm view <name>@<version>` with
hand-written names duplicated on every step. The CLI step mismatched
the real package name (@zseven-w/openpencil vs @zseven-w/openpencil-cli),
so re-running the job after a successful first publish failed on a
spurious 404. The fallback semantics also masked unrelated publish
failures (network, auth, notarize) behind a registry lookup.

New form queries the registry first, skips if the exact name@version
already exists, publishes otherwise — real failures still fail. Names
are read from each package.json so manifest/workflow drift is
impossible. Collapses 10 near-identical steps into one ordered loop.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Daniel Chettiar <74943095+1MochaChan1@users.noreply.github.com>
Co-authored-by: Daniel Chettiar <danielc@snapwork.com>
2026-04-15 22:40:11 +08:00
Kayshen Xu
a67debc233
V0.7.3 (#111)
* fix(ai): stop white section bands on dark-themed pages

- role-resolver: skip fixSectionAlternation when parent fill luminance < 0.5, so we no longer paint #FFFFFF/#F8FAFC over a dark root
- strip-redundant-section-fills: add SAFE_LIGHT_HEXES so stale whites from earlier runs (or weak-model hedges) are cleaned up on the sink side
- regression tests for both layers

* feat(ai): design.md-driven background + sidebar color pipeline

- orchestrator-sidebar-color: extract sidebar surface picker; prefer design.md palette role (sidebar/panel/surface) over catalog style-guide legacy cell
- orchestrator-planning: force rootFrame fill from design.md background when a user spec is provided, so sections don't inherit a bright catalog default
- orchestrator-prompt-optimizer: infer design.md background + neutral theme fallback for sub-agent prompts
- orchestrator-sub-agent / ai-prompts: tell sub-agents to leave section root fills unset when design.md drives the palette
- design-md-style-policy: surface-colors policy block keeps MCP and web pipeline aligned
- add planning + prompt-optimizer regression tests

* chore: ignore .omx/ directory

* Enable local OS fonts with vector rendering and proper permission handling (#110)

* docs(readme): update cover screenshot

* fix(renderer): enable local OS fonts with vector rendering and proper permission handling

* test(renderer): refactoring names and creating vi.stubGlobal for the navigator as it's not available in the test environment.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Daniel Chettiar <danielc@snapwork.com>

* feat(types): add AppendContext and SubTask.existingSectionLabels

* feat(ai): add detectAppendIntent for continue/append prompts

* feat(ai): detect append intent before generate_design dispatch

* feat(ai): add applyAppendContextToPlan helper

* feat(ai): reuse existing content-root in append mode

* feat(ai): sub-agent APPEND MODE preamble for existing siblings

* docs(ai): teach horizontal scroll card-row pattern

* chore(ai): enable incremental-add skill in generation phase

* fix(canvas): render synchronously on resize to prevent white flash

Setting canvas.width/height clears the pixel buffer to transparent.
resize() previously only marked dirty, leaving the canvas transparent
until the next RAF and showing the container bg-muted through for one
frame whenever the flex layout shifted (e.g. RightPanel mount on first
selection after idle). Rendering inline after recreateSurface fills
the new surface before the browser paints, closing that window.

* style: apply oxfmt formatting drift across web and renderer files

Non-semantic line-break and wrapping adjustments picked up by oxfmt.
No behavior changes.

* fix(mcp): run codex via shell on Windows to handle .cmd shims

Since Node 18.20/20.12 (CVE-2024-27980) execFileSync refuses to spawn
.cmd/.bat files directly and throws EINVAL. On Windows route through
execSync with shell resolution so PATHEXT picks whichever shim exists
(codex.exe / codex.cmd / codex.ps1).

* feat(editor): anchor paste to selected container or sibling

Pressing Cmd/Ctrl+V now inserts pasted nodes into the selected
container (if it can hold children) or immediately after the selected
node as a sibling, falling back to the root when nothing is selected.
Previously every paste landed at document root, which broke expected
behavior when working inside nested frames.

* docs(ai): expand horizontal scroll card-row example in overflow skill

Flesh out the inline JSON example so the generation-phase skill shows
the full clipContent + nested fit_content row pattern, instead of a
truncated snippet that left model output inconsistent.

* style(lint): clear 7 oxlint warnings from recent commits

- orchestrator-planning.test.ts: narrow fill-array type to
  Array<{...}> | undefined and use ?.[0] instead of unchecked [0]
  so optional chain does not throw on short-circuit
- mcp-install.ts: drop `?? {}` fallbacks when spreading
  config.mcpServers; spread of undefined in an object literal
  is a no-op (ES2018+)

* style(lint): clear remaining 15 oxlint warnings across repo

Removes pre-existing warnings not related to any single feature:

- no-useless-fallback-in-spread (6): drop `?? {}` when spreading
  possibly-undefined records (document-store-variable-actions,
  pen-mcp/tools/{variables,theme-presets}, variable-theme-manager)
- no-useless-spread (2): replace `[...iterable]` with `Array.from`
  in for-of snapshots (document-events, agent-indicator), keeping
  the re-entry-safe copy intent explicit
- no-control-regex (2): use `\P{ASCII}` unicode property escape
  instead of `[^\x00-\x7F]` to express "non-ASCII" without
  referencing U+0000 (opencode clients)
- no-new-array (1): `Array.from({ length }, () => '..')` in
  document-assets
- no-unused-vars (3): drop unused catch params (agent.ts,
  code-generation-pipeline) and unused globSync import
  (patch-srvx-bun)
- no-useless-escape (1): `[[{]` instead of `[\[{]` in
  chat-message-content regex

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Daniel Chettiar <74943095+1MochaChan1@users.noreply.github.com>
Co-authored-by: Daniel Chettiar <danielc@snapwork.com>
2026-04-15 22:19:12 +08:00
Kayshen Xu
904c033290
V0.7.2-bugfix (#109)
* Stabilize synced main for AI handoff, drag nesting, and Electron dev (#104)

* docs(readme): update cover screenshot

* fix: stabilize electron dev sync and codex env passthrough

* Preserve nested frame behavior during drag reparenting

Reparenting across containers used raw local coordinates and root-only clipping assumptions, which made nodes jump visually and caused dragged frames to lose clip/corner semantics after nesting. This adapts the drag-reparent fix to the current upstream store architecture, keeps frame/shape nodes from auto-detaching on canvas drags, and promotes formerly root-only frame clipping to explicit clipContent when nested.

Constraint: Latest upstream workspace checkout is incomplete locally (missing workspaces/deps), so full upstream verification could not be rerun in this environment
Rejected: Keep using raw local x/y during parent changes | fails for auto-layout/padding-rendered positions
Rejected: Make all nested frames clip unconditionally | would change non-clipping containers
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Preserve visual-position conversion through rendered coordinates when parent changes; local coordinates alone are insufficient once layout participates
Not-tested: Fresh full workspace typecheck/test/build on latest upstream checkout (blocked by missing workspace/dependency setup in this local clone)

* Keep AI codegen requests bounded while exporting asset bundles

The AI codegen pipeline needed two stability fixes: exported design images had to flow through chunk/assembly prompts as reusable asset hints, and oversized chat payloads needed a local guard before hitting provider limits. This commit wires asset extraction into the planning pipeline, threads exported asset paths into prompt assembly, and rejects obviously overlarge chat requests with an actionable client-side error.

Constraint: This branch is split out from a larger local fix stack, so only codegen/prompt/context files are included here
Constraint: Provider request limits are approximate locally, so the payload guard must be conservative rather than exact
Rejected: Inline base64 assets directly into prompts | explodes request size and repeats the same payload per chunk
Rejected: Let provider errors handle oversized payloads | too slow and opaque for users
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep asset references flowing as stable ./assets paths and enforce payload limits before fetch to avoid silent request bloat
Tested: bun x tsc -p apps/web/tsconfig.json --noEmit; cd apps/web && bun --bun vitest run src/services/ai/__tests__/context-optimizer.test.ts src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts; bun run build
Not-tested: Manual end-to-end AI generation with live providers

* Explain sanitized design views instead of leaving AI to guess

The sanitized structure bundle already stabilized asset paths, but it still exposed low-level image/layout/component fields that models had to interpret on their own. This change adds explicit consumer-view enrichment for fills, layout, text, variables, themes, and component semantics, carries original image size through the Figma import path, and augments sanitized bundles with summary/highlight guidance for downstream AI consumers.

Constraint: This branch is intentionally stacked on the asset-bundle PR because it extends the sanitized/codegen asset pipeline rather than replacing it
Constraint: Figma import data is not always complete, so original image size must be preserved when present and inferred only as a fallback downstream
Rejected: Keep sanitized.json as a pure field-level dump | still leaves AI to misread transforms, layout, and component relationships
Rejected: Put all explain text directly in asset extraction helpers | mixes resource stabilization with semantic enrichment responsibilities
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Treat consumer-view enrichment as a distinct layer on top of stable asset extraction; future AI-facing semantics should land there instead of leaking into unrelated pipeline code
Tested: bun x tsc -p apps/web/tsconfig.json --noEmit; cd apps/web && bun --bun vitest run src/services/ai/__tests__/consumer-view-enrichment.test.ts src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts ../../packages/pen-figma/src/figma-fill-mapper.test.ts; bun run build
Not-tested: Manual prompt-to-code generation quality with live provider responses

* Restore code-panel bundle exports for AI handoff flows

The code generation backend still produced asset manifests and AI structure bundles, but the code panel UI no longer exposed those export paths after later sync work. This commit reconnects the panel to bundle export actions, restores ZIP download behavior when generated code includes exported assets, and locks the affordances with focused panel tests.

Constraint: Other local fixes are still in progress in the working tree, so this commit is intentionally limited to the code-panel export surface
Rejected: Rebuild export support in a separate panel | users expect the export actions to remain where generation results are shown
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep code-panel UI aligned with codegen asset/bundle backends whenever generation result shape changes
Tested: cd apps/web && bun --bun vitest run src/components/panels/code-panel.test.tsx src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts; bun run build
Not-tested: Manual click-through of AI Bundle and Download ZIP in the desktop/web UI

* Unblock electron dev startup in the incomplete local workspace

The local workspace was failing before the app could even start: the skills plugin hard-required js-yaml from a node_modules layout that was not present, Vite dev under Bun hit Nitro NodeResponse incompatibilities, and the web tsconfig was missing path mappings for local packages. This commit removes the unnecessary js-yaml dependency from the skills loader, runs Vite under Node for dev startup, hardens readiness probing with socket checks, and points TypeScript/Vite at the in-repo package sources.

Constraint: The current local clone has incomplete hoisted/workspace installation state, so dev startup must not depend on root package links being perfectly present
Constraint: Bun + Nitro dev currently mis-handle NodeResponse in this environment, so the safest startup path is Node-hosted Vite
Rejected: Keep js-yaml and require everyone to fix local hoisting first | still leaves electron:dev broken in the current environment
Rejected: Continue running Vite dev through Bun | reproduces the NodeResponse/Parse Error failure on /api and /editor requests
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the dev launcher biased toward resilient local startup, even when the workspace install shape is imperfect
Tested: bun -e import('./packages/pen-ai-skills/vite-plugin-skills.ts').then(() => console.log('SKILL_PLUGIN_IMPORT_OK')); bun electron:dev verified Vite ready, MCP/Electron compiled, Electron launched, MCP sync log emitted
Not-tested: Long-running interactive desktop session after startup

* fix(figma): preserve cropped image fill transforms

The synced branch started exporting original image dimensions but dropped the
existing crop transform semantics from the shared image-fill type and both
Figma mappers. That broke the new regression test and stripped metadata that
AI consumer-view/bundle code already relies on.

Constraint: keep app and package Figma mappers in lockstep
Rejected: loosen the new regression test | would hide a real metadata regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: when extending image fill metadata, update shared pen-types and both Figma mapper copies together
Tested: bun --bun run test (148/149 files passed; only server/__tests__/sse-keepalive.test.ts blocked by missing agent_napi.node), cd apps/web && bun --bun vitest run src/canvas/skia/drag-reparent-policy.test.ts src/components/panels/layer-dnd-utils.test.ts src/stores/document-position-utils.test.ts src/components/panels/code-panel.test.tsx ../../packages/pen-renderer/src/__tests__/document-flattener.test.ts ../../packages/pen-figma/src/figma-fill-mapper.test.ts, cd apps/web && bun --bun vitest run src/services/ai/__tests__/codegen-assets.test.ts src/services/ai/__tests__/structure-bundle.test.ts src/services/ai/__tests__/consumer-view-enrichment.test.ts, cd apps/web && bun --bun vitest run src/utils/__tests__/security.test.ts, bun test scripts/loopback-no-proxy.test.ts, npx tsc --noEmit, bun --bun run build
Not-tested: server/__tests__/sse-keepalive.test.ts without a locally built @zseven-w/agent-native addon

* docs(editor): normalize new PR comments to English

The PR had a handful of newly introduced Chinese code comments in dev, sync, and AI helper paths. This follow-up keeps the implementation unchanged while translating those comments to English so the PR stays consistent with the repository comment-language expectation.

Constraint: The request was limited to comment language cleanup after the conflict-resolution merge, so behavior had to remain unchanged
Rejected: Leave the mixed-language comments in place | conflicts with the PR requirement for English comments
Rejected: Broader repository-wide translation sweep | unnecessary scope expansion beyond the PR-introduced comments
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep code comments in English on this branch, even when local notes or working memory are in another language
Tested: bun test scripts/loopback-no-proxy.test.ts apps/desktop/__tests__/dev-utils.test.ts; cd apps/web && bun --bun vitest run server/__tests__/mcp-sync-state-active.test.ts src/canvas/skia/__tests__/skia-interaction.test.ts; npx tsc --noEmit; branch-diff comment scan for Han characters in comment lines
Not-tested: Manual runtime behavior, since this change only rewrote comments

* style(editor): apply repository formatting expected by CI

The PR was failing the CI Format check after the conflict-resolution and comment-normalization follow-ups. This commit applies the repository formatter output to the files touched by the branch so CI sees the exact formatting it expects, without changing behavior.

Constraint: The failing GitHub Actions job stopped at Format check, so the fix had to match oxfmt output rather than introduce functional changes
Rejected: Leave the branch as-is and rely on local formatting differences being acceptable | CI explicitly rejects the current formatting
Rejected: Broader code cleanup beyond formatter output | unnecessary scope while repairing the failing check
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: After conflict resolution or comment-only edits on this repo, run bun run format:check before pushing because formatter expectations are stricter than the existing file style in some touched files
Tested: bun run format:check; bun run lint; npx tsc --noEmit
Not-tested: Full test suite after this formatting-only commit (previous run showed formatting was the first CI blocker)

* refactor(editor): remove proxy-specific dev workarounds from PR

The PR no longer needs the loopback proxy bypass layer, so this cleanup removes the proxy-specific dev entrypoint, environment bootstrap, helper module, and its tests while keeping the unrelated Electron and AI handoff changes intact.

Constraint: Removal had to be limited to proxy-related code on PR #104 without undoing the other merged fixes on the branch
Rejected: Keep the helper and stop using it | leaves proxy-specific maintenance surface and tests in the PR
Rejected: Revert the entire Electron dev file to upstream earlier than necessary | would risk dropping unrelated local conflict-resolution choices beyond the proxy scope
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If proxy handling is reintroduced later, keep it out of this PR unless there is a dedicated, separately justified change for it
Tested: bun run format:check; bun run lint; npx tsc --noEmit
Not-tested: Manual electron:dev behavior after removing the proxy-specific launcher path

* docs(ai): translate JSON-facing semantic descriptions to English

The PR still emitted Chinese semantic description strings inside the AI consumer-view and structure-bundle JSON outputs. This change translates those JSON-facing runtime descriptions and updates the affected tests so exported AI-facing structure data is consistently English.

Constraint: The request was limited to JSON description strings, so the change had to preserve the same semantics and structure while only translating output text
Rejected: Leave Chinese test fixtures and runtime descriptions in place | conflicts with the requirement for English JSON descriptions
Rejected: Broader i18n cleanup outside these AI JSON description paths | unnecessary scope expansion beyond the requested exported-description surface
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep AI/exported JSON explanation strings in English unless a future change explicitly adds localized output modes
Tested: cd apps/web && bun --bun vitest run src/services/ai/__tests__/consumer-view-enrichment.test.ts src/services/ai/__tests__/structure-bundle.test.ts src/services/ai/__tests__/codegen-assets.test.ts; bun run format:check; npx tsc --noEmit
Not-tested: Full app runtime flows that consume these JSON descriptions outside the covered unit tests

* refactor(ai): remove remaining network-proxy handling

The current project still carried Anthropic proxy-specific heuristics and environment handling outside the PR-specific cleanup. Since the earlier crashes and connectivity issues were unrelated to proxying, this removes the remaining network-proxy branches, model remapping, and TLS override advice while leaving unrelated request flows intact.

Constraint: The cleanup needed to remove proxy-specific logic without disturbing unrelated transport concepts such as app-internal API proxy routes or React proxy objects used in tests
Rejected: Keep the proxy heuristics as dormant fallback logic | preserves misleading operational guidance and dead maintenance surface
Rejected: Rename every remaining literal use of the word proxy in the repo | would overreach into unrelated concepts like internal API proxying and JS Proxy-based test setup
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If endpoint-specific compatibility logic is needed later, add it as explicit endpoint handling rather than generic proxy heuristics
Tested: bun run format:check; bun run lint; npx tsc --noEmit; repo-wide search for network-proxy env references after cleanup
Not-tested: End-to-end Claude connection flows against custom base URLs after removing proxy-specific remapping

* fix(electron): keep Node-backed dev launch for Nitro compatibility

Comparing against upstream commit 7271a03 confirms the current Electron dev fix is not the same idea as the original Bun-based launcher. The upstream version starts Vite with Bun, while the observed failure shows Nitro now crashes in that path with "Vite environment nitro is unavailable". This keeps the non-proxy Node-backed launcher because it fixes the actual regression without restoring the removed proxy code.

Constraint: The request preferred reverting to the upstream original only if the intent matched, but the current Nitro/Electron failure proves the upstream Bun launcher is no longer equivalent in behavior
Rejected: Restore the exact 7271a03 Bun launcher | reproduces the Nitro dev-worker crash and ERR_EMPTY_RESPONSE in Electron
Rejected: Reintroduce the old proxy workaround bundle | unrelated to the reproduced failure and already removed by request
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Electron dev on the Node-backed Vite launcher unless Nitro/Bun dev compatibility is revalidated with a real startup test
Tested: bun run electron:dev (reached Electron launch after Vite/MCP/Electron compile steps); bun test apps/desktop/__tests__/dev-utils.test.ts; bun run format:check; npx tsc --noEmit
Not-tested: Full interactive manual editor workflow after Electron launch

---------

Co-authored-by: Fini <fini.yang@gmail.com>

* fix(ai,cli): openai-compat turn-2, StepFun reasoning+451, Mac CLI discovery

Round up the v0.7.2 stability fixes for AI connectivity and local CLI
detection that surfaced during real user runs against GLM, StepFun, and
Mac users on nvm/fnm/pnpm/bun/mise/asdf/fish shells.

Provider (via @zseven-w/agent-native v0.3.0 submodule bump):
- OpenAI-compat providers can now complete multi-turn tool-calling loops:
  the request builder translates Anthropic-shaped message history
  (tool_use / tool_result blocks, thinking) into OpenAI's tool_calls +
  role="tool" form so turn 2 no longer 400s. system_prompt is finally
  injected instead of being silently dropped.
- The SSE parser accepts `delta.reasoning` (StepFun step_plan) alongside
  `reasoning_content` (GLM / DeepSeek / Qwen), and also streams tool_call
  fragments, which unblocks GLM / dashscope and stops the
  firstTextTimeout → fetch abort → std.http panic → Bun segfault cascade.
- HTTP 451 (StepFun content-safety) surfaces as InvalidRequest with a
  specific "content blocked by provider safety filter" message instead
  of an opaque error_server.

Server route + client watchdog:
- /api/ai/chat forwards the provider's last_error string
  (result.errors[0]) so users see "HTTP 451 content blocked" rather than
  "Provider error: error_server".
- streamChat clears firstTextTimeout on thinking chunks (when
  thinkingResetsTimeout=true), so models that stream long reasoning
  before any text aren't falsely killed as "stuck".

Orchestrator sub-agent resilience:
- Failed sub-agents (empty response / unparseable output) now retry once
  with a minimal ~3KB kernel prompt (schema + jsonl-format only). Only
  the failing subtask re-runs — successful earlier sections are kept.
- Deterministic refusals (HTTP 400/401/429/451, "content blocked",
  "censorship", "authentication failed") short-circuit the retry ladder
  so a 4-minute StepFun safety scan isn't spent twice in a row.

Local CLI discovery (Mac users on managed shells):
- New server/utils/cli-resolver-helpers.ts exports probeViaLoginShell()
  and posixUserBinDirs(). Login-shell probe asks $SHELL (or zsh/bash
  fallback — fish added at /opt/homebrew/bin/fish and friends) with
  `-ilc 'command -v <cli>'` so nvm/pnpm/bun/mise/asdf/volta/fnm shims
  are visible even when Electron scrubs the inherited PATH.
- resolveClaudeCli / resolveGeminiCli / resolveCopilotCli and the
  inline codex/opencode resolvers in connect-agent.ts all run the same
  PATH → login-shell → npm-prefix → user-bin candidates ladder. Each
  step logs via serverLog to ~/.openpencil/logs/server-YYYY-MM-DD.log
  for remote diagnosis.

Builtin provider preset:
- Add StepFun Coding Plan (api.stepfun.com/step_plan/v1, label "StepFun
  Coding Plan") alongside the existing StepFun preset.

Version bump 0.7.1 → 0.7.2 across all workspaces.

---------

Co-authored-by: RaisCui <857943+raiscui@users.noreply.github.com>
Co-authored-by: Fini <fini.yang@gmail.com>
2026-04-14 21:42:56 +08:00
Kayshen Xu
7271a03833
V0.7.1 (#103)
* fix(desktop,web): rebuild Electron dev sync + bitmap dragging fix on v0.7.1 (#99)

Re-applies b046a0d from the closed PR #97 now that the base is v0.7.1.
Original conflict against v0.7.0 came from the release branch churn —
the cherry-pick onto v0.7.1 applies cleanly.

Keeps dev-startup, sync-noise, and bitmap-dragging fixes; drops the
loopback proxy helper scripts upstream rejected in PR #92. Readiness
probe now does direct socket checks inside the existing dev entrypoint;
sync hardening stays focused on request diagnostics, backpressure, and
drag-time clip-rect correctness.

Original commit: b046a0d
Supersedes: #97 (closed, head branch deleted)

Co-authored-by: Rais <vdcoolzi@gmail.com>

* fix(canvas): use ImageFill.url in skia-interaction test

The image-backed rectangle fixture in the skia-interaction test used
`{ type: 'image', src: '…' }`, but `ImageFill` in pen-types declares
the field as `url`. `npx tsc --noEmit` flagged it as TS2352 on the
`as PenNode` cast. One-word rename.

The squash-merge of #99 captured an earlier snapshot that did not
include this fix, so re-apply directly on v0.7.1.

* feat(cli): add `op install` / `op uninstall` for openpencil-skill

Bundle skill files at build time (scripts/bundle-skill.ts → skill-bundle.json)
so users without GitHub access can install directly. Falls back to git clone
when the bundle is empty.

Supports auto-detection of: Claude Code, Codex, Cursor, Gemini CLI, OpenCode.
CI workflows updated to checkout openpencil-skill before cli:compile.

* fix(panels): allow reparenting nodes into rectangle in layer panel

CONTAINER_TYPES was missing 'rectangle', preventing drag-drop into
rectangles even though the data model (ContainerProps) and store
(moveNode) both support it.

* fix(agent,ai): tool_exec reset + insert_node with after + move_node + CRUD tools

- fix(agent): reset StreamingToolExecutor between turns — prevents stale
  tool_use IDs that caused 400 errors on multi-turn tool calls (MiniMax etc.)
- feat(ai): add insert_node "after" parameter — auto-resolves sibling's
  parent and position for intuitive node insertion
- feat(ai): add move_node and insert_node to CRUD tool set
- feat(ai): add Chinese keywords (增加/添加/插入) to design intent detection
- fix(ai): insert_node uses addNode directly for existing parents instead
  of streaming pipeline, fixing parent resolution

* feat(ai): route CRUD intents to lightweight prompt and tool set

CRUD operations (read/update/delete) now get a focused system prompt
without design generation instructions, and use getCrudToolDefs()
(which includes insert_node and move_node) instead of the full design set.

* fix(agent,mcp): submodule update + MCP tool improvements

- Update agent-native submodule (tool_exec reset, HTTP error diagnostics)
- Improve MCP tool descriptions and parameter schemas
- Enhance agent.ts error handling

* feat(ai): add PenNode examples to CRUD prompt for complete node generation

The CRUD system prompt now includes button and text node examples
showing the full structure (fills, children, icons, layout) so models
generate complete nodes instead of empty frames.

* Update agent-native submodule to commit f9633a8, ensuring compatibility with recent changes and improvements in the agent's functionality.

* fix(ci,agent): generate skill-bundle before type check + fix moveNode arg

- Add `bun run cli:bundle-skill` step in CI before `tsc --noEmit` so
  skill-bundle.json exists when type-checking the CLI
- Fix moveNode index parameter: default to -1 when undefined

* fix(ci): add pen-engine and pen-react to npm publish workflow

Insert pen-engine and pen-react in topological order between
pen-renderer and pen-mcp so they are published before pen-sdk.

* docs: add MIT LICENSE and README to all packages

- Add MIT LICENSE to pen-ai-skills, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk, pen-types
- Add README.md to pen-engine, pen-react, pen-mcp, pen-ai-skills

* docs: comprehensive package metadata, README, CLAUDE.md, and LICENSE

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add CLAUDE.md to pen-types, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk
- Add MIT LICENSE to all packages
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg → ZSeven-W

* docs: package metadata, README, LICENSE, and CLAUDE.md for all packages

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add MIT LICENSE to all packages missing it
- Add CLAUDE.md to pen-types
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg to ZSeven-W

* docs: rewrite README for pen-core, pen-figma, pen-renderer, pen-sdk

Comprehensive READMEs with full API reference, usage examples,
feature tables, and architecture overview for each package.

* feat(acp): acpAgents store — persist, hydrate, CRUD actions

* feat(acp): pen-acp package + agent settings types + store

- pen-acp/types.ts: AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpConnectionState
- pen-acp/client.ts: connectAcpAgent (local stdio + remote WebSocket), disconnectAcpAgent
- pen-acp/event-adapter.ts: acpUpdateToSSE (ACP session/update → SSE events)
- agent-settings.ts: AcpAgentConfig type, widen ModelGroup/GroupedModel.provider
- agent-settings-store.ts: acpAgents persist/hydrate/CRUD + acpConnectionStatus

* fix(acp): remove unused type imports in client.ts

* feat(acp): ACP agent settings UI — form, cards, connect/disconnect

* feat(acp): add AcpAgentSection to Agents settings tab

* refactor(agent): AgentSession as discriminated union (native | acp)

* feat(acp): connection manager — connect, disconnect, cleanup

* feat(acp): connect/disconnect API route

* feat(acp): ACP branch in agent result handler

* fix(agent): use NativeAgentSession type for runDelegateMember

* feat(acp): ACP prompt SSE stream in agent endpoint

* feat(acp): ACP agents in model list + request routing

* i18n(acp): add ACP agent translation keys for all 15 locales

* fix(i18n): translate ACP keys for all 15 locales + fix missing key references

- zh.ts/zh-tw.ts: proper Chinese translations (Agent not translated)
- All other locales: translated from English placeholders
- Fix acp.add → acp.addAgent, acp.disconnected → acp.notConnected

* chore: bump agent-native submodule — surface upstream HTTP errors

* feat(acp,build,codegen): comprehensive fixes for ACP integration + prod build

ACP agent integration:
- Rewrite system prompt to enforce layered design pipeline (get_design_prompt
  → design_skeleton → design_content → design_refine) for higher quality output
- Use correct PenNode field names: content for text, iconFontName for icons
- Strict JSON rules to prevent empty-key / trailing-comma / smart-quote errors
- Prefer icon_font over path icons (standalone MCP has no hooks registered)
- Auto-start MCP server before ACP session (lazy bootstrap)
- Auto-reconnect ACP on stale connection (dev server restart scenario)
- Auto-approve tool permission requests (trust model: user configured agent)
- Use type: 'http' + headers: [] for MCP server config (SDK schema requirement)
- Persist ACP connections via globalThis so they survive Vite HMR

Build / packaging:
- Place agent-native under server/node_modules for Nitro to resolve at runtime
- Copy agent_napi.node to napi/ as extraResource
- Kill detached MCP server on Electron quit (before-quit + dev SIGINT handlers)
- Capture drag-dropped filesystem path via webUtils.getPathForFile so recent
  files entries are clickable after reopening

Codegen:
- Compact JSON (no indent) + strip noise fields (id, parentId, default rotation
  /opacity/visible, layout-managed x/y) to reduce request body size by 60-70%
  so proxies don't reject with 403 'Request not allowed'

MCP batch_design robustness:
- splitOperations tracks bracket/quote balance → multi-line JSON now works
- Auto-normalize fill/stroke shorthand forms
- Collect per-line errors instead of aborting whole batch
- Repair empty keys, trailing commas, smart quotes in JSON
- Bindless I(...) form supported (auto-generates binding)

UI:
- ModelDropdown / ChatInput handle ACP model icons (Plug)
- Reset streaming state + abort controller on ACP error path
- Strip h3 JSON error wrapper so chat shows clean error messages
- ACP agent settings form + cards + connect/disconnect

* fix(types): resolve TS errors in CI typecheck

- acp-connection-manager.ts: correct relative import path (utils/ → src/types)
- ai-chat-handlers.ts: cast currentProvider to AIProviderType at design-generator callsites
- ai-chat-panel.tsx: explicitly type groups as ModelGroup[] so 'acp' string fits the widened union
- acp-agent-settings.tsx: cast window through unknown for Record lookup
- electron.d.ts: add getPathForFile to ElectronAPI declaration
- builtin-provider-presets.ts: drop now-redundant config.preset !== 'custom' check (handled by early return)
- pen-acp/client.ts: cast Writable/Readable.toWeb to typed Streams; coerce nullish agentInfo fields to undefined

* fix(ci): checkout openpencil-skill into workspace subdirectory

GitHub Actions rejects checkout paths outside the runner workspace.
Move from ../openpencil-skill to external/openpencil-skill and pass
SKILL_ROOT env to the bundle script so it can locate the cloned repo.

* chore(ci): checkout openpencil-skill in ci workflow

Bundle the real skill contents in CI builds instead of falling back
to the empty bundle. Mirrors the checkout pattern used by the
electron and npm publish workflows.

---------

Co-authored-by: Rais <vdcoolzi@gmail.com>
2026-04-13 21:40:40 +08:00
Kayshen Xu
b51d069ea6
V0.7.1 (#102)
* fix(desktop,web): rebuild Electron dev sync + bitmap dragging fix on v0.7.1 (#99)

Re-applies b046a0d from the closed PR #97 now that the base is v0.7.1.
Original conflict against v0.7.0 came from the release branch churn —
the cherry-pick onto v0.7.1 applies cleanly.

Keeps dev-startup, sync-noise, and bitmap-dragging fixes; drops the
loopback proxy helper scripts upstream rejected in PR #92. Readiness
probe now does direct socket checks inside the existing dev entrypoint;
sync hardening stays focused on request diagnostics, backpressure, and
drag-time clip-rect correctness.

Original commit: b046a0d
Supersedes: #97 (closed, head branch deleted)

Co-authored-by: Rais <vdcoolzi@gmail.com>

* fix(canvas): use ImageFill.url in skia-interaction test

The image-backed rectangle fixture in the skia-interaction test used
`{ type: 'image', src: '…' }`, but `ImageFill` in pen-types declares
the field as `url`. `npx tsc --noEmit` flagged it as TS2352 on the
`as PenNode` cast. One-word rename.

The squash-merge of #99 captured an earlier snapshot that did not
include this fix, so re-apply directly on v0.7.1.

* feat(cli): add `op install` / `op uninstall` for openpencil-skill

Bundle skill files at build time (scripts/bundle-skill.ts → skill-bundle.json)
so users without GitHub access can install directly. Falls back to git clone
when the bundle is empty.

Supports auto-detection of: Claude Code, Codex, Cursor, Gemini CLI, OpenCode.
CI workflows updated to checkout openpencil-skill before cli:compile.

* fix(panels): allow reparenting nodes into rectangle in layer panel

CONTAINER_TYPES was missing 'rectangle', preventing drag-drop into
rectangles even though the data model (ContainerProps) and store
(moveNode) both support it.

* fix(agent,ai): tool_exec reset + insert_node with after + move_node + CRUD tools

- fix(agent): reset StreamingToolExecutor between turns — prevents stale
  tool_use IDs that caused 400 errors on multi-turn tool calls (MiniMax etc.)
- feat(ai): add insert_node "after" parameter — auto-resolves sibling's
  parent and position for intuitive node insertion
- feat(ai): add move_node and insert_node to CRUD tool set
- feat(ai): add Chinese keywords (增加/添加/插入) to design intent detection
- fix(ai): insert_node uses addNode directly for existing parents instead
  of streaming pipeline, fixing parent resolution

* feat(ai): route CRUD intents to lightweight prompt and tool set

CRUD operations (read/update/delete) now get a focused system prompt
without design generation instructions, and use getCrudToolDefs()
(which includes insert_node and move_node) instead of the full design set.

* fix(agent,mcp): submodule update + MCP tool improvements

- Update agent-native submodule (tool_exec reset, HTTP error diagnostics)
- Improve MCP tool descriptions and parameter schemas
- Enhance agent.ts error handling

* feat(ai): add PenNode examples to CRUD prompt for complete node generation

The CRUD system prompt now includes button and text node examples
showing the full structure (fills, children, icons, layout) so models
generate complete nodes instead of empty frames.

* Update agent-native submodule to commit f9633a8, ensuring compatibility with recent changes and improvements in the agent's functionality.

* fix(ci,agent): generate skill-bundle before type check + fix moveNode arg

- Add `bun run cli:bundle-skill` step in CI before `tsc --noEmit` so
  skill-bundle.json exists when type-checking the CLI
- Fix moveNode index parameter: default to -1 when undefined

* fix(ci): add pen-engine and pen-react to npm publish workflow

Insert pen-engine and pen-react in topological order between
pen-renderer and pen-mcp so they are published before pen-sdk.

* docs: add MIT LICENSE and README to all packages

- Add MIT LICENSE to pen-ai-skills, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk, pen-types
- Add README.md to pen-engine, pen-react, pen-mcp, pen-ai-skills

* docs: comprehensive package metadata, README, CLAUDE.md, and LICENSE

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add CLAUDE.md to pen-types, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk
- Add MIT LICENSE to all packages
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg → ZSeven-W

* docs: package metadata, README, LICENSE, and CLAUDE.md for all packages

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add MIT LICENSE to all packages missing it
- Add CLAUDE.md to pen-types
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg to ZSeven-W

* docs: rewrite README for pen-core, pen-figma, pen-renderer, pen-sdk

Comprehensive READMEs with full API reference, usage examples,
feature tables, and architecture overview for each package.

* feat(acp): acpAgents store — persist, hydrate, CRUD actions

* feat(acp): pen-acp package + agent settings types + store

- pen-acp/types.ts: AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpConnectionState
- pen-acp/client.ts: connectAcpAgent (local stdio + remote WebSocket), disconnectAcpAgent
- pen-acp/event-adapter.ts: acpUpdateToSSE (ACP session/update → SSE events)
- agent-settings.ts: AcpAgentConfig type, widen ModelGroup/GroupedModel.provider
- agent-settings-store.ts: acpAgents persist/hydrate/CRUD + acpConnectionStatus

* fix(acp): remove unused type imports in client.ts

* feat(acp): ACP agent settings UI — form, cards, connect/disconnect

* feat(acp): add AcpAgentSection to Agents settings tab

* refactor(agent): AgentSession as discriminated union (native | acp)

* feat(acp): connection manager — connect, disconnect, cleanup

* feat(acp): connect/disconnect API route

* feat(acp): ACP branch in agent result handler

* fix(agent): use NativeAgentSession type for runDelegateMember

* feat(acp): ACP prompt SSE stream in agent endpoint

* feat(acp): ACP agents in model list + request routing

* i18n(acp): add ACP agent translation keys for all 15 locales

* fix(i18n): translate ACP keys for all 15 locales + fix missing key references

- zh.ts/zh-tw.ts: proper Chinese translations (Agent not translated)
- All other locales: translated from English placeholders
- Fix acp.add → acp.addAgent, acp.disconnected → acp.notConnected

* chore: bump agent-native submodule — surface upstream HTTP errors

* feat(acp,build,codegen): comprehensive fixes for ACP integration + prod build

ACP agent integration:
- Rewrite system prompt to enforce layered design pipeline (get_design_prompt
  → design_skeleton → design_content → design_refine) for higher quality output
- Use correct PenNode field names: content for text, iconFontName for icons
- Strict JSON rules to prevent empty-key / trailing-comma / smart-quote errors
- Prefer icon_font over path icons (standalone MCP has no hooks registered)
- Auto-start MCP server before ACP session (lazy bootstrap)
- Auto-reconnect ACP on stale connection (dev server restart scenario)
- Auto-approve tool permission requests (trust model: user configured agent)
- Use type: 'http' + headers: [] for MCP server config (SDK schema requirement)
- Persist ACP connections via globalThis so they survive Vite HMR

Build / packaging:
- Place agent-native under server/node_modules for Nitro to resolve at runtime
- Copy agent_napi.node to napi/ as extraResource
- Kill detached MCP server on Electron quit (before-quit + dev SIGINT handlers)
- Capture drag-dropped filesystem path via webUtils.getPathForFile so recent
  files entries are clickable after reopening

Codegen:
- Compact JSON (no indent) + strip noise fields (id, parentId, default rotation
  /opacity/visible, layout-managed x/y) to reduce request body size by 60-70%
  so proxies don't reject with 403 'Request not allowed'

MCP batch_design robustness:
- splitOperations tracks bracket/quote balance → multi-line JSON now works
- Auto-normalize fill/stroke shorthand forms
- Collect per-line errors instead of aborting whole batch
- Repair empty keys, trailing commas, smart quotes in JSON
- Bindless I(...) form supported (auto-generates binding)

UI:
- ModelDropdown / ChatInput handle ACP model icons (Plug)
- Reset streaming state + abort controller on ACP error path
- Strip h3 JSON error wrapper so chat shows clean error messages
- ACP agent settings form + cards + connect/disconnect

* fix(types): resolve TS errors in CI typecheck

- acp-connection-manager.ts: correct relative import path (utils/ → src/types)
- ai-chat-handlers.ts: cast currentProvider to AIProviderType at design-generator callsites
- ai-chat-panel.tsx: explicitly type groups as ModelGroup[] so 'acp' string fits the widened union
- acp-agent-settings.tsx: cast window through unknown for Record lookup
- electron.d.ts: add getPathForFile to ElectronAPI declaration
- builtin-provider-presets.ts: drop now-redundant config.preset !== 'custom' check (handled by early return)
- pen-acp/client.ts: cast Writable/Readable.toWeb to typed Streams; coerce nullish agentInfo fields to undefined

---------

Co-authored-by: Rais <vdcoolzi@gmail.com>
2026-04-13 21:30:23 +08:00
Fini
0e02281982 docs(readme): update cover screenshot 2026-04-13 21:15:00 +08:00
Kayshen Xu
6e8345a2a0
docs(readme): v0.7.0 feature sweep, circular sponsor avatar, 15-locale i18n (#98)
* fix(readme): render sponsor avatar as circle via wsrv.nl proxy

GitHub's markdown renderer strips inline style attributes from <img>
tags, so the `style="border-radius: 50%"` we shipped in #96 rendered
as a square on the repo page — visually off next to the contrib.rocks
round avatars in the Contributors section right above it.

Swap the avatar src to `wsrv.nl/?url=…&mask=circle`, which returns a
pre-masked circular PNG. Applied across all 15 README locales.

* docs(readme): surface v0.7.0 additions in Features and shortcuts

Sweep the English README to catch up with everything landed between
v0.6.0 and v0.7.0:

- Features: add AI & Agents (concurrent teams, orchestrator, layered
  workflow, Style Guides, multi-model profiles, Chinese-LLM Anthropic
  passthrough), Git Integration (clone, branch, push/pull, folder-mode
  three-way merge, conflict panel), Export (PNG/JPEG/WEBP/PDF +
  incremental MCP codegen pipeline). Extend Desktop App with Save As,
  Open Recent, unsaved-changes dialog, recent-files persistence.
- Keyboard shortcuts: rebind Export from Cmd+Shift+E to Cmd+Shift+P
  (matches the editor since the Cmd+Shift+P IME-proof rebind) and
  add Cmd+Shift+S for Save As.
- Roadmap: tick Git integration and canvas raster export.

Locale sweep follows in the next commit.

* i18n: sweep v0.7.0 Features / Shortcuts / Roadmap across 14 locales

Mirror the English README v0.7.0 content into every locale variant
so translations don't drift:

- Features: added AI & Agents, Git Integration, Export subsections,
  plus the new UIKits bullet in Design System and the Save As /
  Open Recent / unsaved-changes / recent-files lines in Desktop App.
- Keyboard shortcuts: Cmd+Shift+E → Cmd+Shift+P (Export) and filled
  the last empty row with Cmd+Shift+S (Save As), translating the
  labels per locale.
- Roadmap: added [x] Git integration and [x] canvas raster export
  before the first unchecked item.

Covers de, es, fr, hi, id, ja, ko, pt, ru, th, tr, vi, zh, zh-TW.
Product names, backticked identifiers, framework names, file
extensions, format names and keyboard tokens kept in English.
2026-04-12 09:21:35 +08:00
Kayshen Xu
c01eb4a930
Docs/readme sponsors (#96)
* 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

* docs(readme): add Sponsors section with current backer across all 15 locales

Adds a compact Sponsors block between Contributors and Community in
every README variant — a 64×64 avatar for MrQyun (current $100/mo
backer) and a one-line "Become a sponsor →" CTA pointing at
github.com/sponsors/ZSeven-W.

Headings localized per locale (Sponsoren / Patrocinadores / Sponsors /
प्रायोजक / Sponsor / スポンサー / 스폰서 / Patrocinadores / Спонсоры /
ผู้สนับสนุน / Sponsorlar / Nhà tài trợ / 赞助者 / 贊助者).

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Di <di.yan@mail.polimi.it>
2026-04-11 23:53:22 +08:00
Kayshen Xu
e20957b740
V0.7.0 (#95)
* 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>
2026-04-11 23:25:13 +08:00
Kayshen Xu
3c697d817d
V0.6.0 (#88)
* fix(renderer): skip paragraph image cache when zoomed in

* refactor(publish-cli): streamline version publishing process

- Removed the check for already published versions to simplify the workflow.
- Updated npm publish commands to handle already published versions gracefully, allowing the process to continue without aborting.
- Added publishing step for the new pen-ai-skills package, ensuring it is included in the deployment process.

* fix(mcp): wire openpencil codex install to config toml (#85)

Co-authored-by: Kaiiiiiiiii <182183652+Smile232323@users.noreply.github.com>

* refactor(mcp): reorganize codex-cli handling in installation process

- Removed the codex-cli configuration from CLI_CONFIGS and adjusted the installation logic to handle codex-cli actions directly.
- Improved error handling for unknown CLI tools and streamlined the installation process for codex-cli, ensuring it uses its own commands for adding/removing components.

* feat(agent): add built-in agent SDK and web integration

Introduces @zseven-w/agent — a domain-agnostic agent SDK with:
- Agent loop (plan→act→observe) with dual execution model
- Anthropic + OpenAI-compatible provider adapters via Vercel AI SDK
- Tool registry with auth levels (read/create/modify/delete/orchestrate)
- Agent team orchestration with delegate tool
- SSE streaming protocol encoder/decoder
- Sliding window context management

Web app integration:
- Built-in provider config UI with presets (Anthropic, OpenAI, OpenRouter, DeepSeek)
- Model search via provider API proxy endpoint
- Built-in models appear in the model dropdown alongside CLI agents
- Server-side agent SSE endpoint with session management
- Client-side tool executor with undo batch support
- Inline tool call blocks in chat UI

* feat(api): implement keep-alive pings for long-running LLM calls

- Added a keep-alive mechanism in the SSE stream for the agent API to prevent timeouts during long-running LLM calls.
- Updated the keep-alive interval to 8 seconds to align with Bun.serve's default idle timeout of 10 seconds.
- Ensured proper cleanup of the ping timer when the stream is closed.

* feat(mcp): enhance live sync diagnostics and port file management

- Implemented a mechanism to probe the live sync server's availability, providing clearer diagnostics for connection issues.
- Updated the port file structure to include a unique token for better management and cleanup.
- Enhanced the `getSyncUrl` and `getLiveSyncState` functions to return detailed status messages based on the server's response.
- Added tests for live sync diagnostics to ensure accurate reporting of server states.

* feat(mcp): implement sync URL caching for improved performance

- Added caching mechanism for sync URLs to reduce repeated reads and health checks, enhancing performance during live document and selection fetches.
- Introduced functions to set and clear the cached sync URL, improving connection management.
- Updated existing functions to utilize the cached URL when available, streamlining the fetching process.

* feat(skia): enhance syncFromDocument error handling and normalize document structure

- Wrapped the syncFromDocument method in a try-catch block to log errors during synchronization, improving debugging capabilities.
- Introduced normalization of external documents in the document store to ensure consistent structure before processing.
- Updated text content resolution to handle both 'content' and 'text' fields, enhancing compatibility with various node formats.
- Refactored text measurement functions to streamline text handling and improve layout consistency.

* chore: update package versions to 0.6.0 across all modules and enhance code generation pipeline

- Bumped version from 0.5.3 to 0.6.0 in package.json files for all modules including core, agent, and various code generation packages.
- Refactored code generation functions to utilize a new AI code generation pipeline, marking previous functions as deprecated with plans for removal in v1.0.0.
- Introduced new export nodes functionality in the MCP server for raw PenNode data export.

* fix(mcp): clear stale sync cache on page load to prevent false dirty state

When refreshing the page or opening a new file, the SSE document:init
event would echo back the old cached document, causing applyExternalDocument
to unconditionally set isDirty=true and trigger a spurious save prompt.

* style(panels): improve AI checklist visual design

Add progress bar, spinner animation, highlighted active items, and
pill-shaped counter badge for a more polished orchestrator progress UI.

* feat(ci): add Homebrew Cask update workflow for OpenPencil releases

- Implemented a new GitHub Actions job to update the Homebrew Cask for OpenPencil upon new version tags.
- The workflow fetches the latest macOS binaries, computes their SHA256 checksums, and updates the cask formula accordingly.
- Includes steps for committing and pushing changes to the tap repository.

* docs: update installation instructions in README and enhance CI workflow for Scoop bucket updates

- Added detailed installation instructions for macOS, Windows, and Linux in the README.
- Introduced a new GitHub Actions job to update the Scoop bucket with the latest OpenPencil release, including versioning and SHA256 checksum computation for Windows binaries.

* docs: add OpenPencil Skill plugin link to all i18n READMEs

Add localized LLM Skill link in CLI section across all 15 README
files, pointing to zseven-w/openpencil-skill repository.

* docs: add same-name project disclaimer to all i18n READMEs

Distinguish from open-pencil/open-pencil (Figma-compatible visual
design with real-time collaboration) — this project focuses on
AI-native design-to-code workflows.

* fix(agent): resolve SSE timeout, routing, and message format issues

- Add 5s keep-alive ping to agent SSE stream (prevents Bun 10s idle timeout)
- Consolidate tool-result and abort into single agent endpoint (?action=result|abort)
- Fix assistant message history: include text alongside tool-call parts
- Fix ToolResultPart format: use 'output' field with {type:'text',value} structure
- Use openai.chat() instead of openai() for Chat Completions API (OpenRouter compat)
- Use proper TOOL_SCHEMAS instead of z.any() for tool parameters

* fix(agent): prevent sliding window from splitting assistant+tool groups

The sliding window context strategy was naively slicing by message count,
which could leave orphaned tool_result messages without their preceding
assistant tool_use — causing Claude API to reject the request.

Now counts "logical turns" (user/assistant starts) and never cuts inside
an assistant→tool group. Also increased default window from 10 to 50 turns.

* fix(agent): use correct AI SDK v6 parameter name maxOutputTokens

streamText() in AI SDK v6 renamed maxTokens to maxOutputTokens.
Default 4096 prevents OpenRouter credit limit errors from requesting
the model's full 65536 token capacity.

* feat(agent): add API error classification and auto-retry without tools

When a model doesn't support function calling (400 errors, missing
arguments), the agent loop now classifies the error and automatically
retries without tools on the first turn. Also provides user-friendly
error messages for common failures: privacy restrictions, credit limits,
rate limiting, and incompatible models.

* fix(agent): improve system prompt with PenNode schema and workflow guidance

- Add PenNode property schema to agent system prompt so models know
  valid properties (fills, cornerRadius, padding array, etc.)
- Explicitly forbid CSS properties (backgroundColor, boxShadow, etc.)
- Add workflow steps: plan before creating, avoid create-then-delete
- Change max turns reached from error to normal done event

* feat(agent): align insert_node with CLI batch_design capability

- insert_node now supports nested children arrays (recursive ID generation)
- Post-processing pipeline runs after insertion: role resolution, icon
  resolution, layout sanitization, unique ID enforcement — same as MCP
  batch_design with postProcess=true
- System prompt teaches model to create entire designs in ONE insert_node
  call with nested children, instead of 20+ individual calls
- Includes concrete example of nested PenNode structure in prompt

* fix(agent): deep-clone node before post-processing to avoid Zustand mutation

Zustand state is immutable — post-processing functions mutate in-place.
JSON.parse(JSON.stringify()) creates a mutable copy. Each post-processing
step is individually try-caught so partial failures don't break insertion.

* fix(agent): return node count in insert_node result to prevent model retries

insert_node now returns {id, nodesCreated, message} so the model knows
the full tree was created. System prompt reinforces: when insert_node
succeeds, do NOT retry. Reduces 5+ duplicate insert calls to exactly 1.

* fix(agent): prevent duplicate root-level inserts from weaker models

Track the first root-level insert_node ID per session. Subsequent
root-level insert_node calls return the existing ID with a message
instead of creating duplicates. Fixes M2.5 and similar models that
ignore "do not retry" prompt instructions.

* feat(agent): support Anthropic API format in custom provider settings

Custom provider preset now has an API Format toggle: "OpenAI Compatible"
or "Anthropic". This allows users to configure custom Anthropic-compatible
endpoints (proxies, mirrors) in addition to OpenAI-compatible ones.
Anthropic provider also now accepts optional baseURL for proxy support.

* fix(agent): update Custom option label in provider dropdown

* fix(agent): match 'invalid function arguments' in error classifier for auto-retry

* fix(agent): always auto-retry without tools on first turn errors

Many providers (MiniMax, StepFun) have broken tool call implementations.
Instead of pattern-matching specific error messages, any streaming error
on turn 0 now triggers auto-retry without tools. The model falls back
to generating a text-only response instead of failing entirely.

* fix(agent): use clean JSON Schema for tool definitions (no $schema field)

Replaced Zod schemas with jsonSchema() from AI SDK for tool parameter
definitions. Zod's zodToJsonSchema adds "$schema" and "additionalProperties"
fields that strict OpenAI-compatible APIs (MiniMax, StepFun) reject with
"invalid function arguments". Clean JSON Schema only has type/properties/
required — compatible with all providers.

Also added text-mode fallback prompt for when tools are truly unsupported.

* fix(agent): ensure tool call args is always {} and fix turn>0 retry

Two fixes for MiniMax "invalid function arguments" error:
1. Ensure args is always {} (never undefined/null) when building
   conversation history — undefined args causes strict APIs to reject
2. Auto-retry without tools now works on ANY turn (not just turn 0)
   by resetting history to original user messages and restarting

* fix(agent): use SDK response.messages for history instead of manual construction

Root cause of MiniMax "invalid function arguments" error: we were manually
constructing ModelMessage[] for conversation history, which the SDK then
failed to convert correctly to OpenAI format (dropping function.arguments).

Fix: use response.response.messages from the SDK — it produces correctly
formatted messages that the provider knows how to convert. Only manually
add tool result messages for client-executed tools (no execute() function).

Also keeps a fetch interceptor as safety net to patch any remaining
edge cases with missing/malformed arguments.

* fix(agent): parse stringified JSON in insert_node data parameter

Some models (MiniMax M2.5) send insert_node data as a JSON string
instead of an object. Now handleInsertNode detects string data and
JSON.parse it before processing. Fixes nodesCreated:1 when model
sends nested children as a stringified object.

* fix(agent): sanitize node data and catch addNode side-effect errors

- Convert model's 'border' property to valid 'strokes' array
- Filter null/non-object children before processing
- Wrap docStore.addNode in try-catch — canvas sync side-effects
  (e.g., renderer hitting unknown properties) may throw but the node
  IS still inserted. Return success regardless.

* fix(agent): auto-zoom to fit after insert_node to show new design

* fix(agent): align insert_node with batch_design — auto-replace empty root frame

When inserting a frame at root level and an empty root frame exists,
replace it and inherit its position (x:0, y:0). This matches the MCP
batch_design behavior (line 146-161) so the design lands in the visible
viewport instead of off-screen at x:1250.

* fix(agent): use updateNode for empty frame replacement (no duplicate keys)

The remove+add pattern caused React duplicate key errors because two
separate Zustand state updates could leave the node in two places.
Now uses a single updateNode call to replace the empty frame's
properties in-place — atomic operation, no duplicates.

* fix(agent): set replaced=true BEFORE updateNode to prevent duplicate on side-effect throw

* fix(agent): use atomic tree swap for empty frame replacement

updateNode caused canvas sync crash (map on undefined) because the
renderer couldn't handle a complete property replacement on an existing
node. Now uses removeNodeFromTree + insertNodeInTree in a single
setState — same atomic approach as MCP batch_design. The Skia engine
sees the new node as a fresh addition, not an update to an existing one.

* fix(agent): simplify empty frame replacement to removeNode+addNode via store API

* refactor(agent): use applyNodesToCanvas instead of raw addNode

The agent's insert_node was bypassing the design pipeline's
sanitization and canvas sync code, causing blank canvas rendering.
Now delegates to the SAME applyNodesToCanvas function used by the
CLI/MCP design pipeline. This handles:
- sanitizeNodesForInsert (role defaults, layout fixes, unique IDs)
- isCanvasOnlyEmptyFrame → replaceEmptyFrame (empty frame at 0,0)
- icon resolution and image scanning
- proper Skia canvas sync

Removed the custom postProcessNode method — no longer needed since
applyNodesToCanvas does the same work correctly.

* feat(agent): add generate_design tool using internal CLI design pipeline

generate_design delegates to the SAME generateDesign() function the
standard chat uses — orchestrator, sub-agents, streaming insertion,
post-processing. Finds the first connected CLI provider for LLM calls.
Also uses insertStreamingNode with resetGenerationRemapping() for
insert_node, matching the CLI streaming pattern exactly.

* feat(agent): replace insert_node with generate_design as primary design tool

Remove insert_node and find_empty_space from agent tool set — models
should use generate_design for creating designs, not raw PenNode JSON.
generate_design accepts natural language prompts and delegates to the
full internal pipeline. Keeps update_node/delete_node for modifications.

* feat(agent): simplify system prompt to direct models to generate_design

Clear instruction: when user asks to create/design, MUST call
generate_design tool. No JSON output, no manual node construction.

* fix(agent): strip <think> tags from model text output in chat UI

Models like MiniMax M2.5 and DeepSeek output <think>...</think> as
plain text (not via the thinking SSE event). Strip both closed and
unclosed <think> tags from the displayed text during streaming.

* fix(renderer): skip paragraph image cache when zoomed out below 0.5x

Bitmap text cache at fixed DPR resolution produces jagged edges when
scaled down significantly. Now only uses cache at 0.5x–1x zoom range.
Below 0.5x and above 1x, falls back to vector Paragraph API rendering.

* fix(ai): prevent model from using OpenPencil as brand name in generated designs

* fix(agent): strip <think> tags from final message, not just during streaming

The regex was only applied during text-delta events but the final
message (returned by runAgentStream and set in done/error handlers)
used the raw accumulated text. Now stripThinkTags() is applied at
the return point so the final displayed message is always clean.

* fix(ai): reduce keepalive ping interval from 15s to 5s for Bun compatibility

Bun.serve has a 10s idle timeout. The previous 15s ping interval meant
the first ping arrived AFTER Bun killed the connection, causing
"socket connection was closed unexpectedly" errors when waiting for
slow LLM responses (extended thinking, TTFT > 10s). Now pings at 5s.

* fix(renderer): rasterize paragraph image cache at 2x minimum for crisp text on low-DPR

* fix(agent): remove hardcoded TOOL_SCHEMAS and maxOutputTokens

- Server uses client-provided JSON schemas (single source of truth)
- maxOutputTokens configurable per provider, defaults to model limit
- turnTimeout increased to 5min for generate_design pipeline
- Session cleanup wrapped in try-catch
- Re-export streamText from agent SDK for consumer use

* feat(agent): add builtin provider support to /api/ai/chat endpoint

streamViaBuiltin() uses Vercel AI SDK to call LLM directly with API
key — no CLI tool required. ai-service.ts attaches builtin credentials
from agent settings store when provider is 'builtin'. This enables
generate_design to work without any CLI provider connected.

* fix(agent): align generate_design params with CLI pipeline and handle field name variants

- Pass concurrency, variables, themes, designMd to generateDesign()
  (was missing, causing lower quality output vs CLI mode)
- Accept both 'prompt' and 'description' field names from models
- Fall back to builtin provider when no CLI provider connected
- Enable animated node insertion

* feat(agent): dynamic system prompt via pen-ai-skills + misc fixes

- Replace hardcoded AGENT_SYSTEM_PROMPT with buildAgentSystemPrompt()
  that loads design knowledge from pen-ai-skills (same as CLI pipeline)
- Validate builtin provider apiKey before agent connection
- Client-side tool schema serialization for server (no duplication)
- Update tool_call block status locally as fallback for lost SSE events
- Pass maxOutputTokens from provider config

* refactor(agent): extract builtin provider settings to separate file

Split BuiltinProviderForm, BuiltinProviderCard, BuiltinProvidersSection
from agent-settings-dialog.tsx (1279→730 lines) into
builtin-provider-settings.tsx (394 lines). Both under 800 line limit.

* refactor(agent): extract model selector to separate file

Split ModelDropdown, ConcurrencyButton, resolveNextModel, PROVIDER_ICON
from ai-chat-panel.tsx (915→690 lines) into ai-chat-model-selector.tsx
(183 lines). Both under 800 line limit.

* fix(renderer): guard against undefined fill entries in resolveFillColor/resolveStrokeColor

* feat(ai): extend BuiltinProviderPreset with 9 new providers

Add MiniMax, Zhipu, Kimi, Bailian, DouBao, Xiaomi MiMo, ModelScope,
StepFun and NVIDIA NIM to the builtin provider preset type union.

* feat(ai): add provider presets and region switcher for builtin providers

Add preset configs for MiniMax, Zhipu, Kimi, Bailian (DashScope),
DouBao Seed, Xiaomi MiMo, ModelScope, StepFun and NVIDIA NIM with
correct API base URLs and model placeholders.

Providers with separate China/Global endpoints (MiniMax, Zhipu, Kimi,
Bailian, StepFun) get a region toggle that auto-switches the base URL.
Region inference works both for new providers and when editing existing
ones via REGION_URLS reverse-lookup table.

* style(ai): increase settings dialog height for more provider entries

* feat(ai): add i18n for builtin provider settings (15 locales)

Extract all hardcoded strings in builtin-provider-settings.tsx to
i18n keys under the builtin.* namespace. Add translations for all
15 supported languages: en, zh, zh-TW, ja, ko, fr, es, de, pt,
ru, hi, tr, th, vi, id.

* fix(ai): remove remaining hardcoded strings in builtin provider UI

- Extract error messages, badge text, tooltip, and placeholder to i18n
- Remove unused region label fields from PresetRegion interface
- Add 8 new builtin.* keys across all 15 locales
- Fix ai-chat-model-selector API Key badge and parallel agents tooltip
- Fix ai-chat-panel provider description template
- Fix ai-chat-handlers error messages via i18n.t()

* fix(agent): clean up pending tool calls on exit and improve error handling

P0: Wrap agent loop generator in try-finally to reject all pending tool
call promises and clear timeout timers when the loop exits. Prevents
timeout leaks when sessions end early.

P1: Add HTTP status code matching (429, 402, 403) before regex fallback
in classifyAPIError for more reliable error classification.

P1: Clone tool registry in createTeam instead of mutating the caller's
config.lead.tools. Add doc comment for unused _maxTokens parameter in
sliding-window strategy.

* fix(ai): add error handling and retry for tool result POST

The POST to /api/ai/agent?action=result had no error handling. If it
failed, the agent loop would hang forever waiting for the tool result.
Now retries once on failure and logs the error.

* fix(ai): use lastActivity for session TTL and guard stream init

P1: Add lastActivity timestamp to AgentSession, updated on every event
and tool result. TTL cleanup now checks lastActivity instead of
createdAt, preventing active long-running sessions from being killed.

P1: Wrap ReadableStream construction in try-catch to ensure session
cleanup if stream initialization fails.

* chore: add docs/ to gitignore

* fix(ai): update model lists and fix model search for providers

- Update Anthropic models to include Claude 4.6 (Opus + Sonnet)
- Update model placeholders: OpenAI→gpt-5.4, MiniMax→M2.7,
  Zhipu→glm-5, Kimi→kimi-k2.5, Xiaomi→mimo-v2-pro
- Add hardcoded model lists for MiniMax and DouBao (no /models API)
- Generalize ANTHROPIC_MODELS to BUILTIN_MODEL_LISTS lookup table
- Fix provider-models proxy to handle { models: [...] } and array
  response formats in addition to OpenAI's { data: [...] }

* docs: update CLAUDE.md and README files for new AI agent SDK and i18n support

- Added new sections in CLAUDE.md detailing the AI agent SDK and its multi-provider capabilities.
- Updated README files in multiple languages to include information about the built-in AI agent SDK and full interface localization in 15 languages.
- Adjusted key technologies and scopes to reflect recent changes in the project structure and features.

* docs(agent): add Agent Team web integration design spec

Phase 3 design: model routing via Agent Team. Lead agent uses fast
model for chat, designer member uses capable model for generation.
SDK changes: source tracking on events, member_start/end events,
auto team suffix. Web changes: team toggle + design model selector
in settings, conditional createTeam in server endpoint.

* fix(electron): fix Scoop/Homebrew tap auto-update in CI

- Add pre_install to Scoop manifest to rename versioned exe to OpenPencil.exe
- Add mkdir -p for Casks/ and bucket/ dirs in tap repos
- Use curl -fSL to fail fast on HTTP errors instead of silent mode

* docs(agent): add Agent Team web integration implementation plan

9 tasks: SDK event types → SSE tests → team source injection →
server team endpoint → settings store → team UI → i18n →
chat handler wiring → e2e verification

* feat(agent): add source field and team events to AgentEvent type

* test(agent): add SSE encoder/decoder tests for source and team events

* feat(agent): inject source field and team events in agent-team

* feat(ai): extend agent endpoint to support team mode with members

* feat(ai): add teamEnabled and teamDesignModel to agent settings store

* feat(ai): wire team mode in chat handlers with member events

* feat(ai): add i18n keys for team settings (15 locales)

* feat(ai): add Team section UI with design model selector

* fix(agent): route resolveToolResult to correct agent in team mode

Member tool calls (e.g. generate_design) were routed to leadAgent
which didn't have them in its pending map. Added toolCallOwners map
to track which agent (lead or member) issued each tool call and
route resolveToolResult accordingly.

* docs(agent): clarify resolveToolResult routing, member tools, and source handling

* fix(agent): force delegation in team mode and add turnTimeout support

Two fixes for team mode generation failures:
1. Remove generate_design from lead's tool registry in team mode,
   forcing the lead to delegate design work to the designer member
2. Add turnTimeout to TeamMemberConfig/TeamConfig and pass 5min
   timeout to member agents (generate_design needs it)

* fix(ai): make team mode visually distinct in chat UI

- Add source badge on tool call blocks (shows "designer" pill for
  member-initiated tool calls)
- Pass evt.source to ToolCallBlockData
- Use blockquote format for member_start/end events so team activity
  is clearly visible in the chat stream

* fix(agent): scope designer tools, streamline delegation, add model hint

1. Designer member only gets generate_design + snapshot_layout
   (was: all tools, causing 7 wasteful batch_get calls)
2. Team suffix tells lead to delegate immediately without verbose
   planning text
3. UI shows amber tip when only 2 providers configured, suggesting
   a more capable model for design

* fix(ai): clean up partial nodes on generate_design failure

When generate_design fails midway, partial nodes were left on the
canvas and rootInsertId was not set, allowing a retry that created
a duplicate design. Now:
1. Set rootInsertId='generating' BEFORE calling generateDesign
2. Snapshot node IDs before generation
3. On failure, remove all new nodes and reset rootInsertId to null
4. Remove incorrect same-model warning from Team UI

* fix(ai): show Team section with 1 enabled provider

* refactor(ai): smart team mode — auto-enable when design model is set

Remove manual Team toggle. Selecting a Design Model automatically
enables team mode; "None (single agent)" disables it. Simpler UX:
one dropdown replaces toggle + dropdown. Updated descriptions and
removed obsolete teamTitle/teamSameModelWarning i18n keys.

* fix(agent): properly serialize non-Error objects in error messages

String(err) on plain objects produces "[object Object]". Changed to
err instanceof Error ? err.message : JSON.stringify(err) for readable
error messages in tool results and design generation failures.

* refactor(ai): auto team mode using current chat model

Remove Design Model dropdown from settings. Team mode is now fully
automatic: the designer member always uses the same provider/model
as the current chat selection. Zero configuration needed — the value
is in role separation (different prompts + scoped tools), not model
selection.

* style(ai): use min/max height for settings dialog instead of fixed height

* feat(ai): add Google Gemini to builtin provider presets

Base URL: generativelanguage.googleapis.com/v1beta/openai (OpenAI
compatible). Hardcoded model list: Gemini 2.5 Pro, 2.5 Flash, 2.0
Flash. API key placeholder: AIza...

* feat(ai): update Gemini models to 3.x series

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Kaiiiiiiiii <2761362118@qq.com>
Co-authored-by: Kaiiiiiiiii <182183652+Smile232323@users.noreply.github.com>
2026-03-28 23:12:37 +08:00
Kayshen Xu
2cc2447ea0
V0.5.2 (#83)
* feat(ai): scaffold pen-ai-skills package

* feat(ai): add pen-ai-skills core types

* feat(ai): add Vite plugin for skill file compilation

* feat(ai): add budget module with token estimation and category-priority trimming

* feat(ai): add skill loader with phase filtering and test injection

* feat(ai): add skill resolver with phase filter, intent match, and dynamic injection

* feat(ai): migrate all prompt content to skill files

Extract prompt content from 7 TypeScript source files into 27 Markdown
skill files with proper frontmatter. Each file contains phase, trigger,
priority, budget, and category metadata for the skill resolution engine.

Planning: decomposition, design-type
Generation: jsonl-format, jsonl-format-simplified, schema, layout,
  text-rules, overflow, style-defaults, variables, design-system,
  design-code, design-md
Validation: vision-feedback
Maintenance: local-edit, incremental-add, style-consistency
Domains: landing-page, dashboard, mobile-app, form-ui, cjk-typography
Knowledge: role-definitions, design-principles, icon-catalog,
  copywriting, examples

* feat(ai): add document context memory module

* feat(ai): add generation history memory module

* feat(ai): wire memory loading into skill resolver

Add memory field to ResolveOptions and load documentContext/generationHistory
into AgentContext with per-phase limits (planning:5, maintenance:3, others:0).
Export memory utility functions from package index.

* refactor(ai): use resolveSkills('planning') in orchestrator

Replace ORCHESTRATOR_PROMPT import with resolveSkills() from pen-ai-skills.
The planning phase system prompt is now resolved dynamically from skill
files instead of a static constant.

* refactor(ai): use resolveSkills('generation') in sub-agent

Replace SUB_AGENT_PROMPT + designPrinciples concatenation with
resolveSkills() from pen-ai-skills. The generation phase system prompt
is now resolved dynamically with flag-based skill filtering for
variables and design.md context.

* refactor(ai): use resolveSkills('validation') in design validation

Replace the 70-line inline VALIDATION_SYSTEM_PROMPT constant with a
lazy resolver function that loads the validation prompt from pen-ai-skills
skill files at call time.

* refactor(mcp): use skill registry for design prompt sections

* refactor(ai): remove old prompt files replaced by pen-ai-skills

Delete prompt files whose content has been migrated to the
pen-ai-skills package:
- ai-prompt-sections.ts (section registry, triggers, builders)
- orchestrator-prompts.ts (orchestrator + sub-agent prompts)
- design-system-prompts.ts (design token generation prompt)
- design-code-prompts.ts (HTML/CSS code-gen prompt)
- design-principles/ directory (5 principle files + index)

Consolidate role-definitions/ 8 sub-files into index.ts
(registerRole calls are runtime behavior, must be preserved).

Move buildDesignMdStylePolicy into ai-prompts.ts. Strip migrated
constants (PEN_NODE_SCHEMA, DESIGN_EXAMPLES, ADAPTIVE_STYLE_POLICY,
CHAT_SYSTEM_PROMPT, DESIGN_GENERATOR_PROMPT, etc.) from ai-prompts.ts.

Add pen-ai-skills alias to mcp:compile esbuild script.

* docs: add pen-ai-skills to architecture documentation

* fix(mcp): use h3 v2 createEventStream API for SSE endpoint

event.node.res was removed in h3 v2. Migrate to createEventStream
which handles SSE headers, streaming, and cleanup automatically.

* chore: update package versions and add pen-ai-skills to Dockerfile

- Bump version for multiple packages to 0.5.2, including pen-ai-skills, pen-codegen, pen-core, pen-figma, pen-renderer, pen-sdk, and pen-types.
- Add pen-ai-skills package to Dockerfile for inclusion in the build process.
- Update TypeScript configuration to ensure proper file inclusion.
- Modify GitHub Actions workflow to trigger on version tags.
- Enhance AI-related functionality by integrating resolveAgentModel for dynamic model resolution in chat and generation APIs.

* refactor: clean up unused imports and improve dynamic content handling

- Removed unused imports from various files, including SkillMeta and ResolvedSkill from types.test.ts, and estimateTokens from resolve-skills.ts.
- Updated the dynamic content injection function to use a more concise parameter in the regex replacement callback.

* chore: enhance build and CI workflows with skill generation

- Added a new script to generate the skill registry during post-installation in package.json.
- Updated the GitHub Actions workflows to include steps for compiling the CLI and generating the skill registry, ensuring all necessary components are built and ready for deployment.

* chore: update postinstall script to ensure successful execution

- Modified the postinstall script in package.json to allow for continued execution even if the initial commands fail, enhancing robustness during installation.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-24 21:32:05 +08:00
Kayshen Xu
3caaa495bb
V0.5.2 (#82)
* feat(ai): scaffold pen-ai-skills package

* feat(ai): add pen-ai-skills core types

* feat(ai): add Vite plugin for skill file compilation

* feat(ai): add budget module with token estimation and category-priority trimming

* feat(ai): add skill loader with phase filtering and test injection

* feat(ai): add skill resolver with phase filter, intent match, and dynamic injection

* feat(ai): migrate all prompt content to skill files

Extract prompt content from 7 TypeScript source files into 27 Markdown
skill files with proper frontmatter. Each file contains phase, trigger,
priority, budget, and category metadata for the skill resolution engine.

Planning: decomposition, design-type
Generation: jsonl-format, jsonl-format-simplified, schema, layout,
  text-rules, overflow, style-defaults, variables, design-system,
  design-code, design-md
Validation: vision-feedback
Maintenance: local-edit, incremental-add, style-consistency
Domains: landing-page, dashboard, mobile-app, form-ui, cjk-typography
Knowledge: role-definitions, design-principles, icon-catalog,
  copywriting, examples

* feat(ai): add document context memory module

* feat(ai): add generation history memory module

* feat(ai): wire memory loading into skill resolver

Add memory field to ResolveOptions and load documentContext/generationHistory
into AgentContext with per-phase limits (planning:5, maintenance:3, others:0).
Export memory utility functions from package index.

* refactor(ai): use resolveSkills('planning') in orchestrator

Replace ORCHESTRATOR_PROMPT import with resolveSkills() from pen-ai-skills.
The planning phase system prompt is now resolved dynamically from skill
files instead of a static constant.

* refactor(ai): use resolveSkills('generation') in sub-agent

Replace SUB_AGENT_PROMPT + designPrinciples concatenation with
resolveSkills() from pen-ai-skills. The generation phase system prompt
is now resolved dynamically with flag-based skill filtering for
variables and design.md context.

* refactor(ai): use resolveSkills('validation') in design validation

Replace the 70-line inline VALIDATION_SYSTEM_PROMPT constant with a
lazy resolver function that loads the validation prompt from pen-ai-skills
skill files at call time.

* refactor(mcp): use skill registry for design prompt sections

* refactor(ai): remove old prompt files replaced by pen-ai-skills

Delete prompt files whose content has been migrated to the
pen-ai-skills package:
- ai-prompt-sections.ts (section registry, triggers, builders)
- orchestrator-prompts.ts (orchestrator + sub-agent prompts)
- design-system-prompts.ts (design token generation prompt)
- design-code-prompts.ts (HTML/CSS code-gen prompt)
- design-principles/ directory (5 principle files + index)

Consolidate role-definitions/ 8 sub-files into index.ts
(registerRole calls are runtime behavior, must be preserved).

Move buildDesignMdStylePolicy into ai-prompts.ts. Strip migrated
constants (PEN_NODE_SCHEMA, DESIGN_EXAMPLES, ADAPTIVE_STYLE_POLICY,
CHAT_SYSTEM_PROMPT, DESIGN_GENERATOR_PROMPT, etc.) from ai-prompts.ts.

Add pen-ai-skills alias to mcp:compile esbuild script.

* docs: add pen-ai-skills to architecture documentation

* fix(mcp): use h3 v2 createEventStream API for SSE endpoint

event.node.res was removed in h3 v2. Migrate to createEventStream
which handles SSE headers, streaming, and cleanup automatically.

* chore: update package versions and add pen-ai-skills to Dockerfile

- Bump version for multiple packages to 0.5.2, including pen-ai-skills, pen-codegen, pen-core, pen-figma, pen-renderer, pen-sdk, and pen-types.
- Add pen-ai-skills package to Dockerfile for inclusion in the build process.
- Update TypeScript configuration to ensure proper file inclusion.
- Modify GitHub Actions workflow to trigger on version tags.
- Enhance AI-related functionality by integrating resolveAgentModel for dynamic model resolution in chat and generation APIs.

* refactor: clean up unused imports and improve dynamic content handling

- Removed unused imports from various files, including SkillMeta and ResolvedSkill from types.test.ts, and estimateTokens from resolve-skills.ts.
- Updated the dynamic content injection function to use a more concise parameter in the regex replacement callback.

* chore: enhance build and CI workflows with skill generation

- Added a new script to generate the skill registry during post-installation in package.json.
- Updated the GitHub Actions workflows to include steps for compiling the CLI and generating the skill registry, ensuring all necessary components are built and ready for deployment.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-24 21:16:28 +08:00
Kayshen Xu
2592bf116f
V0.5.1 (#79)
* fix(docker): support multi-platform builds and fix monorepo paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization   (#76)

* fix(canvas): stabilize frame label size during zoom

  Draw frame labels in screen-space after the viewport transform
  restore, converting scene coords manually. Previously fontSize=12/zoom
  fed into Math.ceil caused integer-boundary jumps that made labels
  flicker during zoom. Also skip shadow rendering while actively
  zooming for smoother performance.

* perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization

   - Add paraImageCache (SkImage, 128 MB LRU limit) keyed on the same key as paraCache
   - Use drawImageRect instead of drawParagraph on cache hit, skipping per-frame glyph shaping and rasterization
   - Fall back to direct drawParagraph only when off-screen surface creation (MakeSurface) fails
   - Extract _dpr getter to deduplicate device-pixel-ratio resolution logic across draw paths
   - Evict oldest entries when cache exceeds byte limit; delete SkImage on eviction and dispose()

* feat(cli): introduce OpenPencil CLI for terminal control of the design tool

- Added a new CLI application under `apps/cli` to manage OpenPencil from the terminal.
- Implemented commands for app control (`start`, `stop`, `status`), document operations (`open`, `save`, `get`, `selection`), and design manipulation (`design`, `import`).
- Enhanced documentation with usage instructions and platform support details.
- Updated build scripts to include CLI compilation and publishing processes.
- Introduced a new GitHub Actions workflow for publishing the CLI to npm.
- Updated existing workflows to integrate CLI build steps and ensure proper versioning across packages.

* docs: update README files to include CLI tool details and multi-platform code export

- Added CLI section to README files in multiple languages, detailing commands for terminal control of the design tool.
- Included instructions for global installation and usage examples for the CLI.
- Expanded documentation on multi-platform code export capabilities from a single `.op` file to various frameworks.
- Updated CLAUDE.md to reference the new CLI documentation and its integration with the design tool.

* chore(bun.lock): update package dependencies to specific versions

- Removed workspace references for several packages in the bun.lock file.
- Updated dependencies for `@zseven-w/pen-core`, `@zseven-w/pen-types`, `@zseven-w/pen-codegen`, `@zseven-w/pen-figma`, and `@zseven-w/pen-renderer` to version `0.5.1-beta.1`.
- Ensured consistency in dependency management across the project.

* fix(docker): add missing CLI package.json to build context

The Dockerfile was missing COPY for apps/cli/package.json, causing
bun install --frozen-lockfile to fail because the CLI workspace
dependency could not be resolved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add unpublish script and enhance CLI publish workflow

- Introduced a new `unpublish` script to manage unpublishing of all @zseven-w packages from npm, with options for specific versions, all versions, or deprecation.
- Updated the GitHub Actions workflow to check if a version is already published before proceeding with the publish step, ensuring better error handling and preventing conflicts.
- Removed the fallback for npm publish commands to fail fast on errors, improving the reliability of the publishing process.

* chore(bun.lock): update package versions and configuration

- Bumped the configVersion from 0 to 1 in bun.lock.
- Updated several package dependencies to their latest versions, including:
  - @anthropic-ai/claude-agent-sdk to 0.2.81
  - @babel/helpers to 7.29.2
  - @babel/parser to 7.29.2
  - @babel/runtime to 7.29.2
  - @csstools/color-helpers to 6.0.2
  - @csstools/css-color-parser to 4.0.2
- Ensured consistency in dependency management across the project.

* docs: restore Feishu group QR code in Chinese README

* chore: update dependencies and add workspace references

- Added "h3" package with version "^2.0.1-rc.18" to bun.lock, package.json, and apps/web/package.json.
- Updated dependencies in multiple packages to use "workspace:*" for better management and consistency across the project.
- Ensured all relevant packages are aligned with the latest workspace structure.

* chore: update package versions in bun.lock and package.json

- Updated several @tanstack packages to their latest versions, including:
  - @tanstack/react-devtools to 0.10.0
  - @tanstack/react-router to 1.168.1
  - @tanstack/react-router-devtools to 1.166.11
  - @tanstack/react-router-ssr-query to 1.166.10
  - @tanstack/react-start to 1.167.2
  - @tanstack/router-plugin to 1.167.1
- Bumped @tanstack/devtools-vite to 0.6.0 in devDependencies.
- Mocked 'paper' module in security tests to prevent crashes in jsdom environment.

* chore: update CLI and MCP compile scripts to include package aliases

- Enhanced the `mcp:compile` and `cli:compile` scripts in `package.json` and `apps/cli/package.json` to include additional package aliases for better module resolution.
- This change improves the build process by ensuring that the correct paths are used for various dependencies.

* chore: remove tag push trigger from publish CLI workflow

- Eliminated the tag push trigger from the GitHub Actions workflow for publishing the npm package, simplifying the workflow to only allow manual dispatch.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-23 22:46:04 +08:00
Kayshen Xu
370e51c0f7
V0.5.1 (#78)
* fix(docker): support multi-platform builds and fix monorepo paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization   (#76)

* fix(canvas): stabilize frame label size during zoom

  Draw frame labels in screen-space after the viewport transform
  restore, converting scene coords manually. Previously fontSize=12/zoom
  fed into Math.ceil caused integer-boundary jumps that made labels
  flicker during zoom. Also skip shadow rendering while actively
  zooming for smoother performance.

* perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization

   - Add paraImageCache (SkImage, 128 MB LRU limit) keyed on the same key as paraCache
   - Use drawImageRect instead of drawParagraph on cache hit, skipping per-frame glyph shaping and rasterization
   - Fall back to direct drawParagraph only when off-screen surface creation (MakeSurface) fails
   - Extract _dpr getter to deduplicate device-pixel-ratio resolution logic across draw paths
   - Evict oldest entries when cache exceeds byte limit; delete SkImage on eviction and dispose()

* feat(cli): introduce OpenPencil CLI for terminal control of the design tool

- Added a new CLI application under `apps/cli` to manage OpenPencil from the terminal.
- Implemented commands for app control (`start`, `stop`, `status`), document operations (`open`, `save`, `get`, `selection`), and design manipulation (`design`, `import`).
- Enhanced documentation with usage instructions and platform support details.
- Updated build scripts to include CLI compilation and publishing processes.
- Introduced a new GitHub Actions workflow for publishing the CLI to npm.
- Updated existing workflows to integrate CLI build steps and ensure proper versioning across packages.

* docs: update README files to include CLI tool details and multi-platform code export

- Added CLI section to README files in multiple languages, detailing commands for terminal control of the design tool.
- Included instructions for global installation and usage examples for the CLI.
- Expanded documentation on multi-platform code export capabilities from a single `.op` file to various frameworks.
- Updated CLAUDE.md to reference the new CLI documentation and its integration with the design tool.

* chore(bun.lock): update package dependencies to specific versions

- Removed workspace references for several packages in the bun.lock file.
- Updated dependencies for `@zseven-w/pen-core`, `@zseven-w/pen-types`, `@zseven-w/pen-codegen`, `@zseven-w/pen-figma`, and `@zseven-w/pen-renderer` to version `0.5.1-beta.1`.
- Ensured consistency in dependency management across the project.

* fix(docker): add missing CLI package.json to build context

The Dockerfile was missing COPY for apps/cli/package.json, causing
bun install --frozen-lockfile to fail because the CLI workspace
dependency could not be resolved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add unpublish script and enhance CLI publish workflow

- Introduced a new `unpublish` script to manage unpublishing of all @zseven-w packages from npm, with options for specific versions, all versions, or deprecation.
- Updated the GitHub Actions workflow to check if a version is already published before proceeding with the publish step, ensuring better error handling and preventing conflicts.
- Removed the fallback for npm publish commands to fail fast on errors, improving the reliability of the publishing process.

* chore(bun.lock): update package versions and configuration

- Bumped the configVersion from 0 to 1 in bun.lock.
- Updated several package dependencies to their latest versions, including:
  - @anthropic-ai/claude-agent-sdk to 0.2.81
  - @babel/helpers to 7.29.2
  - @babel/parser to 7.29.2
  - @babel/runtime to 7.29.2
  - @csstools/color-helpers to 6.0.2
  - @csstools/css-color-parser to 4.0.2
- Ensured consistency in dependency management across the project.

* docs: restore Feishu group QR code in Chinese README

* chore: update dependencies and add workspace references

- Added "h3" package with version "^2.0.1-rc.18" to bun.lock, package.json, and apps/web/package.json.
- Updated dependencies in multiple packages to use "workspace:*" for better management and consistency across the project.
- Ensured all relevant packages are aligned with the latest workspace structure.

* chore: update package versions in bun.lock and package.json

- Updated several @tanstack packages to their latest versions, including:
  - @tanstack/react-devtools to 0.10.0
  - @tanstack/react-router to 1.168.1
  - @tanstack/react-router-devtools to 1.166.11
  - @tanstack/react-router-ssr-query to 1.166.10
  - @tanstack/react-start to 1.167.2
  - @tanstack/router-plugin to 1.167.1
- Bumped @tanstack/devtools-vite to 0.6.0 in devDependencies.
- Mocked 'paper' module in security tests to prevent crashes in jsdom environment.

* chore: update CLI and MCP compile scripts to include package aliases

- Enhanced the `mcp:compile` and `cli:compile` scripts in `package.json` and `apps/cli/package.json` to include additional package aliases for better module resolution.
- This change improves the build process by ensuring that the correct paths are used for various dependencies.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-23 22:31:06 +08:00
Kayshen Xu
b4d1d2a7bb
V0.5.1 (#77)
* fix(docker): support multi-platform builds and fix monorepo paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization   (#76)

* fix(canvas): stabilize frame label size during zoom

  Draw frame labels in screen-space after the viewport transform
  restore, converting scene coords manually. Previously fontSize=12/zoom
  fed into Math.ceil caused integer-boundary jumps that made labels
  flicker during zoom. Also skip shadow rendering while actively
  zooming for smoother performance.

* perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization

   - Add paraImageCache (SkImage, 128 MB LRU limit) keyed on the same key as paraCache
   - Use drawImageRect instead of drawParagraph on cache hit, skipping per-frame glyph shaping and rasterization
   - Fall back to direct drawParagraph only when off-screen surface creation (MakeSurface) fails
   - Extract _dpr getter to deduplicate device-pixel-ratio resolution logic across draw paths
   - Evict oldest entries when cache exceeds byte limit; delete SkImage on eviction and dispose()

* feat(cli): introduce OpenPencil CLI for terminal control of the design tool

- Added a new CLI application under `apps/cli` to manage OpenPencil from the terminal.
- Implemented commands for app control (`start`, `stop`, `status`), document operations (`open`, `save`, `get`, `selection`), and design manipulation (`design`, `import`).
- Enhanced documentation with usage instructions and platform support details.
- Updated build scripts to include CLI compilation and publishing processes.
- Introduced a new GitHub Actions workflow for publishing the CLI to npm.
- Updated existing workflows to integrate CLI build steps and ensure proper versioning across packages.

* docs: update README files to include CLI tool details and multi-platform code export

- Added CLI section to README files in multiple languages, detailing commands for terminal control of the design tool.
- Included instructions for global installation and usage examples for the CLI.
- Expanded documentation on multi-platform code export capabilities from a single `.op` file to various frameworks.
- Updated CLAUDE.md to reference the new CLI documentation and its integration with the design tool.

* chore(bun.lock): update package dependencies to specific versions

- Removed workspace references for several packages in the bun.lock file.
- Updated dependencies for `@zseven-w/pen-core`, `@zseven-w/pen-types`, `@zseven-w/pen-codegen`, `@zseven-w/pen-figma`, and `@zseven-w/pen-renderer` to version `0.5.1-beta.1`.
- Ensured consistency in dependency management across the project.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-23 21:20:59 +08:00
Kayshen Xu
6c89c6dc9f
V0.5.0 (#73)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

* feat(docker): update Dockerfile to include additional package.json files

- Added package.json files for multiple packages (pen-types, pen-core, pen-codegen, pen-figma, pen-renderer, pen-sdk) and apps (web, desktop) to the Docker build context.
- Ensured all necessary dependencies are included for a complete build process.

* refactor(docker): update Dockerfile to use new output directory structure

- Changed the output directory from `/.output` to `/out/web` for all build stages in the Dockerfile.
- Updated CMD instructions to reflect the new path for the server entry point.
- Ensured consistency across all CLI variants for improved maintainability.

* fix(main): update server entry point paths for development and production

- Changed the development server entry point path from `.output/server/index.mjs` to `out/web/server/index.mjs`.
- Updated the production comment to reflect the new output directory structure for consistency.

* chore(docker): increase Node.js memory limit in Dockerfile

- Set NODE_OPTIONS to increase the maximum old space size to 4096 MB for better performance during builds.

* refactor(docker): remove Gemini CLI build stage from Dockerfile

- Eliminated the build stage for the Gemini CLI from the Dockerfile.
- Updated the CMD instruction to reflect the new output directory structure.
- Ensured consistency across all CLI variants for improved maintainability.

* refactor: update ignore patterns for build output

- Changed `.output` to `out` in both `.dockerignore` and `.gitignore` to align with the new output directory structure.
- Ensured consistency in ignoring build artifacts across the project.

* fix(docker): update Gemini CLI installation in Dockerfile

- Replaced the installation of @anthropic-ai/gemini-cli with @google/gemini-cli in both build stages of the Dockerfile.
- Ensured consistency in CLI tool installations across different Docker build stages.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 10:14:21 +08:00
Kayshen Xu
fb3b7b801a
V0.5.0 (#72)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

* feat(docker): update Dockerfile to include additional package.json files

- Added package.json files for multiple packages (pen-types, pen-core, pen-codegen, pen-figma, pen-renderer, pen-sdk) and apps (web, desktop) to the Docker build context.
- Ensured all necessary dependencies are included for a complete build process.

* refactor(docker): update Dockerfile to use new output directory structure

- Changed the output directory from `/.output` to `/out/web` for all build stages in the Dockerfile.
- Updated CMD instructions to reflect the new path for the server entry point.
- Ensured consistency across all CLI variants for improved maintainability.

* fix(main): update server entry point paths for development and production

- Changed the development server entry point path from `.output/server/index.mjs` to `out/web/server/index.mjs`.
- Updated the production comment to reflect the new output directory structure for consistency.

* chore(docker): increase Node.js memory limit in Dockerfile

- Set NODE_OPTIONS to increase the maximum old space size to 4096 MB for better performance during builds.

* refactor(docker): remove Gemini CLI build stage from Dockerfile

- Eliminated the build stage for the Gemini CLI from the Dockerfile.
- Updated the CMD instruction to reflect the new output directory structure.
- Ensured consistency across all CLI variants for improved maintainability.

* refactor: update ignore patterns for build output

- Changed `.output` to `out` in both `.dockerignore` and `.gitignore` to align with the new output directory structure.
- Ensured consistency in ignoring build artifacts across the project.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 10:13:45 +08:00
Kayshen Xu
fac5ec0293
V0.5.0 (#71)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

* feat(docker): update Dockerfile to include additional package.json files

- Added package.json files for multiple packages (pen-types, pen-core, pen-codegen, pen-figma, pen-renderer, pen-sdk) and apps (web, desktop) to the Docker build context.
- Ensured all necessary dependencies are included for a complete build process.

* refactor(docker): update Dockerfile to use new output directory structure

- Changed the output directory from `/.output` to `/out/web` for all build stages in the Dockerfile.
- Updated CMD instructions to reflect the new path for the server entry point.
- Ensured consistency across all CLI variants for improved maintainability.

* fix(main): update server entry point paths for development and production

- Changed the development server entry point path from `.output/server/index.mjs` to `out/web/server/index.mjs`.
- Updated the production comment to reflect the new output directory structure for consistency.

* chore(docker): increase Node.js memory limit in Dockerfile

- Set NODE_OPTIONS to increase the maximum old space size to 4096 MB for better performance during builds.

* refactor(docker): remove Gemini CLI build stage from Dockerfile

- Eliminated the build stage for the Gemini CLI from the Dockerfile.
- Updated the CMD instruction to reflect the new output directory structure.
- Ensured consistency across all CLI variants for improved maintainability.

* refactor: update ignore patterns for build output

- Changed `.output` to `out` in both `.dockerignore` and `.gitignore` to align with the new output directory structure.
- Ensured consistency in ignoring build artifacts across the project.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 10:09:26 +08:00
Kayshen Xu
f31dd537bd
V0.5.0 (#70)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

* feat(docker): update Dockerfile to include additional package.json files

- Added package.json files for multiple packages (pen-types, pen-core, pen-codegen, pen-figma, pen-renderer, pen-sdk) and apps (web, desktop) to the Docker build context.
- Ensured all necessary dependencies are included for a complete build process.

* refactor(docker): update Dockerfile to use new output directory structure

- Changed the output directory from `/.output` to `/out/web` for all build stages in the Dockerfile.
- Updated CMD instructions to reflect the new path for the server entry point.
- Ensured consistency across all CLI variants for improved maintainability.

* fix(main): update server entry point paths for development and production

- Changed the development server entry point path from `.output/server/index.mjs` to `out/web/server/index.mjs`.
- Updated the production comment to reflect the new output directory structure for consistency.

* chore(docker): increase Node.js memory limit in Dockerfile

- Set NODE_OPTIONS to increase the maximum old space size to 4096 MB for better performance during builds.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 10:03:23 +08:00
Kayshen Xu
283d17f3c2
V0.5.0 (#69)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

* feat(docker): update Dockerfile to include additional package.json files

- Added package.json files for multiple packages (pen-types, pen-core, pen-codegen, pen-figma, pen-renderer, pen-sdk) and apps (web, desktop) to the Docker build context.
- Ensured all necessary dependencies are included for a complete build process.

* refactor(docker): update Dockerfile to use new output directory structure

- Changed the output directory from `/.output` to `/out/web` for all build stages in the Dockerfile.
- Updated CMD instructions to reflect the new path for the server entry point.
- Ensured consistency across all CLI variants for improved maintainability.

* fix(main): update server entry point paths for development and production

- Changed the development server entry point path from `.output/server/index.mjs` to `out/web/server/index.mjs`.
- Updated the production comment to reflect the new output directory structure for consistency.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 09:57:01 +08:00
Kayshen Xu
5b489622a7
V0.5.0 (#68)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

* feat(docker): update Dockerfile to include additional package.json files

- Added package.json files for multiple packages (pen-types, pen-core, pen-codegen, pen-figma, pen-renderer, pen-sdk) and apps (web, desktop) to the Docker build context.
- Ensured all necessary dependencies are included for a complete build process.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 09:47:46 +08:00
Kayshen Xu
03d4433693
V0.5.0 (#67)
* docs: add image search & generation design spec and implementation plan

- Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation
- Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration

* feat(types): add image service types and imagePrompt to ImageNode

* feat(server): add image service API key validation endpoint

Adds POST /api/ai/image-service-test that validates credentials for
openverse (client_credentials), openai/custom (Bearer + /v1/models),
gemini (API key + v1beta/models), and replicate (Bearer + /v1/models).

* feat(server): add multi-provider image generation endpoint

* feat(server): add dual-source image search endpoint (Openverse + Wikimedia)

POST /api/ai/image-search searches freely-licensed images via Openverse
with automatic fallback to Wikimedia Commons on 429 rate-limit responses.
Supports optional OAuth credentials for authenticated Openverse requests.

* feat(store): add imageSearchStatuses to canvas store for runtime status tracking

* feat(store): add image generation config and Openverse OAuth to agent settings

* feat(editor): add Images tab to agent settings dialog

Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar.

* feat(panels): add image search popover with Openverse/Wikimedia results grid

* feat(panels): add image generate popover with multi-provider support

* feat(panels): add Search and Generate buttons to image property section

* feat(ai): update prompts to use imagePrompt instead of src for image nodes

* feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback

* feat(ai): trigger auto image search after design generation completes

* feat(mcp): implement G() operation for image search in batch design DSL

Adds the G(parent, mode, prompt) operation to batch_design DSL that creates
an image node and optionally fetches a real image URL via the image-search
API when mode is "search". Converts executeLine to async to support the
network call.

* feat(mcp): auto-fill images after design refinement in layered pipeline

* feat(ai): split imageSearchQuery and imagePrompt for search vs generation

- ImageNode now has both imageSearchQuery (short keywords for search)
  and imagePrompt (long description for AI image generation)
- AI prompts instruct LLM to generate both fields
- Search pipeline and popovers use imageSearchQuery
- Generate popover uses imagePrompt
- Server-side simplifySearchQuery kept as fallback for manual input

* fix(ai): hook auto image search into orchestrator completion path

The primary generation path uses executeOrchestration -> insertStreamingNode,
not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call
to orchestrator.ts after all sub-agents complete. Added debug logging.
Removed plan/spec docs from git.

* style(editor): remove provider names from image search ready status

* fix(panels): clean up image gen error display and settings UI

- Parse API error response to show concise message instead of raw JSON
- Limit error text to 2 lines with line-clamp
- Fix image gen test button sending wrong service name
- Inline Image Search ready indicator with section header
- Remove debug logging from image search pipeline

* style(panels): allow up to 4 lines for image gen error message

* fix: avoid 1-frame delay when resizing canvas (#60)

rAF callbacks run before ResizeObserver in the same frame.
Scheduling render in ResizeObserver via rAF defers it to the next frame.

Invoke render() synchronously to leverage ResizeObserver's pre-paint timing
and ensure immediate visual update.

* feat(electron): implement desktop application structure and auto-updater

- Introduced a new Electron desktop application with a structured directory for apps and packages.
- Added auto-updater functionality to manage application updates seamlessly.
- Created a comprehensive menu system for the desktop app.
- Implemented logging capabilities for better debugging and error tracking.
- Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder.
- Established TypeScript configurations for both the desktop and web applications.
- Integrated Vite for the web application with support for React and Tailwind CSS.
- Added icons and assets for the desktop application.

* chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization

- Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages.
- Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files.

* chore: update package versions to 0.5.0 and refactor Skia components

- Bumped version to 0.5.0 in bun.lock and all relevant package.json files.
- Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities.
- Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package.

* fix(panels): handle string fill values in icon nodes (#61)

AI-generated icon/path nodes may have fill stored as a raw string
instead of a PenFill[] array, causing "Cannot use 'in' operator"
crash when selecting the node in the property panel.

* chore: update documentation and project structure for monorepo organization

- Added a new version bump command to synchronize all package.json files.
- Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages.
- Enhanced README files in multiple languages to include the new structure and commands.
- Adjusted image paths in documentation to point to the correct locations for the desktop application.

* feat(ai): incremental image search and improved image generation prompts

- Refactor image search from batch post-generation to incremental queue:
  enqueueImageForSearch() triggers as each image node is inserted during
  streaming, so images appear progressively instead of all at once after
  generation completes. scanAndFillImages() remains as a final sweep.
- Update imagePrompt guidance to avoid "transparent background" and
  similar phrases that many models cannot reliably produce.
- Pass node width/height from image panel to generation endpoint for
  aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size
  selection, Replicate dimensions).

* feat(ai): multi-profile image generation config and cleaner error messages

- Support multiple image generation profiles with active selection;
  first configured profile becomes default. Old single-config migrated
  automatically on hydrate.
- Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec.
- Extract clean error messages from provider JSON responses (Gemini
  error.message, OpenAI error.message, Replicate detail) instead of
  returning raw JSON text.
- Remove destructive client-side regex that mangled error display.

* feat(design-md): integrate design system panel and functionality

- Added a new DesignMdPanel component for managing design system specifications.
- Implemented functionality to toggle the design system panel in the editor layout and toolbar.
- Introduced new commands for importing, exporting, and auto-generating design.md content.
- Updated AI chat handlers to utilize design.md data for enhanced design generation.
- Enhanced localization support for design system features across multiple languages.

* perf(canvas): skip draw calls for nodes outside the viewport (#64)

Add viewport culling in render() to avoid issuing CanvasKit draw calls
  for off-screen nodes. A 64px screen-space buffer is kept around the
  viewport edges so nearby nodes are pre-rendered, preventing pop-in
  during fast panning.

* feat(utils): enhance Windows process spawning for CLI scripts

- Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately.
- Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files.
- Improved handling of command execution to avoid limitations of cmd.exe.

* feat(ai): add support for Gemini CLI integration

- Extended the AI provider options to include 'gemini' across various components and APIs.
- Implemented functions for generating, validating, and connecting to the Gemini CLI.
- Added Gemini-specific error handling and model fetching logic.
- Updated UI components to display Gemini as a selectable provider with appropriate icons and labels.
- Enhanced localization support for Gemini-related features in multiple languages.

* feat(editor): warn before closing with unsaved changes

Intercept window/tab close when isDirty is true:
- Electron: native dialog with Save / Don't Save / Cancel
- Web: beforeunload handler + confirm on New/Open actions
- i18n: close-confirm strings for all 15 locales

* feat(ipc): extract IPC handlers to a dedicated module

- Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability.
- Implemented file open/save dialogs, theme setting, and preferences management through IPC.
- Enhanced updater functionality with state management and auto-update settings.
- Improved code structure by separating concerns, making it easier to manage IPC-related logic.

* feat(docs): update CLAUDE documentation and add new files for desktop and web apps

- Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`.
- Updated `pen-core` description to include clone utilities in `pen-core`.
- Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities.
- Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization.

* feat(docker): add Gemini CLI support and update documentation

- Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it.
- Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools.
- Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant.
- Added Gemini CLI connection instructions to the main README for better user guidance.

* feat(docs): add Gemini CLI connection instructions to multiple language READMEs

- Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI.
- Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings.

* perf(renderer): replace count-based text cache limits with memory-based eviction (#66)

previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small
  for scenes with many nodes, causing constant cache churn and paragraph
  rebuilds every frame, which dropped FPS significantly during canvas pan.

  - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps)
  - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated
    as content.length*64+4096
  - eviction uses Map insertion order (FIFO) instead of a separate string[]
    array, replacing O(n) array.shift() with O(1) Map.entries().next()
  - evict before insert so the budget check includes the incoming entry

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
2026-03-22 09:44:04 +08:00
Kayshen Xu
b05ebb944a
V0.4.4 (#59)
* feat(ai): enhance Windows CLI binary resolution and connection handling

- Introduced functions to handle both .cmd and .ps1 wrappers for Windows installations, improving compatibility for CLI tools like Codex and Copilot.
- Updated connection logic in `connect-agent.ts` to utilize environment variables for Codex home directory, enhancing flexibility in locating configuration files.
- Added warning messages in connection results to inform users when no models are found, guiding them to run the CLI tools for model population.
- Modified the agent settings dialog to accommodate the new warning field in connection responses, improving user feedback during the connection process.

This update significantly enhances the user experience for Windows users by ensuring better handling of CLI binaries and providing clearer connection status information.

* feat(ai): improve Windows binary resolution for CLI tools

- Added `resolveWinExtension` function to handle extensionless binaries returned by the `where` command on Windows, ensuring compatibility with `.cmd` and `.ps1` wrappers.
- Updated `connect-agent.ts` and `copilot-client.ts` to utilize the new resolution function, enhancing the reliability of binary path lookups for Codex and Copilot.
- Enhanced logging to provide clearer information on resolved paths and their existence status.

This update significantly improves the handling of CLI binaries on Windows, ensuring users have a smoother experience when connecting to AI tools.

* feat(ai): enhance binary path handling in connect-agent

- Added logic to ensure the directory of the resolved OpenCode binary is included in the PATH environment variable, improving the execution of CLI tools.
- Enhanced logging to provide feedback when the binary directory is prepended to the PATH, aiding in troubleshooting and user awareness.

This update improves the reliability of connecting to AI tools by ensuring the necessary binaries are accessible during execution.

* feat(patching): add patch for @opencode-ai/sdk and update package configurations

- Introduced a new patch for the @opencode-ai/sdk version 1.2.6 to address specific issues.
- Updated package.json and bun.lock to include the new patchedDependencies section, ensuring the patch is applied during installation.
- Removed redundant binary path handling code in connect-agent.ts to streamline the connection process.

This update enhances the SDK's functionality while simplifying the connection logic for improved performance.

* fix(ai): add orchestrator fallback and three-way intent routing

Orchestrator now falls back to a heuristic plan when the model returns
non-JSON instead of throwing an error.

Intent classification upgraded from binary (DESIGN/CHAT) to three-way
(DESIGN_NEW/DESIGN_MODIFY/CHAT). Modification requests without an
explicit selection auto-target the last top-level frame on the active
page, preventing them from being misrouted to the orchestrator.

* chore: bump version to 0.4.4 and remove deprecated patch for @opencode-ai/sdk

- Updated package version in package.json from 0.4.3 to 0.4.4.
- Removed the patchedDependencies section for @opencode-ai/sdk as the patch is no longer needed.
- Added new utility function `buildSpawnClaudeCodeProcess` to enhance agent SDK functionality across multiple files.

* chore: bump version to 0.4.4 in package.json

* chore(electron): optimize application icon assets

Reduce icon file sizes for faster builds and smaller distribution.

* chore: remove deprecated patchedDependencies for @opencode-ai/sdk in bun.lock

- Eliminated the patchedDependencies section for @opencode-ai/sdk as the patch is no longer necessary, streamlining the dependency management in the project.

* fix(ai): improve Codex CLI error extraction for auth and structured log errors

Parse Codex's structured log format (<timestamp> ERROR <module>: <message>)
to surface real errors like expired auth tokens instead of the unhelpful
"Warning: no last agent message" fallback.

* fix(ai): update error handling and remove deprecated reasoning field

- Updated error checks in chat and generate handlers to ensure proper validation of required fields.
- Removed the deprecated reasoning field from the OpenCode SDK integration, streamlining the prompt parameters.
- Enhanced logging for system prompt injection errors to improve debugging capabilities.

* fix(ai): enhance Windows compatibility for Codex CLI execution

- Updated the handling of prompts on Windows to avoid parsing errors caused by shell escaping in PowerShell and cmd.exe.
- Introduced a temporary PowerShell script to manage prompt input and command execution, ensuring special characters are processed correctly.
- Refactored the argument passing mechanism to accommodate the new script-based approach.

* fix(electron): improve process cleanup and Windows compatibility

- Simplified the application quit logic to always call app.quit() on window close.
- Enhanced the before-quit event to ensure proper cleanup of the Nitro process and port file.
- Updated the Nitro process termination logic to use SIGKILL for reliable cleanup on non-Windows platforms.
- Refactored argument handling in the Codex CLI execution to utilize PowerShell array splatting for safer argument passing.

* feat(ai): add fallback for model parsing from Codex's latest-model.md

- Implemented a new function to parse model IDs from the latest-model.md file when models_cache.json is unavailable, enhancing compatibility for fresh installations.
- Updated logging to reflect the loading of models from the fallback method and improved error handling for model loading scenarios.

* feat(ai): update prompt structure for design generation assistant

- Revised the prompt format to enhance clarity and organization for the design generation assistant.
- Introduced clear section headers for system instructions and user tasks, improving user experience and guidance.

* refactor(ai): streamline prompt handling for Codex CLI execution

- Simplified the prompt input mechanism for all platforms by utilizing stdin mode, eliminating the need for temporary scripts on Windows.
- Improved argument passing to avoid shell escaping issues and command-line length limits, enhancing compatibility and reliability across environments.
- Updated the execution logic to handle prompts more efficiently, ensuring a smoother user experience.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-20 22:00:22 +08:00
Kayshen Xu
bdf37908fd
V0.4.3 (#50)
* chore(ai): update dependencies and enhance logging functionality

- Bump version of `@github/copilot-sdk` and related packages to `0.1.32` and `1.0.7` for improved features and bug fixes.
- Update Discord invite links across multiple README files to the new server.
- Introduce a new logging utility in `server/utils/server-logger.ts` for better server process logging, including automatic log cleanup and directory management.
- Enhance the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands.
- Refactor `resolve-claude-cli.ts` and `resolve-copilot-cli.ts` to include detailed logging for CLI binary resolution processes.

This update improves dependency management, enhances user experience with updated links, and provides better insights into server operations through logging.

* chore: bump version from 0.4.0 to 0.4.3 in package.json
2026-03-18 21:34:58 +08:00
Kayshen Xu
efaa56a9e4
V0.4.2 (#46)
* feat(docker): add Docker support with multi-stage build and CI workflow

- Introduced a `.dockerignore` file to exclude unnecessary files from the Docker context.
- Added a `Dockerfile` for multi-stage builds, optimizing the application for production with a slim runtime.
- Created a GitHub Actions workflow (`docker.yml`) to automate the building and pushing of Docker images on version tag pushes.
- Enhanced the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands.
- Updated the canvas components to support new polygon shape and related functionalities, including UI adjustments for shape tools and appearance settings.

This update significantly enhances the deployment process and expands the application's capabilities with Docker integration.

* feat(docker): enhance Dockerfile with multi-stage builds and CLI variants

- Updated the Dockerfile to include multiple image variants for different CLI tools, including Claude, Codex, OpenCode, and Copilot, alongside the base web app.
- Improved the build process by separating the build stage and production stage, optimizing the final image size.
- Added environment variables and commands for each variant to ensure proper execution in production.
- Enhanced the README files in multiple languages to document the new Docker deployment options and usage instructions.

This update significantly expands the Docker deployment capabilities, allowing users to choose the appropriate image variant based on their needs.
2026-03-17 21:07:50 +08:00
Kayshen Xu
ebe1346d24
V0.4.1 (#45)
* feat(canvas): enhance font handling and text measurement

- Introduced a new `cssFontFamily` utility to ensure proper quoting of font family names in canvas text rendering.
- Updated text measurement functions to utilize `cssFontFamily`, improving compatibility with various font formats.
- Added a `systemFontFamilies` set in `SkiaFontManager` to manage system fonts that cannot be loaded into CanvasKit, ensuring fallback to bitmap rendering.
- Implemented a hook to enumerate system fonts, enhancing the font selection experience in the UI.
- Added a new `FontPicker` component for improved font selection, integrating bundled and system fonts with search functionality.
- Updated translations for new font-related UI elements across multiple languages.

This update significantly improves text rendering accuracy and user experience when selecting fonts.

* feat(figma): enhance clipboard data processing and font handling

- Introduced a new mechanism to identify and handle known non-Google fonts, preventing unnecessary requests to the Google Fonts CDN for system and proprietary fonts.
- Improved the Figma clipboard data extraction process by simplifying error handling and removing excessive debug logs, enhancing performance and readability.
- Added functionality to convert unresolved image references in Figma clipboard data into placeholder rectangles, ensuring better visual fidelity when images are missing.
- Enhanced instance conversion logic to merge symbol properties into instances, ensuring that clipboard data retains necessary layout and visual properties.

This update significantly improves the handling of Figma clipboard data and font management, leading to a more robust user experience.

* refactor(figma): enhance clipboard data processing and style resolution

- Improved the Figma clipboard data extraction by adding optional HTML parsing to enrich nodes with style hints, ensuring better fidelity in text rendering.
- Updated the `figmaClipboardToNodes` function to log conversion results and handle unresolved image references more effectively.
- Refactored style resolution logic to ensure inline properties are correctly applied from style references, enhancing the overall rendering accuracy of Figma imports.

This update significantly enhances the handling of Figma clipboard data and style management, leading to a more robust user experience.

* feat(canvas): add image fill support and enhancements to fill section

- Introduced support for image fills in the SkiaRenderer, allowing for dynamic image rendering with various fit modes and adjustments.
- Added a new ImageFillPopover component for managing image fill properties, including exposure, contrast, and saturation adjustments.
- Updated the FillSection component to include an option for image fills, enhancing the user interface for fill selection.
- Improved localization for new image-related features across multiple languages.

This update significantly enhances the capabilities of the canvas rendering system, providing users with more options for fill types and customization.

* feat(canvas): enhance text shadow rendering and fill section UI

- Implemented a new method for drawing text shadows as blurred copies of glyphs, aligning with Figma's drop-shadow behavior.
- Updated the `drawText` method to incorporate shadow effects for text nodes, improving visual fidelity.
- Adjusted the FillSection component to use a fixed width for better layout consistency.

This update significantly enhances text rendering capabilities and improves the user interface for fill options.

* refactor(figma): simplify arc ellipse conversion logic

- Removed unnecessary position adjustments for flipped nodes, as the extractPosition function already computes the correct visual top-left.
- Cleaned up comments to clarify the handling of arc properties without rotation adjustments, streamlining the conversion process for arc ellipses.

This update enhances the clarity and efficiency of the arc ellipse conversion logic in the Figma node converters.

* fix(canvas): use drawImageRect for image fill modes instead of broken shader scaling

CanvasKit's Image.makeShaderOptions() fails to render when the localMatrix
contains scaling factors with Clamp/Decal tile modes. Only Repeat mode
works reliably with shader scaling.

- Tile mode: keep shader + TileMode.Repeat (works correctly)
- Fill/Fit/Crop/Stretch: use drawImageRect with canvas clipping
- Add drawImageFillRect() for non-tile image fill rendering
- makeFillPaint() returns optional imageFillDraw info for drawRect

* fix(panels): fix image adjustment reset button not working

The reset button called onAdjustmentChange in a loop, but each call
spread from the same stale fill reference, so only the last adjustment
was actually reset. Added onResetAdjustments callback that resets all
adjustment values in a single atomic update.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-16 09:33:57 +08:00
Kayshen Xu
90bbcb16fd
V0.4.0 (#44)
* perf(canvas): bitmap cache during zoom/pan, fix save for large files

- Add canvas-zoom-cache: captures pixel snapshot on first viewport change,
  draws cached bitmap with transform delta (~0.1ms vs ~15ms per frame).
  Refreshes snapshot every 200ms during continuous interaction.
- Integrate zoom cache into wheel zoom and space+drag panning.
- Set renderOnAddRemove: false on Fabric canvas to avoid per-object renders.
- Remove depth/size LOD from viewport culling (zoom cache handles perf).
- Rewrite syncCanvasPositionsToStore: single tree walk + single store set
  instead of per-object updateNode (was O(n²) with 200+ history pushes).
- Unify save/save-as: .op files save in-place, non-.op triggers save-as.
- Add Electron filePath storage for reliable native IPC saves.
- Add error handling with fallback for all save paths.

* feat(canvas): integrate CanvasKit for enhanced rendering and layout

- Added support for CanvasKit WASM to improve rendering performance and capabilities.
- Introduced a new SkiaCanvas component for rendering using CanvasKit.
- Implemented spatial indexing with RBush for efficient hit testing in the Skia engine.
- Enhanced text measurement and layout handling using Canvas 2D for accurate word wrapping.
- Updated layout engine to accommodate badge and overlay nodes without affecting layout flow.
- Bumped version from 0.3.3 to 0.3.4 to reflect these significant changes.

* fix(electron): add graceful-fs to devDependencies for Node.js v25 compat (#38)

* feat(figma): enhance instance override handling and derived data processing

- Added support for symbol tree context in instance conversion.
- Improved the application of instance overrides by filtering derived data to exclude nested instances.
- Implemented a two-strategy approach for resolving overrides and derived data, accommodating both direct index mapping and expanded DFS for nested instances.
- Updated FigmaSymbolOverride interface to extend FigmaNodeChange, enhancing the handling of overridable node properties.

* refactor(canvas): remove loading state and enhance error handling in SkiaCanvas

- Eliminated the loading state management from SkiaCanvas, simplifying the component.
- Updated error handling to directly display error messages without loading indicators.
- Adjusted the EditorLayout to directly render SkiaCanvas without suspense, improving performance.
- Introduced drag-and-drop file handling in the canvas store for better user experience.
- Enhanced Figma import dialog to auto-process pending files from drag-and-drop.

* fix(ai): improve error messages for API authentication and connection issues

- Updated error hints in chat.ts to provide clearer instructions for API authentication, including running "claude login" or setting the ANTHROPIC_API_KEY in settings.json.
- Enhanced friendly error messages in connect-agent.ts to guide users on authentication steps when encountering connection errors.
- Refactored resolve-claude-agent-env.ts to improve environment variable normalization, allowing for better handling of ANTHROPIC_CUSTOM_HEADERS and ensuring proper serialization of object values.

* refactor(env): streamline environment variable reading and enhance settings handling

- Renamed and refactored the function for reading Claude settings to improve clarity and maintainability.
- Introduced a new function to read settings from both `settings.json` and `settings.local.json`, ensuring local settings take precedence.
- Updated test setup to use the system's temporary directory for security tests, enhancing compatibility across environments.

* feat(ai): add fallback models for third-party API proxies in connect-agent

- Introduced FALLBACK_CLAUDE_MODELS to provide default model options when supportedModels() fails, enhancing connectivity with third-party API proxies.
- Updated error handling in connectClaudeCode to return fallback models on specific connection errors, ensuring users can still connect and select a model.

* chore: bump version from 0.3.4 to 0.4.0 in package.json

* feat(ai): enhance prompt handling for basic-tier models

- Introduced logic to inline system prompts into user messages for basic-tier models (e.g., MiniMax, GLM) to ensure proper instruction visibility.
- Updated design modification and orchestrator functions to accommodate this change, improving interaction with third-party routers.
- Added a utility function to strip non-standard XML-like tags from AI responses, enhancing JSON extraction reliability.

* fix(server): add ESM-compatible __dirname polyfill (#42)

- Add fileURLToPath/dirname polyfill to mcp-install.ts
- Add fileURLToPath/dirname polyfill to mcp-server-manager.ts

Fixes browser version crash with "__dirname is not defined"
Fixes MCP over HTTP transport failure since v3.3

Closes #37

* fix(server): make MCP HTTP server survive parent process lifecycle (#43)

## Problem
The MCP HTTP server process died when:
1. User closed the Settings/Agent dialog in the UI
2. User interacted with the editor canvas
3. Nitro server hot-reloaded or restarted
4. Electron app sent SIGTERM to Nitro on window close

This made the MCP server unusable for HTTP transport, requiring users
to keep the settings dialog open constantly.

## Root Cause Analysis
The MCP server was spawned as a regular child process without detached
mode. This created several fatal dependencies:

1. **Signal propagation**: Lines 29-34 registered handlers for SIGINT,
   SIGTERM, SIGHUP that killed the MCP process when the parent received
   these signals (e.g., Electron closing Nitro)

2. **Parent-child lifecycle binding**: Without detached mode, the child
   process is tied to the parent's event loop. Any parent instability
   (hot reload, dialog closure causing microtasks, etc.) could affect
   the child

3. **In-memory process tracking**: The mcpProcess variable was stored
   in module memory, so Nitro restarts/hot-reloads would lose track of
   the running server

## Solution
1. **Detached spawn mode**: Use `detached: true` + `unref()` to make
   the MCP process completely independent of the parent

2. **PID file persistence**: Track the process via files in /tmp
   instead of in-memory variables, surviving parent restarts

3. **Remove signal handlers**: Delete the SIGINT/SIGTERM/SIGHUP
   handlers that were killing the MCP process

4. **Cross-platform kill**: Use process.kill() instead of execSync
   with taskkill for safer process termination

## Testing
### Test Case 1: Settings Dialog Close
- Open OpenPencil → Settings → Start MCP HTTP Server
- Close settings dialog
- Reopen settings → MCP server still running 

### Test Case 2: Editor Interaction
- Start MCP HTTP Server
- Draw on canvas, create shapes, use all tools
- Check MCP server status → Still running 

### Test Case 3: Browser Version
- Run `npx vite --port 3000`
- Start MCP from browser UI
- Close tab, reopen → Server still running 

### Test Case 4: Electron App
- Start MCP from installed app
- Close app window
- Reopen app → Server still running (tracked via PID file) 

### Test Case 5: Stop/Restart Cycle
- Start → Stop → Start again
- Works correctly 

## Files Changed
- server/utils/mcp-server-manager.ts - Complete rewrite with detached mode
- server/api/ai/mcp-install.ts - Add ESM __dirname polyfill

Fixes #37

Co-authored-by: Kayshen Xu <kayshen.xu@gmail.com>

* fix(canvas): respect node-level theme overrides during variable resolution (#41)

Previously, resolveNodeForCanvas was called with a single activeTheme
for all nodes, ignoring any per-node theme property. This meant nodes
with theme: {"Mode": "Light"} would still render using Dark mode colors.

This fix merges each node's theme property with the default activeTheme,
allowing individual nodes to override theme axes for proper themed
variable resolution.

Fixes design documents where some panels need different theme variants
than the default (e.g., Light mode components in a Dark mode document).

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Kayshen Xu <kayshen.xu@gmail.com>

* feat(ai): enhance text processing by stripping tool call blocks

- Added a new function to strip fake tool call blocks emitted by basic-tier models, ensuring cleaner JSON extraction.
- Updated the existing text processing flow to incorporate this new function, improving the handling of non-standard model artifacts.
- Modified orchestrator prompts to disallow function calls and tool call syntax in JSON outputs, enhancing response consistency.

* fix(server): resolve merge conflict and clean up ESM __dirname polyfill

- Removed merge conflict markers from mcp-install.ts and mcp-server-manager.ts.
- Ensured consistent implementation of ESM-compatible __dirname polyfill across both files.

* feat(ai): add node availability check and HTTP fallback for MCP server installation

- Implemented a function to check for the availability of Node.js on the system, enhancing compatibility for environments without Node.js.
- Added a fallback mechanism to install the MCP server using an HTTP URL when Node.js is not found, ensuring functionality remains intact.
- Updated the installation response to include a flag indicating if the HTTP fallback was used, allowing the UI to reflect the server status accurately.
- Enhanced the agent settings dialog to synchronize the MCP server status when the HTTP server is auto-started.

* refactor(canvas): replace Fabric.js with CanvasKit/Skia for enhanced rendering performance

- Removed Fabric.js dependencies and related code, transitioning to CanvasKit/Skia as the primary canvas engine.
- Updated documentation and README files to reflect the new canvas technology.
- Adjusted data flow and component interactions to accommodate the new rendering engine.
- Ensured backward compatibility by retaining legacy Fabric.js files for specific utilities until full removal is feasible.

* refactor(canvas): update layout engine and remove Fabric.js dependencies

- Enhanced the canvas layout engine by integrating Skia and CanvasKit, replacing Fabric.js references.
- Improved type safety by refining type assertions and imports across various components.
- Added a new function to retrieve canvas dimensions, defaulting to 800x600 if no engine is mounted.
- Removed obsolete rendering logic related to Fabric.js, streamlining the layout indicator functionality.
- Updated multiple components to utilize the new canvas size retrieval method, ensuring consistent behavior across the application.

* fix(figma): resolve multiple .fig import rendering issues

- Fix layout properties in preserve mode: only apply auto-layout (gap,
  padding, justify, align) for frames with stackMode, use absolute
  positioning for non-auto-layout frames
- Reverse children order for auto-layout frames in preserve mode to
  match Figma's layout flow order (tree builder sorts descending for
  z-stacking but layout needs ascending)
- Fix instance inlining: always inline symbol content when instance has
  no local children, regardless of override/derived data presence
- Fix derived data mapping: use direct GUID matching (Strategy 0) when
  derived guidPath GUIDs are actual symbol node GUIDs, preventing the
  off-by-one size/transform misassignment from index-based mapping
- Add styleIdForFill/styleIdForStrokeFill resolution: resolve fill style
  references to inline paints before tree building, fixing missing fills
  on 448+ nodes including active menu items and styled elements
- Fix collectImageBlobs to detect JPEG/GIF/WebP in addition to PNG
- Fix instance override application: apply overrides even when derived
  data is absent

* fix(figma): skip opacity=0 nodes during import to fix breadcrumb visibility

Nodes with opacity <= 0 are now skipped in convertChildren, along with
all their descendants.  This fixes the breadcrumb "Page / Page / Page"
text showing through despite the parent items having opacity=0, since
the Skia renderer does not propagate parent opacity to child nodes.

* fix(figma): resolve styleIdForFill in instance override entries

Style references (styleIdForFill, styleIdForStrokeFill) inside
symbolOverrides were not being resolved to inline paints.  This caused
text nodes in overridden instances (e.g. "All Products" card on blue
background) to retain the symbol's default colors instead of the
instance-specific white fills.

The resolveStyleReferences pre-processing step now iterates into each
node's symbolData.symbolOverrides array and resolves style references
there as well.

* fix(figma): apply icon colors and fix arc rendering in .fig import

- Fix donut chart missing segment: swap start/end angles when
  endingAngle < startingAngle instead of adding 2π, producing correct
  clockwise arc equivalents for counter-clockwise Figma arcs

- Fix icon stroke thickness: scale strokeWeight proportionally in
  scaleTreeChildren, applyInstanceOverrides derived sizing, and
  convertVector for Lucide icons rendered smaller than 24×24

- Fix nested instance color overrides: build nestedOverrideMap from
  multi-guid override paths (e.g. instanceGuid/childGuid) and inject
  child-scoped overrides into nested INSTANCE symbolOverrides, enabling
  Dashboard Summary Card icons to receive white/blue stroke colors

- Fix override-only instances: when derivedSymbolData is empty but
  symbolOverrides exist (e.g. sidebar icon instances with stroke color
  overrides but no size changes), fall through to direct GUID matching
  so stroke paints are correctly applied

- Include strokePaints in hasVisualOverrides check so instances with
  stroke-only overrides are properly inlined with their symbol children

* feat(canvas): add vector text rendering with bundled fonts for Figma import

Replace bitmap text fallback with CanvasKit Paragraph API for true vector
text rendering. Bundle 11 popular font families locally via @fontsource
packages to ensure reliable rendering in regions where Google Fonts CDN
is unavailable. Key changes:

- SkiaFontManager: local-first font loading with Google Fonts fallback
- Paragraph API rendering with caching, styled segments, and alignment
- Fixed-width text tolerance to prevent unwanted line wrapping from font
  metric differences between Figma and CanvasKit
- Auto-width text manual alignment offset (center/right) to avoid
  infinite layout width issues
- Figma text mapper: support RAW lineHeight units
- Figma import dialog: pre-load fonts after conversion

* fix(canvas): align text centering with CanvasKit and improve font fallback

- Replace Fabric.js FONT_SIZE_MULT (1.13) with actual paragraph height
  (fontSize * lineHeight) for cross-axis text centering, fixing icon
  vertical misalignment in horizontal layouts
- Remove Fabric-specific optical correction (getTextOpticalCenterYOffset)
  since CanvasKit halfLeading handles this correctly
- Add font fallback chain with separate Inter Ext family for latin-ext
  glyph coverage (₦ U+20A6 etc.)
- Cache failed font loads to prevent repeated fetch attempts per frame
- Skip Google Fonts requests for known system/proprietary fonts
  (PingFang SC, Microsoft YaHei, D-DIN-PRO, etc.)

* fix(figma): recursive nested instance expansion and CJK font support

- Fix 3 bugs in applyInstanceOverrides() that broke deeply nested Figma
  component instances (6+ levels): propagate multi-guid derivedSymbolData
  to nested instances, resolve virtual GUIDs via pkToNodeGuid map, and
  build nestedDerivedMap alongside nestedOverrideMap for recursive expansion
- Bundle Noto Sans SC (chinese-simplified + latin subsets, 400/700 weights)
  for offline CJK vector text rendering without Google Fonts dependency
- Add Noto Sans SC to font fallback chain for CJK glyph coverage
- Add China CDN mirror (fonts.font.im) as fallback when Google Fonts is
  inaccessible, with 4s timeout on primary CDN
- Increase .fig file size limits to 150MB compressed / 300MB decompressed

* fix(canvas): enhance CJK font support and pre-load Noto Sans SC

- Update SkiaEngine to pre-load Noto Sans SC alongside Inter for improved CJK glyph coverage in the font fallback chain, preventing rendering issues with system fonts.
- Modify FigmaImportDialog to ensure Noto Sans SC is included when primary fonts are system fonts, ensuring proper rendering of CJK text.

* refactor(figma): optimize instance override handling with full DFS strategy

- Replace hybrid skip-INSTANCE and expanded DFS with a unified full DFS approach for handling all node types, including INSTANCE nodes.
- Update the mapping logic to ensure correct localID assignments and improve the handling of overflow entries during instance expansion.
- Enhance the overall structure and readability of the applyInstanceOverrides function by consolidating the traversal methods.

* refactor(figma): improve layout mapping and instance override logic

- Enhance the mapFigmaLayout function to conditionally set gap based on stackSpacing and justifyContent, ensuring compatibility with Figma's layout behavior.
- Simplify the applyInstanceOverrides function by removing redundant logic and focusing on a unified full DFS approach for node mapping, improving clarity and performance.
- Ensure derivedSymbolData is correctly replaced with nested data from outer instances, preventing incorrect merging of entries.

* refactor(figma): enhance instance override logic with root-inclusive DFS

- Introduce a root-inclusive depth-first search (DFS) to accurately detect and skip overrides targeting the symbol root during instance processing.
- Update the applyInstanceOverrides function to improve clarity and performance by refining the handling of derived data and overrides.
- Ensure that overrides targeting the root are excluded from child nodes, preventing incorrect application of overrides in nested instances.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Related8919 <191752213+Related8919@users.noreply.github.com>
Co-authored-by: Hrijul Dey <44521405+hr1juldey@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-15 10:01:35 +08:00
Kayshen Xu
c1587b1a55
chore: bump version from 0.3.2 to 0.3.3 2026-03-11 22:18:44 +08:00
Kayshen Xu
4b89911dca
build: Remove publisher name from Windows build config 2026-03-11 21:41:40 +08:00
Kayshen Xu
a83663e0ef
V0.3.3 (#36)
* fix(ai): add icon name aliases and fix multi-path SVG concatenation

Add 55+ common icon name aliases (burger→hamburger, sushi→fish, etc.)
to both client icon-resolver and server icon API for robust AI-generated
icon resolution. Register Lucide's own aliases for broader coverage.

Fix SVG path concatenation bug where joining multiple <path> d-values
caused incorrect rendering — a standalone <path> treats initial lowercase
"m" as absolute, but after concatenation it becomes relative to the
previous sub-path endpoint. Now ensures each sub-path starts with
absolute "M".

Add tryAsyncIconFontResolution for icon_font nodes that miss local
lookup — fetches from server API, caches result, and triggers canvas
re-render.

* fix(canvas): preserve badge/overlay absolute positioning in auto-layout

Add isBadgeOverlayNode() detector for badge, indicator, notification-dot,
and overlay nodes. These nodes now retain their x/y coordinates instead
of being stripped by layout sanitization.

Update computeLayoutPositions to exclude badge nodes from the layout flow
— they keep absolute positioning and render on top (prepended for correct
z-order in reverse iteration).

* fix(ai): prevent duplicate canvas objects and fix emoji-to-icon pipeline

Streaming path: add ensureUniqueNodeIds before inserting nodes to prevent
ID collisions across multiple AI generations. Track newly inserted IDs
so subsequent streaming nodes don't collide either.

Canvas sync: deduplicate Fabric objects sharing the same penNodeId —
keep only the one tracked in objMap, remove stale duplicates.

Badge nodes: use shared isBadgeOverlayNode() for z-order insertion
and skip x/y stripping in layout parents.

Fix emoji-to-icon pipeline: re-run applyIconPathResolution after
applyNoEmojiIconHeuristic converts emoji text nodes to path nodes,
so the icon resolver can match by name (e.g. "Pizza Emoji Path" → pizza).

* fix(canvas): add async icon resolution fallback for icon_font nodes

When lookupIconByName fails locally, queue tryAsyncIconFontResolution
to fetch from server API. Cache result in ICON_PATH_MAP and trigger
canvas re-render via store update. Store iconFontName and iconStyle
on Fabric object for sync tracking.

* fix(ai): strengthen emoji ban in prompts and improve orchestrator defaults

Update all AI prompts to explicitly ban emoji characters with concrete
examples and redirect to icon_font nodes instead of the previously
incorrect "path nodes" guidance.

Add z-order rule to orchestrator prompt: overlay elements must come
before content they overlap.

Add padding support to OrchestratorPlan rootFrame type. Default mobile
root frame gap to 16 for consistent spacing.

* feat(electron): add publisher name to Windows build configuration

Updated the `electron-builder.yml` to include a publisher name for Windows builds, enhancing the identification of the application during installation. Additionally, revised the README files across multiple languages to reflect the new project description and features, emphasizing OpenPencil as the world's first AI-native open-source vector design tool with concurrent agent teams and design-as-code capabilities.

* chore(electron): remove publisher name from Windows build configuration

Removed the `publisherName` field from the Windows build configuration in `electron-builder.yml`, streamlining the build process and focusing on artifact naming conventions.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-11 21:39:27 +08:00
Kayshen Xu
9930e4d3b0
V0.3.3 (#35)
* fix(ai): add icon name aliases and fix multi-path SVG concatenation

Add 55+ common icon name aliases (burger→hamburger, sushi→fish, etc.)
to both client icon-resolver and server icon API for robust AI-generated
icon resolution. Register Lucide's own aliases for broader coverage.

Fix SVG path concatenation bug where joining multiple <path> d-values
caused incorrect rendering — a standalone <path> treats initial lowercase
"m" as absolute, but after concatenation it becomes relative to the
previous sub-path endpoint. Now ensures each sub-path starts with
absolute "M".

Add tryAsyncIconFontResolution for icon_font nodes that miss local
lookup — fetches from server API, caches result, and triggers canvas
re-render.

* fix(canvas): preserve badge/overlay absolute positioning in auto-layout

Add isBadgeOverlayNode() detector for badge, indicator, notification-dot,
and overlay nodes. These nodes now retain their x/y coordinates instead
of being stripped by layout sanitization.

Update computeLayoutPositions to exclude badge nodes from the layout flow
— they keep absolute positioning and render on top (prepended for correct
z-order in reverse iteration).

* fix(ai): prevent duplicate canvas objects and fix emoji-to-icon pipeline

Streaming path: add ensureUniqueNodeIds before inserting nodes to prevent
ID collisions across multiple AI generations. Track newly inserted IDs
so subsequent streaming nodes don't collide either.

Canvas sync: deduplicate Fabric objects sharing the same penNodeId —
keep only the one tracked in objMap, remove stale duplicates.

Badge nodes: use shared isBadgeOverlayNode() for z-order insertion
and skip x/y stripping in layout parents.

Fix emoji-to-icon pipeline: re-run applyIconPathResolution after
applyNoEmojiIconHeuristic converts emoji text nodes to path nodes,
so the icon resolver can match by name (e.g. "Pizza Emoji Path" → pizza).

* fix(canvas): add async icon resolution fallback for icon_font nodes

When lookupIconByName fails locally, queue tryAsyncIconFontResolution
to fetch from server API. Cache result in ICON_PATH_MAP and trigger
canvas re-render via store update. Store iconFontName and iconStyle
on Fabric object for sync tracking.

* fix(ai): strengthen emoji ban in prompts and improve orchestrator defaults

Update all AI prompts to explicitly ban emoji characters with concrete
examples and redirect to icon_font nodes instead of the previously
incorrect "path nodes" guidance.

Add z-order rule to orchestrator prompt: overlay elements must come
before content they overlap.

Add padding support to OrchestratorPlan rootFrame type. Default mobile
root frame gap to 16 for consistent spacing.

* feat(electron): add publisher name to Windows build configuration

Updated the `electron-builder.yml` to include a publisher name for Windows builds, enhancing the identification of the application during installation. Additionally, revised the README files across multiple languages to reflect the new project description and features, emphasizing OpenPencil as the world's first AI-native open-source vector design tool with concurrent agent teams and design-as-code capabilities.

* chore(electron): remove publisher name from Windows build configuration

Removed the `publisherName` field from the Windows build configuration in `electron-builder.yml`, streamlining the build process and focusing on artifact naming conventions.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-11 21:32:24 +08:00