Commit graph

197 commits

Author SHA1 Message Date
Fini b153b37cfe fix(mcp): get_design_prompt reads design.md per-document (no global leak)
The earlier doc-backed fix left one leak: handleGetDesignMd/handleSetDesignMd
still called setDesignMdForPrompt(spec), which wrote into a process-level
module variable `_designMdContent` that get_design_prompt's "style" +
"design-md" sections read. Switching between documents kept the prior
file's policy; get_design_prompt itself had no filePath parameter so it
couldn't even identify the current document.

Fix:
- Delete `_designMdContent` / setDesignMdForPrompt / getDesignMdForPrompt.
- `buildDesignPrompt(section, designMdPolicy?)` takes policy as an explicit
  stateless argument.
- Export `designMdSpecToPromptPolicy(spec)` — pure converter.
- Add `filePath` to get_design_prompt's schema. The route handler opens
  the addressed document, derives the policy from `doc.designMd`, and
  threads it into buildDesignPrompt. Add `design-md` to the section enum
  (previously only returned via the "style" override).
- design-md.ts handlers no longer touch the old setter.

Verified by a two-file live smoke: set design.md on A → get_design_prompt
on B returns "No design.md loaded" with no A-specific tokens.
2026-04-20 00:44:49 +08:00
Fini 2aabe494f5 fix(core): design.md lives on PenDocument — kill cross-document leak
design.md was stored in a global Zustand store + per-file-key localStorage
in apps/web, and in a module-level cache in pen-mcp. Both leaked across
files: a newly-created document could pick up the previous file's dark
palette (async clearForNewDocument raced with AI chat reads; hydrate()
could rehydrate the last file's designMd on refresh; shared .pen files
lost the spec entirely because it wasn't inside the document).

Fix:
- Add `designMd?: DesignMdSpec` to PenDocument (pen-types). It now
  serializes with .pen/.op and travels across sessions/users.
- Add `setDesignMd` action to document-store.
- Rewrite design-md-store as a thin mirror over document-store so the
  legacy hook API still works. On document load it migrates any legacy
  localStorage entry into the opened document and deletes the localStorage
  key; hydrate() wipes the orphan `openpencil-design-md-current-key`.
- MCP handleGetDesignMd / handleSetDesignMd / handleExportDesignMd read
  `doc.designMd` directly and persist via saveDocument. Removed the
  process-level `_mcpDesignMd` cache.

Verified via MCP live round-trip: set on file A → persists to A's .op on
disk → new file B returns hasDesignMd:false (no leak).
2026-04-20 00:37:31 +08:00
Fini bc7e16fa20 fix(mcp): element tools with colored bg set foreground for readable contrast
pen-core's DEFAULT_FILL is gray-300 (#d1d5db) — any text/icon without
explicit fill renders light gray, unreadable on primary blue / dark /
white backgrounds. Set explicit foreground colors on toast text + icon,
fab icon, stepper step numbers, checkbox check, and segmented-control
labels. Added contrast-regression test to lock the invariant.
2026-04-20 00:02:59 +08:00
Fini 039fb89251 feat(mcp): add alert + toast + progress_bar + fab + breadcrumb + stepper (25 → 31 element tools)
Feedback + floating + nav batch. progress_bar uses fixed bar_width so the
fill can be derived from value/100 (pen-core has no percent sizing).
Stepper connectors use fill_container so the bar splits evenly between
circles.
2026-04-19 23:55:52 +08:00
Fini fc64af5609 fix(mcp): add_tabs_v0 tabs split bar evenly (width=fill_container)
Layout-engine trap: a fill_container child inside a fit_content parent
resolves to the grandparent's width (pen-core engine.ts:182-187), so the
active tab's underline rect blew the whole tab up to bar width. Switched
every tab to width=fill_container (Twitter/Material pattern) so the bar
splits evenly and the underline sits correctly inside its slot.
2026-04-19 23:25:12 +08:00
Fini 477c0b2377 fix(mcp): add_tabs_v0 underline uses sibling rectangle (directional stroke unsupported)
PenStroke.thickness only supports number | [T,R,B,L] — {bottom:N} silently
resolves to 0 in resolveStrokeWidth so the old tab underline never rendered.
Switched active tab to a vertical frame with a sibling rectangle underline
(role=tab-underline), matching how add_divider_v0 already handles this.
2026-04-19 23:17:28 +08:00
Fini 36c46a50ef feat(mcp): add switch + checkbox + radio + tabs + segmented + empty_state (19 → 25 element tools)
Controls + empty state batch. Schemas split into element-tool-defs-ext.ts
to keep the main route file under the 800-line limit as the family grows.
2026-04-19 23:08:06 +08:00
Fini c12390c98d feat(mcp): add search_bar + form_field (17 → 19 element tools)
Forms coverage. search_bar fixes 44/22 hit-target; form_field enforces
fill_container input + 48px height from design-guidelines ROLE_GUIDE.
2026-04-19 22:52:50 +08:00
Fini a59189303b refactor(mcp): split element tool defs out of design-routes (848 → 201+671)
Codex stop-hook: design-routes.ts reached 849 lines, violating the
repo's CLAUDE.md "Single files must not exceed 800 lines" rule.

Extract the 17 element-tool JSON schema definitions + names + dispatch
switch into a new file `routes/element-tool-defs.ts` (671 lines). The
core design-routes.ts keeps only:
  - 2 core tool defs (get_design_prompt, batch_design)
  - LAYERED_DESIGN_TOOLS spread
  - D0 spike tool def + dispatch (gated)
  - Combined DESIGN_TOOL_DEFINITIONS / DESIGN_TOOL_NAMES / handleDesignToolCall
    that merges core + element-tool via re-export

design-routes.ts: 849 → 201 lines
element-tool-defs.ts: 0 → 671 lines (both under the 800 cap; room to
add ~3-5 more element tools before element-tool-defs itself needs
splitting by category — e.g. atom-tool-defs vs row-tool-defs)

handleDesignToolCall falls through via `if (ELEMENT_TOOL_NAMES.has(name))
return handleElementToolCall(name, a)` instead of an inlined 17-case
switch. Same dispatch semantics, much shorter file.

DESIGN_TOOL_NAMES kept as a single exported Set so existing callers
(server.ts, test files) still see all 22 tool names (5 core + 17 element)
via one import. ELEMENT_TOOL_NAMES also exported for tests that want to
assert the split explicitly.

180/180 pen-mcp tests pass unchanged. format + tsc green. Bundle
rebuilt.
2026-04-19 22:42:38 +08:00
Fini fa8fb393ca feat(mcp): add icon_label + list_row (15 → 17 element tools)
Two composition primitives completing the "atoms + composition" tier.

- add_icon_label_v0: atomic icon + text horizontal pair (alignItems=
  center, gap=8, fit_content). Building block for menu items,
  breadcrumbs, status indicators. Narrow schema: icon always leads,
  sizes fixed (icon 16, text 14/500), no alignment enum.

- add_list_row_v0: iOS/Material list row — optional leading icon +
  vertical text stack (title + optional subtitle) + optional trailing
  icon (typically chevron-right).
  No-overlap invariant: middle text stack wrapped in VERTICAL
  container with width=fill_container so long titles wrap vertically
  instead of pushing the trailing icon out of frame — same pattern
  as add_section_header_v0. overflow.md rule: text with
  fill_container + fixed-width only propagates wrap height inside
  vertical-layout parents. The vertical wrapper is what prevents the
  overlap.

Tests: 11 new unit (5 icon-label + 6 list-row). List-row includes
an explicit no-overlap regression test asserting the text stack is
vertical + fill_container. contract test ELEMENT_TOOL_NAMES updated
15 → 17.

elements.md skill gets a new "Composition" category in the decision
tree (items 16-17) + 2 new PREFER phrases + 2 usage examples + role
list extended. d0 snapshot updated.

MCP live smoke: ListTools = 57 (40 baseline + 17 element);
icon_label produces 3-node tree; list_row full variant produces
6-node tree with text stack correctly vertical + fill_container.

180/180 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
2026-04-19 22:32:02 +08:00
Fini b5dd3d52b2 fix(ai): cjk-typography.md body rule aligns with text-rules/tool/skill
Codex stop-hook #17: after fix #16 made add_body_text_v0 always use
Inter for CJK body, one source still allowed the alternative:
cjk-typography.md:16 said "Body: 'Inter' (system CJK fallback) or
'Noto Sans SC'". Every other authority in the repo says body=Inter
unconditionally:

  - text-rules.md (text section of get_design_prompt): body='Inter'
  - skills/phases/planning/decomposition.md:45: "body='Inter'"
  - packages/pen-mcp/src/tools/add-body-text-v0.ts: always 'Inter'
  - skills/phases/generation/elements.md: "Inter everywhere"
  - role-definitions.md:88: "body-text: lineHeight=1.5 (CJK: 1.6)"
    (no font override)

cjk-typography's "or Noto Sans SC" was the lone dissenter — an AI
reading the domain skill would see a contradictory option that no
other skill or tool supports. Remove the alternative so the repo is
single-voiced.

Also clarify the heading vs body split in the last two bullets: the
script-specific Noto rule is HEADING-only; body is Inter + CJK
lineHeight/letterSpacing. Cross-reference the other authorities so
a future editor knows which rule sources must stay in sync.

253/253 tests pass (pen-mcp + pen-ai-skills). format green.
2026-04-19 20:55:05 +08:00
Fini f7ce47fa0d fix(mcp): add_body_text_v0 uses Inter for ALL scripts (end CJK-rule conflict)
Codex stop-hook #16: CJK guidance was internally contradictory across
repo skills and the handler. Three sources disagreed:

  - text-rules.md (design-prompt TEXT_RULES): "body='Inter'" — unqualified
  - cjk-typography.md: body is "'Inter' (system CJK fallback) OR
    'Noto Sans SC'" — permissive
  - My previous add_body_text_v0 (e24c7fc): body was mapped per-script
    to Noto Sans SC / JP / KR — this combination is NOT authorized
    by any repo skill (cjk-typography only allows Inter or SC; never
    lists JP/KR for body)

Authoritative rule: text-rules.md. Body is Inter regardless of
script. Inter has system CJK fallback at render time so a single
body face serves all scripts. ONLY headings dispatch to
script-specific Noto faces (Noto Sans SC for Chinese / JP for
Japanese / KR for Korean) — that's add_heading_v0's job; body
doesn't need the same split.

Fix:
- add_body_text_v0 handler: fontFamily always 'Inter'; script
  detection is now used ONLY to decide lineHeight (1.5 Latin /
  1.6 CJK) + letterSpacing (undefined Latin / 0 CJK)
- tool description: rewritten to explicitly note "body ALWAYS Inter"
  and "only headings dispatch to Noto faces"
- elements.md usage examples: all 4 body examples now show Inter
  output with only lineHeight varying by script; added inline
  comment clarifying the text-rules.md derivation
- elements.md decision-tree item 15: reworded to "Inter everywhere"
- add-body-text-v0.test.ts: per-script tests now assert fontFamily
  ===Inter across zh/jp/ko + kanji+hiragana + mixed content
- d0 parity snapshot regenerated (description changes reach
  pre-D0 definitions; snapshot works as designed, catches drift
  and is updated deliberately)

169/169 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
2026-04-19 20:48:27 +08:00
Fini 97388716c1 fix(ai): AI-facing descriptions reflect per-script CJK font dispatch
Codex stop-hook #15: previous fix (91b9054) updated the handlers to
dispatch by script (Chinese → SC / Japanese → JP / Korean → KR),
but four AI-consumable surfaces still said "all CJK → Noto Sans SC":

- add_body_text_v0 tool description: "Chinese/Japanese/Korean content
  gets fontFamily=Noto Sans SC + lineHeight=1.6"
- add_heading_v0 tool description: listed only Latin presets; no
  mention of CJK handling at all (silently stale)
- elements.md skill usage example: "// auto Noto Sans SC + 1.6" for a
  Chinese string but no example showing JP/KR getting their own faces
- elements.md decision tree line 14-15: just said "CJK detection
  (correct fontFamily)" — generic enough that drift was invisible

Fix:
- body_text description rewritten to enumerate the 4-way script
  dispatch explicitly (SC/JP/KR/Inter), note JP precedence for
  hiragana/katakana + kanji mixes, and document the letterSpacing=0
  invariant
- heading description expanded to list both Latin and CJK preset
  tables plus the script-specific font mapping with the explicit
  "NEVER use SC for JP/KR" admonition lifted from text-rules.md
- elements.md gets 2 new usage examples (JP, KR) showing the
  different Noto face selection so the AI reading the skill sees
  non-SC CJK fonts in practice, not just in prose
- decision-tree lines for heading/body now name SC/JP/KR explicitly
- d0-parity-spike snapshot regenerated (get_design_prompt enum + tool
  definitions have changed descriptions)

169/169 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
2026-04-19 20:36:16 +08:00
Fini 620ffe57e3 fix(mcp): script-specific CJK fonts for heading + body_text (JP/KR)
Codex stop-hook #14: previous fix (8ca2cb9) mapped ALL CJK content to
'Noto Sans SC'. text-rules.md spec (and memory
project_pencil_optimization) requires script-specific fonts:

  Chinese  → Noto Sans SC
  Japanese → Noto Sans JP
  Korean   → Noto Sans KR

Using SC for JP/KR has reasonable Unicode coverage but violates the
explicit font contract. Each Noto face ships with script-native
punctuation + glyph variants that the dedicated face renders
correctly.

Fix: extract script detection + font mapping into shared helpers in
element-tool-helpers.ts so add_heading_v0 and add_body_text_v0 stay
in sync:

  detectCjkScript(s): 'chinese' | 'japanese' | 'korean' | null
    Detection order:
      1. Hiragana (U+3040-309F) / Katakana (U+30A0-30FF) → Japanese
         (these scripts are UNIQUE to Japanese even when mixed with
         Han ideographs — a heading like "今日は" is Japanese despite
         having kanji, because hiragana "は" disambiguates)
      2. Hangul Syllables (U+AC00-D7AF) → Korean
      3. CJK Unified Ideographs / Symbols → Chinese (Simplified default)
      4. Otherwise null

  cjkFontFamily(script): 'Noto Sans SC'|'Noto Sans JP'|'Noto Sans KR'|undefined

Apply to both add_heading_v0 (CJK_BASE preset table + per-script
fontFamily injection) and add_body_text_v0 (fontFamily = cjkFont ??
'Inter'; letterSpacing = 0 only when CJK).

Tests: 3 new per-script font assertions in each tool's test file
(Japanese → Noto Sans JP, Korean → Noto Sans KR, plus the kanji
+hiragana disambiguation edge case). 169/169 pen-mcp suite pass.
format + tsc green. Bundle rebuilt.
2026-04-19 20:32:18 +08:00
Fini 303c2a3b5a fix(mcp): add_heading_v0 CJK content gets CJK-specific typography
Codex stop-hook #13: heading presets hardcoded Latin typography
(lineHeight 1.0/1.1/1.2/1.25, display letterSpacing -0.5) and violated
three documented CJK rules when called with Chinese/Japanese/Korean
content:

  1. memory project_pencil_optimization: "CJK headings 1.3-1.4 (NOT
     1.1-1.2 like Latin)"
  2. text-rules.md: "CJK letterSpacing: 0, NEVER negative. Negative
     letterSpacing causes CJK character overlap."
  3. text-rules.md: "CJK font selection: heading=Noto Sans SC /
     Noto Sans JP / Noto Sans KR. NEVER Space Grotesk or Manrope —
     they have no CJK glyphs."

Fix: same auto-CJK detection used by add_body_text_v0 (regex scan for
\u3000-\u303f \u3040-\u309f \u30a0-\u30ff \u4e00-\u9fff \uac00-\ud7af).
CJK content selects a separate preset table:
  display 48/700/1.3 Noto Sans SC  (was 48/700/1.0/-0.5 Latin)
  h1      32/700/1.3 Noto Sans SC  (was 32/700/1.1)
  h2      24/600/1.35 Noto Sans SC (was 24/600/1.2)
  h3      20/600/1.4 Noto Sans SC  (was 20/600/1.25)
All CJK presets drop letterSpacing entirely (never negative, never
overridden from theme). Latin presets unchanged.

Tests: 6 new CJK cases (zh/jp/ko/mixed/Latin-still-works +
per-level lineHeight verification). Loop test uses unique filenames
per iteration to sidestep openDocument cache reuse.

13/13 heading tests pass. 160/160 pen-mcp overall. format + tsc green.
Bundle rebuilt.
2026-04-19 20:26:34 +08:00
Fini 78a8a8f81e feat(mcp): add text_button + heading + body_text (12 → 15 element tools)
Three text-primitive tools, each encoding a documented Pencil-demo or
memory-noted non-Claude failure mode.

- add_text_button_v0: padding-based button (padding=[12,20],
  cornerRadius=8, fit_content × 2, horizontal, centered). Pencil demo
  pattern — height auto-derives from padding, no explicit height.
  Optional leading icon at 16px. Narrow: single md preset (no size
  enum). Label + optional icon = 1-2 children.

- add_heading_v0: typographic heading with 4-level preset enum
  (display / h1 / h2 / h3; default h2). Each preset fixes fontSize
  / fontWeight / lineHeight / optional letterSpacing per memory
  data (display=48/700/1.0/-0.5, h1=32/700/1.1, h2=24/600/1.2,
  h3=20/600/1.25). Single text node output — the enum only changes
  typography, not structure ("应拆尽拆" compliant). Prevents the
  "default 1.5 lineHeight makes multi-word headings stack tight"
  failure mode.

- add_body_text_v0: body text with AUTO CJK detection via regex
  scan (/[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff
  \uac00-\ud7af]/). CJK → fontFamily='Noto Sans SC' + lineHeight 1.6
  + letterSpacing 0 (memory: NEVER Space Grotesk/Manrope for CJK).
  Latin → Inter + 1.5 + no letterSpacing override. Always sets
  width=fill_container + textGrowth=fixed-width (intended for
  vertical-layout parents per the documented rule). Mixed
  content triggers CJK.

Tests: 18 new unit (4 + 7 + 7). CJK detection tested across Chinese /
Japanese / Korean / mixed. 160/160 pen-mcp suite green.

Contract test's ELEMENT_TOOL_NAMES updated 12 → 15. elements.md skill
gets a new "Text + button primitives" category in the decision tree
(items 13-15) + PREFER phrases + usage examples; regression test
dynamically derives expected names from registry so no stale-drift.

MCP live smoke (/tmp/claude/mcp-test-phase-1c-tools.ts): ListTools =
55, element tools = 15; display heading gets correct typography
preset; Latin body gets Inter+1.5; CJK body gets Noto Sans SC+1.6+0
letterSpacing.

format + tsc green. Bundle rebuilt.
2026-04-19 20:17:16 +08:00
Fini b3a84525b5 fix(ai): purge hardcoded element-tool counts from description + test
Codex stop-hook #12: previous fix (e05ca79) introduced its own rot —

- Test "names EVERY production element tool" asserted
  elementTools.length >= 12. A hardcoded count is exactly what the
  whole regression suite is trying to prevent. If someone removes a
  tool, the count drops to 11 and the test keeps passing (because
  >=12 is a floor, not a target).

- get_design_prompt description said "12 tools covering rows[..]/
  containers[..]/atoms[..]". "12 tools" is stale the moment we add
  or remove a tool. The category names (rows/containers/atoms) are
  also hardcoded — if we ship a new category, the description is
  misleading.

Fix:
- Test: replace `>=12` with `>0`. Assertion is now: "registry has
  at least one element tool AND every element tool is mentioned in
  the elements skill." No hardcoded count.
- Description: replace the "12 tools covering [..]" enumeration
  with "N-tool element-tool family reference — decision tree,
  PREFER/FALLBACK rules, composition pattern; the section itself
  enumerates the current tools." Specific names + counts live in
  elements.md skill where the regression test keeps them in sync
  with the registry.
- Add regression test asserting the description does NOT match
  /\d+\s+tools?\s+(covering|in|across)/i — catches any future
  reintroduction of a hardcoded count.

d0 snapshot updated to reflect the new description.

142/142 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
2026-04-19 20:09:03 +08:00
Fini 12761e743e fix(ai): refresh AI-facing integration for all 12 element tools
Codex stop-hook #11: the last 3 tools (divider / badge / avatar) were
registered in MCP + tested in unit tests, but the AI-facing integration
layer was stale:

- elements.md skill still listed only 9 tools (missing divider / badge
  / avatar from decision tree, PREFER phrases, usage examples, and
  role registry)
- get_design_prompt tool description still enumerated the ORIGINAL 5
  element tools ("add_card_row_v0 / add_metric_row_v0 / …")
- Both are read directly by external MCP clients (Claude Code / Codex
  / Gemini CLI / Cursor) to decide which tool to pick — stale content
  means the AI never learns the newer tools exist.

Fix:
- elements.md: add divider / badge / avatar to decision tree (new
  "Atoms" category after rows + containers), PREFER phrases list,
  usage example block, role guarantee list. Frontmatter description
  updated to "12 tools" with category breakdown. Budget bumped
  1500 → 1800 tokens to accommodate the 3 new sections.
- design-routes.ts: get_design_prompt description rewritten to
  describe the element-tool family as "12 tools covering rows
  [card/metric/nav_chip/stat_grid], containers [bottom_nav/
  top_nav_bar/section_header/icon_button/activity_ring], atoms
  [divider/badge/avatar]" — generic categorization plus named
  examples, not a stale list that rots on every addition.
- d0-parity-spike snapshot updated (get_design_prompt definition
  changed — intentional).

Regression tests to prevent future drift:
- "names EVERY production element tool": derives the expected list
  dynamically from DESIGN_TOOL_DEFINITIONS, so any future element
  tool added without updating elements.md trips the test. Was
  previously hard-coded to 5 tool names.
- "description has no stale element-tool references": any
  `add_*_v0` name appearing in get_design_prompt's description
  must correspond to an actually-registered tool.

142/142 pen-mcp tests pass (was 141; +1 stale-guard for description).
format + tsc green. Bundle rebuilt.
2026-04-19 20:03:24 +08:00
Fini ae00fd6e87 feat(mcp): add divider + badge + avatar element tools (9 → 12)
Three low-risk single-/double-node tools completing the first
"应拆尽拆" batch. ListTools now 52 (40 baseline + 12 element).

- add_divider_v0: hairline rectangle (horizontal default: fill_container
  width, height=1; vertical swaps axes). Memory-documented pattern
  (Pencil reverse engineering): "Dividers: rectangle(h=1,
  fill_container) or directional stroke". Ships colorless.

- add_badge_v0: short pill / tag (cornerRadius=999, padding=[4,10],
  font 11/600). Forces the documented constraint (overflow.md):
  CJK ≤8 chars / Latin ≤16 chars — longer labels should not be badges.

- add_avatar_v0: circular avatar with optional centered initial. Same
  frame+cornerRadius=size/2+flex-centering pattern as activity_ring —
  NEVER the ellipse+sibling text anti-pattern (layout.md §RING /
  CIRCLE WITH CENTER CONTENT). Initial font auto-scales (size × 0.4,
  floored at 12 for tiny avatars).

Tests: 16 new unit (6 divider + 4 badge + 6 avatar). contract test
updated from 9 → 12 tool names. 141/141 pen-mcp suite passes.

MCP live smoke (/tmp/claude/mcp-test-phase-1b-tools.ts): ListTools
= 52, all 3 new tools callable with expected node counts
(divider=1, badge=2, avatar-with-initial=2), avatar cornerRadius
verified as size/2.

format + tsc green. Bundle rebuilt.
2026-04-19 19:51:05 +08:00
Fini 3b677b9f3b fix(mcp): wrap section header title in vertical container for correct wrap height
Codex stop-hook #10: previous fix (72087f8) used
width=fill_container + textGrowth=fixed-width on the title text but
placed it DIRECTLY inside the horizontal section-header frame. Per
packages/pen-ai-skills/skills/phases/generation/overflow.md:
"Text in VERTICAL layout: width=fill_container + textGrowth=fixed-width.
In horizontal: width=fit_content." The layout engine only measures
wrap-grown height when text follows the vertical-layout rule. In our
horizontal header, a wrapped title rendered visually but did not
propagate its extra height to header.height=fit_content, so the
header stayed at single-line height and the NEXT sibling in the
parent vertical layout overlapped the wrapped lines.

Fix: introduce a fill_container + vertical + fit_content title
container that wraps the text node. The text now follows the
documented rule (fill_container + fixed-width in vertical parent),
wrap height is measured correctly, and header.height=fit_content
grows to match. Following content gets pushed down by the wrapped
height as intended.

Regression test: add a long-title case that asserts
  - header.height === 'fit_content'
  - title-container.layout === 'vertical'
  - title-text.width === 'fill_container'
  - title-text.textGrowth === 'fixed-width'
  - header has NO space_between AND NO fixed height
Existing id-count test updated: 5 → 6 nodes (added title container).

7/7 section-header tests pass; 125/125 pen-mcp suite green. format +
tsc clean. Bundle rebuilt.
2026-04-19 19:45:40 +08:00
Fini 0e5dda4b9e fix(mcp): add_section_header_v0 title+action cannot overlap on long titles
Codex stop-hook #9: previous implementation used
justifyContent:space_between with both children at natural
(fit_content) width. Long titles push past the action's starting
position and overlap it — flexbox space_between distributes REMAINING
space but does not clip items that collectively exceed the container
width.

Fix: title now takes width:fill_container + textGrowth:fixed-width so
it consumes all remaining horizontal space and wraps vertically when
too long (the header's height:fit_content accommodates wrapping).
Action stays width:fit_content on the right. Header adds gap:16 for
guaranteed visual breathing room. justifyContent:space_between is
intentionally removed — fill_container on one sibling makes it
redundant and the removal is what prevents the overlap.

Regression test: seed a header with a deliberately long title + short
action and assert title has fill_container/fixed-width, action has
fit_content, and header has NO justifyContent. 7/7 section-header
tests pass. 124/124 pen-mcp suite green.
2026-04-19 19:37:32 +08:00
Fini 75467676b1 feat(mcp): expand element tool family to 9 (add 4 layout-pattern tools)
Step 7 continues: 5 → 9 narrow element tools, each solving one
documented anti-pattern from pen-ai-skills prompt knowledge.

- add_stat_grid_v0: NON-scrolling 2-5 metric grid. Each cell uses
  width=fill_container so the renderer auto-distributes space.
  Directly solves the documented activity-rings overflow bug in
  packages/pen-ai-skills/skills/phases/generation/layout.md
  (three fixed 100px rings in a 279px inner card silently clip the
  third; with fill_container the third fits by construction).
  Different from add_metric_row_v0 which is a scrolling wrapper
  with fixed-px items.

- add_section_header_v0: heading + optional trailing action ("See
  all" / "View more"). Forces horizontal space_between alignItems=
  center so action stays flush-right. Common dashboard pattern that
  non-Claude models frequently vertical-stack instead.

- add_top_nav_bar_v0: mobile app bar. Leading icon (back/menu) +
  centered title + trailing icon (search/more). Dual of
  add_bottom_nav_v0. Empty slots become 44×44 spacers so the title
  stays visually centered even with asymmetric icons.

- add_icon_button_v0: 44×44 icon-only button with flex centering.
  Explicitly NOT layout=none (the documented anti-pattern in
  memory: layout=none + nested absolute-positioned children renders
  unreliably under Skia). Forces layout=horizontal + justifyContent
  /alignItems=center.

All four follow the established element-tool pattern:
- Sugar route via insertElementTree (parent_id pre-check, DSL
  escape pre-check, snapshot rollback, post-check parent-location
  verification)
- assignIdsRecursively on the built subtree
- No union types in schema; narrow required params; optional
  sizing/styling via follow-up batch_design U-op

Tests: 22 new unit (5+6+6+5 per tool) + element-tools-contract
updated to assert all 9 tools satisfy §4 invariants. All 124
pen-mcp tests pass.

skill file `elements.md` expanded from 5 → 9 tools (decision tree
updated, PREFER phrases mapped per tool, usage examples added,
role list extended). Registry regenerated from 44 → 44 skills
(same count, elements.md edit in place).

MCP live e2e smoke via StdioClientTransport confirms ListTools now
returns 49 tools (40 baseline + 9 element), all 4 new tools
callable, structural invariants verified on live output
(stat-grid cell.width=fill_container; icon-button layout \!= none;
3-slot top-nav structure; section-header action group).

format + tsc green. Bundle rebuilt.
2026-04-19 19:28:41 +08:00
Fini bff94b2ada fix(ai): gate elements skill behind hasMcpTools flag (no prompt pollution)
Codex stop-hook #8: elements.md had `trigger: null` which makes
resolveSkills('generation', ...) unconditionally load its 1500-token
N-tool reference into every generation prompt — including the
embedded orchestrator in apps/web/src/services/ai that emits
single-shot JSON and CANNOT call MCP tools. The content was dead
weight there (orchestrator-sub-agent.ts:333 / ai-prompts.ts:132 both
build generation prompt via resolveSkills without any tool-use path).

Fix: trigger: { flags: [hasMcpTools] }. Skill only auto-loads when
caller explicitly declares MCP tools are available. No existing caller
sets this flag, so the embedded orchestrator prompt is now clean
again.

External MCP clients (Claude Code / Codex / Gemini CLI / Cursor) still
get the content via get_design_prompt(section='elements'), which uses
getSkillByName direct lookup and bypasses resolveSkills' trigger
filter. That contract is preserved.

Added explanatory HTML comment in the skill header so future editors
understand the gating + opt-in rule.

Tests: 4 new cases in design-prompt-elements.test.ts verify:
- getSkillByName returns skill regardless of flags (direct lookup)
- resolveSkills('generation') WITHOUT flag → elements excluded
- resolveSkills('generation') WITH {hasMcpTools:true} → elements included
- buildDesignPrompt('elements') works regardless of flag (bypass path)

14/14 design-prompt-elements tests pass; 186/186 across pen-mcp +
pen-ai-skills. format + tsc green. Bundle rebuilt.
2026-04-19 19:06:35 +08:00
Fini 43a6b7b86f feat(ai): add 'elements' section to get_design_prompt + new skill
Step 4 of N-tool element design. Teach external MCP clients (Claude
Code / Codex / Gemini CLI / Cursor) when to reach for a narrow
element tool vs fall through to batch_design.

New skill: packages/pen-ai-skills/skills/phases/generation/elements.md
- Decision tree mapping item shape → tool (card_row / metric_row /
  nav_chip_row / bottom_nav / activity_ring)
- PREFER vs STILL-USE-batch_design conditions with concrete spec
  phrases ("horizontal scrolling cards", "KPI cards", "bottom nav")
- Minimal usage examples for all 5 tools
- Composition pattern: build section via batch_design → insert row
  with parent_id → post-hoc style via batch_design U-op
- Invariants enumeration (wrapper / id assignment / role set) so AI
  knows what NOT to rebuild
- Failure-mode guidance: if tool throws, inspect message and switch
  strategy rather than retry
- Priority 14 / budget 1500 tokens

design-prompt.ts: register 'elements' in SECTION_NAME_MAP,
PromptSection type, SECTION_MAP dispatch.
design-routes.ts: add 'elements' to get_design_prompt.section enum
+ updated description.

_generated/skill-registry.ts regenerates at vite build time (43 → 44
skills; gitignored, not committed).

Tests:
- 10 new unit tests (design-prompt-elements.test.ts) cover
  registration, content invariants, decision-tree presence, fallback
  teaching, composition pattern, invariants naming, unknown-section
  fallback behavior
- d0-parity-spike snapshot updated (get_design_prompt definition
  intentionally changed)

MCP live e2e smoke via StdioClientTransport confirms ListTools enum
shows 11 values including 'elements', CallTool returns 4122 char
content, all 5 tools named, all structural assertions pass.

98/98 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
2026-04-19 18:52:00 +08:00
Fini b62e1e9bd9 fix(mcp): rollback preserves snapshot bytes exactly (no re-serialize)
Codex stop-hook #7: previous rollback (a0ace58) called
saveDocument(fp, restoredDoc) to trigger the live-canvas re-sync, but
saveDocument runs JSON.stringify(doc) with no indent and writes the
result to disk. That overwrites the snapshot we just restored via
writeFile — losing the user's original formatting (indentation, key
order, number representation, trailing newlines) and replacing it
with pen-mcp's canonical serialization.

Fix:
- Export pushLiveDocument from document-manager so callers can trigger
  a live-canvas push WITHOUT re-writing disk
- Rollback path: writeFile(snapshot) + invalidateCache + openDocument +
  pushLiveDocument. The file is restored byte-exact; pushLiveDocument
  reads the parsed doc from cache and pushes it, no disk rewrite

Semantically equivalent to before for the live-canvas side (same push
with same restored doc) but now preserves the snapshot bytes on disk
exactly. Important for users who hand-edit .op files or whose original
document was produced by a different serializer (pen-core, Electron
save-as, etc.).

88/88 pen-mcp tests pass (existing "file bytes unchanged" assertions
already verify this; they simply now rely on a path that doesn't
re-serialize). format + tsc green.
2026-04-19 18:39:11 +08:00
Fini b191b35a3c fix(mcp): rollback re-syncs live canvas after restoring file
Codex stop-hook #6: saveDocument is DUAL-WRITE for file-backed paths
(document-manager.ts:411) — it writes to disk AND calls
pushLiveDocument. handleBatchDesign uses saveDocument so a bad insert
gets pushed to the live canvas before our post-check runs. Prior
rollback (6dfa88b) only restored the file via writeFile, leaving the
live-canvas renderer showing the bad insert until the next refresh.

Fix: after writeFile + invalidateCache, call saveDocument(fp, restoredDoc)
in the rollback path. saveDocument will re-write the file (no-op, same
content) AND call pushLiveDocument again with the restored doc,
bringing the live canvas back in sync with disk. If the live-sync step
fails, re-throw with a diagnostic noting the live canvas may be stale
but disk is authoritative.

Wrap the re-sync in try/catch so a transient live-sync failure doesn't
swallow the original insert-failure reason. Disk is the source of
truth and is already restored when we hit this path.

If no sync URL is configured (common in unit-test contexts),
pushLiveDocument is a no-op — so this is safe in all scenarios.

Live-canvas-only paths (filePath='live://canvas') remain
non-rollback-able because pushLiveDocument is a one-way push without
a history mechanism; the pre-check is the primary defense there.

88/88 pen-mcp tests pass (rollback behavior unchanged at the
observable-test level since our tests don't configure a live sync
URL; the re-sync is correct by construction given saveDocument's
published semantics). format + tsc green.
2026-04-19 18:33:12 +08:00
Fini 36852d77ff fix(mcp): pre-check + rollback so wrong-parent never persists on disk
Codex stop-hook #5: previous post-insert check (5311313) throws on
wrong-parent detection, but by the time we detect it batch_design has
ALREADY written the bad insert to disk / pushed to live canvas. The
throw is a post-facto notification, not a guarantee.

Three-layer defense:

1. **Pre-check (NEW, prevents disk write)**: simulateDslParentResolve
   mirrors batch-design.ts:resolveRef (`raw.replace(/^"|"$/g, '')`, no
   JSON-unescape). If JSON.stringify(parent_id) → quote-strip ≠
   parent_id, the DSL round-trip is lossy → throw before ever calling
   handleBatchDesign. Covers ids containing `"` or `\` (the cases
   Codex #3 and #4 flagged).

2. **Snapshot + rollback (NEW, for file-backed docs)**: before calling
   handleBatchDesign we readFile the target into a snapshot. If any
   post-check (errors / missing insert / wrong parent) trips, we
   writeFile the snapshot back + invalidateCache, then throw.
   File-backed docs are now atomic at the element-tool boundary.

3. **Post-check (kept as defense-in-depth)**: node-in-tree +
   parent-location verification. With pre-check in place these should
   be unreachable, but they guard against future DSL-parser changes
   or silent-failure modes we haven't anticipated.

Known limitation: live canvas cannot be atomically rolled back because
pushLiveDocument is a one-way push. The rollback path skips live with
a clear error message noting the insert may still be visible until
next refresh. Pre-check is the primary defense for live; post-check
rollback-best-effort applies only to filePath'd calls.

Tests updated: existing "weird quoted parent_id" and
"A\"B decoy collision" regression tests now hit pre-check (cleaner
error, same invariant: throw + file unchanged). 88/88 pen-mcp pass.
format + tsc green.
2026-04-19 18:27:58 +08:00
Fini ea5057380c fix(mcp): post-insert must verify parent location, not just presence
Codex stop-hook #4: the prior post-insert check (e797584) verified that
the inserted nodeId is findable anywhere in the tree, but did not
verify it landed under the REQUESTED parent. Silent wrong-parent
inserts are still possible if batch_design's resolveRef quote-strip
produces a literal that matches a DIFFERENT node than the one
ensureParentExists validated.

Concrete example: doc has nodes with ids `A"B` (3 chars: A, ", B) and
`A\"B` (4 chars: A, \, ", B). User passes parent_id='A"B'.
ensureParentExists does a raw-string `===` match and finds the first
node. insertElementTree does JSON.stringify → `"A\"B"` in DSL source.
batch_design's parseInsertArgs → resolveRef → `/^"|"$/g` quote-strip
→ literal `A\"B` (4 chars). insertNodeInTree matches the DECOY and
inserts under it. Prior post-check: node is in tree → passes. Actual:
wrong parent.

Fix: when args.parent_id is provided, post-check also walks
findParentInTree(postChildren, insertedId) and confirms the resolved
parent id === the requested parent_id. Mismatch → throw with clear
diagnostic.

Regression test: element-tools-contract.test.ts adds the A"B / A\"B
decoy scenario and asserts the tool throws (not silent success).

88/88 pen-mcp tests pass (+1 new wrong-parent guard). format + tsc
green. Bundle rebuilt.
2026-04-19 18:12:43 +08:00
Fini 5847b50d19 fix(mcp): post-insert verification closes silent no-op path
Codex stop-hook #3: JSON.stringify(parent_id) in insertElementTree
doesn't prevent silent no-op. batch_design's resolveRef
(batch-design.ts:442-445) only does `/^"|"$/g` quote-stripping — it does
NOT JSON-unescape — so a parent_id containing `"` or `\` round-trips
as a different literal than what's actually stored in the document.
insertNodeInTree finds no match, silently returns the original tree,
and batch_design still reports success (pushes to results, bumps
nodeCount, errors stays empty).

ensureParentExists catches the "parent genuinely doesn't exist" case
via pre-check using the raw string for direct equality. It does NOT
catch the escape-mismatch case (which passes pre-check with the raw
string but fails in the DSL path).

Fix: insertElementTree now re-reads the document after handleBatchDesign
and confirms the inserted nodeId is actually findable via
findNodeInTree. If not present → throw with diagnostic info about
parent_id and pageId. This is the single source of truth for "did the
insert land?" regardless of what DSL parser subtleties occur
downstream.

Regression test: element-tools-contract.test.ts adds a case that seeds
a document with parent id containing a literal `"` (manually crafted —
nanoid never produces such ids, but imported/migrated docs might),
then calls handleAddBottomNavV0 with that parent_id. Previously the
tool would return success with an orphan record; now it throws and the
on-disk document is verified unchanged.

87/87 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
2026-04-19 18:06:59 +08:00
Fini 70592537fd fix(mcp): Codex review #2 P0 fixes — tighten element-tool safety + contract
Four cleanup items from the second independent Codex review, all
scoped to element tools + their contract tests.

P0.1 insertElementTree safety boundary (element-tool-helpers.ts:112-124):
- parent_id now JSON.stringify'd so ids containing quotes/backslashes
  cannot escape DSL quoting and inject additional batch_design ops
- per-item batch_design errors are re-thrown with a concise summary
  instead of silently surfaced via result.errors (N-tool single-insert
  semantics require loud failure; callers can't distinguish "inserted
  with errors" from "didn't insert at all")

P0.2 add_activity_ring_v0 narrowing (add-activity-ring-v0.ts):
- strip ring_color / text_size / text_weight params (were 6 business
  params → now 3). Typography/color hardcoded (#000000, 16, 700) per
  spec D6 Style-Guide-orthogonal invariant. Callers override via a
  follow-up batch_design U-op, same as card/metric/nav_chip rows
- route schema + description + test updated accordingly

P0.3 test hardening:
- metric_row + nav_chip_row gain "every node has a unique non-empty id"
  regression test matching the other 3 element tools (id coverage parity)
- all 5 element tools gain "throws on bogus parent_id AND leaves file
  untouched" side-effect assertion. Previous tests only checked the
  throw (mirroring helper impl); now tests verify the actual invariant
  that matters to the user — the document doesn't get partially
  mutated when validation rejects

P0.4 schemaVersion input property (design-routes.ts + new
element-tools-contract.test.ts):
- every element tool (5 production + 1 spike) now declares schemaVersion
  as an input property (enum: ['1.0'], optional) per spec §4.2. Was
  previously only mentioned in description strings, which clients
  couldn't introspect
- new element-tools-contract.test.ts cross-validates §4.1 (additive),
  §4.2 (schemaVersion property), §4.4 (filePath/parent_id accepted),
  and "应拆尽拆" invariant (no children_type/variant unions leaked
  back in)

86/86 pen-mcp tests pass (+6 new contract + 2 new id coverage + 5 new
side-effect). format + tsc green. Bundle rebuilt.

P1 items still open (server-side JSON Schema enforcement, role
registration for nav-chip-active/nav-item-active, live-canvas race
condition in ensureParentExists, saveDocument silent-failure path)
tracked separately.
2026-04-19 17:53:28 +08:00
Fini 160c112876 fix(ai): overflow.md no longer advertises icon as required for nav chips
Codex stop-hook review: the stale AI prompt in
packages/pen-ai-skills/skills/phases/generation/overflow.md still told
clients add_nav_chip_row_v0 items require both label + icon, even
though the tool now accepts label-only chips (fixed in 451474f).

Update the §HORIZONTAL SCROLL ROWS preferred-path bullet for
add_nav_chip_row_v0 to mark icon as optional and call out label-only
support ("All / Videos / Photos" text-only filter tags).
2026-04-19 17:16:10 +08:00
Fini 0794cf9ebb fix(mcp): add_nav_chip_row_v0 icon is optional (restore label-only support)
Codex stop-hook review: splitting add_scroll_row_v0 into narrow tools
regressed label-only chip rows. The original children_type='nav_item'
variant accepted { title } with no icon (text-only filter tags like
"All" / "Videos" / "Photos"), but the new add_nav_chip_row_v0 made icon
required, breaking that use case.

- tool handler: if item.icon absent, skip the icon_font child and emit
  just the text label. Mirrors the original buildNavItem behavior from
  packages/pen-mcp/src/tools/add-scroll-row-v0.ts (removed).
- route schema: items[].required changed from ['label', 'icon'] →
  ['label']; description notes label-only chips are supported.
- test: new "builds label-only chips when icon is omitted" case covering
  3 plain-label items (All / Videos / Photos) asserting each chip has
  exactly 1 text child, no icon_font. Existing icon-present test still
  covers the with-icon path.

78/78 pen-mcp suite passes (was 77 + 1 new). format + tsc green. Bundle
rebuilt.
2026-04-19 17:11:09 +08:00
Fini b4937900f8 refactor(mcp): split add_scroll_row_v0 into 3 narrow tools + extract helpers
Per "应拆尽拆" guidance: remove children_type union from scroll-row
family. Each narrow tool now has ≤5 simple params, no union types, and
a single output pattern — so the LLM never has to decide between
variants inside a tool.

Replaced `add_scroll_row_v0({children_type, items})` with:
- add_card_row_v0({items: {title, subtitle?, icon?}}) — 140×160 cards
- add_metric_row_v0({items: {label, value, icon?}}) — 120×100 tiles,
  value=28/700
- add_nav_chip_row_v0({items: {label, icon, active?}}) — 72 chips with
  active state

Shared wrapper + id assignment + parent check + DSL insertion logic
extracted into element-tool-helpers.ts:
- buildScrollWrapper() — the fill_container+clipContent outer + inner
  fit_content row (identical across all 3 row tools)
- assignIdsRecursively() — moved from duplicated copies in each tool
- insertElementTree() — centralizes the batch_design DSL shape

Also refactored add_bottom_nav_v0 + add_activity_ring_v0 to use the
shared helpers (DRY).

- overflow.md: §HORIZONTAL SCROLL ROWS preferred-path section now
  points at the 3 narrow tools with a decision rule (what's in each
  item → which tool) instead of add_scroll_row_v0+children_type param
- design-routes.ts: unregister add_scroll_row_v0, register 3 new
  tools each with precise inputSchema (label/value/title fields match
  the semantic role)
- Delete obsolete add-scroll-row-v0.ts + test + snapshot

Tests: 77/77 pass (was 86 before removing scroll-row tests). Each new
tool has 3-4 tests covering registration + structure + id coverage +
parent_id validation. Live MCP smoke confirmed 45 tools in ListTools
(40 baseline + 5 element) with all narrow tools functional.

format + tsc green.
2026-04-19 16:59:32 +08:00
Fini 6b0feb3ba5 fix(mcp): fail fast on invalid parent_id in element tools
Codex stop-hook review: add_bottom_nav_v0 (and siblings) advertised a
parent_id contract that could silently no-op. pen-core's
insertNodeInTree returns the original tree unchanged when parentId
doesn't match any node (tree-utils.ts:200-234), so batch_design's
downstream call produces a success-looking response {results, nodeCount}
with an orphaned node that never lands on disk.

- tools/element-tool-helpers.ts: new ensureParentExists() helper —
  loads the target doc via openDocument + resolveDocPath, checks
  getDocChildren(pageId) with findNodeInTree, throws a descriptive
  Error listing parent_id and pageId if missing
- Apply to all 3 element tools (add_scroll_row_v0 / add_bottom_nav_v0 /
  add_activity_ring_v0) at the top of each handler, before DSL
  construction. Null parent_id short-circuits (root insertion is OK)
- routes/design-routes.ts: fix misleading "Page id" description on
  add_bottom_nav_v0.parent_id → now matches siblings ("Target parent
  node id (must exist...)")
- Tests: 4 new (throws on bogus parent_id for each of 3 tools, +
  positive "inserts under valid parent" for add_scroll_row_v0). 86/86
  pen-mcp suite pass

Bundle rebuilt. format + tsc green.
2026-04-19 16:16:11 +08:00
Fini 0799ad4843 feat(mcp): add_bottom_nav_v0 + add_activity_ring_v0 element tools
Step C of N-tool element design: expand MVP from 1 to 3 tools, each
targeting a documented non-Claude failure mode with file:line evidence.

- add_bottom_nav_v0: bottom tab bar. Solves layout.md §NO FIXED-POSITION
  LAYOUT anti-pattern (empty spacer siblings after nav; bottom-nav is
  inline flow, not position:fixed). Schema: items[]+height; output:
  frame(role=bottom-tab-bar, width=fill_container, layout=horizontal,
  justifyContent=space_around) with per-tab icon+label and
  role=nav-item-active for current tab.

- add_activity_ring_v0: Apple-style progress ring with centered text.
  Solves layout.md §RING / CIRCLE WITH CENTER CONTENT anti-pattern
  (ellipse+sibling text stacks wrong; layout=none+absolute renders
  unreliably). Schema: size/thickness/ring_color/center_text +
  text_size/text_weight; output: frame(cornerRadius=size/2, stroke,
  fill=[], layout=horizontal, alignItems=center, justifyContent=center)
  with single text child.

Both tools follow D1=A Sugar route (internal handleBatchDesign call)
and assignIdsRecursively pattern from 9ba64b7.

Tests: 8 new (4 nav + 4 ring), 82/82 pen-mcp suite pass. format + tsc
green. Live MCP smoke test via StdioClientTransport confirmed ListTools
now 43 tools (40 baseline + 3 element), spike still hidden, both new
tools produce correct structure + unique node ids.
2026-04-19 16:05:15 +08:00
Fini b593ba5272 docs(ai): teach overflow.md to prefer add_scroll_row_v0 MCP tool
Step 3 (prompt path, MVP scope). Update §HORIZONTAL SCROLL ROWS to
split into two paths:

- Preferred (MCP tool): call add_scroll_row_v0 directly. External MCP
  clients (Claude Code / Codex / Gemini CLI / Cursor) see the tool in
  ListTools and the prompt now explicitly teaches WHEN to pick it
  over hand-building JSON
- Fallback (hand-built JSON): existing structure teaching, unchanged,
  used when MCP tool isn't available (embedded AI flow, JSON-only)

Embedded orchestrator integration (apps/web/src/services/ai) is out of
scope for this MVP step — it requires architectural work to flip from
one-shot JSON emission to MCP tool_use flow. External clients already
get full benefit from tool registration + this prompt update.

84/84 pen-ai-skills tests pass. format:check + tsc green.
2026-04-19 15:59:04 +08:00
Fini bc2a9a38b1 fix(mcp): assign ids to every node in add_scroll_row_v0 subtree
Codex stop-hook review: child nodes were saved without ids. batch_design
only assigns an id to the top-level inserted node — nested children
(inner row, cards, texts, icon_fonts) come through the DSL unchanged,
which breaks any later tree operation that resolves by id (update /
delete / move / post-processing / spatial index lookup).

Fix: assignIdsRecursively walks the wrapper subtree before serializing
to DSL and stamps every node with generateId(). batch_design's own
overwrite of the top-level id is harmless.

Test: 4 new id-coverage tests (one per children_type + uniqueness
across a 3-card tree). Total 17 tests in add-scroll-row-v0, 74 in
pen-mcp suite. format + tsc green.
2026-04-19 15:39:42 +08:00
Fini b8744fbda3 feat(mcp): add_scroll_row_v0 MVP element tool for non-Claude stability
Step 2 of N-tool element design (spec v0 §7.1 pinned to this impl).
First production element-level tool, replacing the batch_design generic DSL
for the specific pattern LLMs most commonly get wrong: horizontal scroll
rows of cards / metric tiles / nav items.

Tool fixes the structure at schema level:
- Outer wrapper: fill_container + clipContent=true + vertical layout
- Inner row: fit_content + horizontal + gap + padding=[0,20]
- Children: fixed numeric width per variant (card=140 / metric=120 / nav=72)

Exactly matches packages/pen-ai-skills/skills/phases/generation/overflow.md
§HORIZONTAL SCROLL ROWS. Sugar over handleBatchDesign (D1=A route).

- tools/add-scroll-row-v0.ts: 230-line builder with 3 children_type presets
- routes/design-routes.ts: register in production DESIGN_TOOL_DEFINITIONS
- __tests__/add-scroll-row-v0.test.ts: 13 tests covering wrapper invariants,
  per-type structure, icon/subtitle optionality, overrides, persistence,
  golden snapshot
- d0-parity-spike.test.ts: rewrite "names exactly" assertion to "pre-D0
  tools still present" so new production tools don't break baseline; scope
  snapshot to pre-D0 5 tools only

70/70 pen-mcp tests pass. format:check + tsc --noEmit both green.
2026-04-19 15:32:25 +08:00
Fini 955adc1277 fix(mcp): gate add_section_v0 behind OPENPENCIL_D0_SPIKE flag
Codex stop-hook review: spike-only tool should not be exposed to
external MCP clients (Claude Code / Codex / Gemini CLI) by default.

- routes/design-routes.ts: move add_section_v0 out of DESIGN_TOOL_DEFINITIONS
  into separate D0_SPIKE_TOOL_DEFINITIONS / D0_SPIKE_TOOL_NAMES /
  handleD0SpikeToolCall exports, matching DEBUG_TOOL_* pattern
- server.ts: conditionally merge D0_SPIKE_TOOL_DEFINITIONS when
  OPENPENCIL_D0_SPIKE=1, mirroring OPENPENCIL_DEBUG_TOOLS=1 gating
- tests: assert default DESIGN_TOOL_DEFINITIONS is unchanged (spike
  not leaked) and D0_SPIKE_TOOL_DEFINITIONS contains the spike tool
  (only reachable when flag set). Snapshot regenerated.

57/57 pen-mcp tests pass. format:check + tsc --noEmit + vitest all green.
2026-04-19 15:17:19 +08:00
Fini fe2c37c711 feat(mcp): add_section_v0 tool for N-tool parity spike
D0 验证 N-tool "additive-only" 假设:新增元素工具不得改变现有
ListTools 输出或 batch_design 行为。实现为 sugar over handleBatchDesign
(仅 title + layout 两参数),不抽任何共用 helper。

- tools/add-section-v0.ts: 30-line handler,直接调 handleBatchDesign
- routes/design-routes.ts: 注册 tool definition / name / dispatch switch
- __tests__/d0-parity-spike.test.ts: 8 tests
  * 固定 5 个现有 design tool 的完整 definition 为 snapshot
  * 验证 batch_design baseline fixture 行为不变
  * 验证新工具水平/垂直/磁盘持久化三种场景

Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §D0
Report: openpencil-docs/superpowers/notes/2026-04-19-d0-parity-spike-report.md

结论: D1 = A (Sugar) parity 假设成立。add_section_v0 仅作 spike 验证,
MVP 生产将切换至 add_scroll_row_v0(基于 prompt 证据选型)。
2026-04-19 15:04:19 +08:00
Fini 11f94106f6 chore(release): bump to v0.7.4 2026-04-17 19:20:19 +08:00
Fini b8b8272fd7 style(ai): oxfmt reflow agent-tool-executor.ts 2026-04-17 19:20:18 +08:00
Fini 549f244870 chore(agent): bump agent-native to v0.4.0 release 2026-04-17 19:04:15 +08:00
Fini 652174e0b5 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 d7f23b4ceb chore(agent): bump agent-native for Windows N-API symbol export 2026-04-17 00:20:57 +08:00
Fini 90254c1e87 chore(agent): bump agent-native for auto-detected MiniMax quirk 2026-04-17 00:18:49 +08:00
Fini 0b627ac9c4 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 c3215b55bb 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 1be9ec4a19 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 5904ca93e9 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