openpencil/packages/pen-core
Fini ec4fe44368 feat(pen-core): strip nested card-style decoration on inner frames
User-reported 2026-05-11 "Popular Restaurants" — inspecting the live
canvas via batch_get showed the LLM built each row as
`role:card` (outer) carrying stroke + cornerRadius:16 + 2-shadow
elevation, then nested an inner `Card Info` frame ALSO with
`role:card`, cornerRadius:12, and the SAME 2-shadow stack for the
right-hand text column. The doubled decoration rendered as a
visible "border" / box-in-box that the user called out as the
N-tools being "死板" — element-builders deterministically emit
their own card decoration without knowing they're being nested.

New post-pass `stripNestedCardDecoration` walks the page tree and,
for each non-protected frame:
  - if the frame has stroke AND any frame ancestor has stroke   → strip stroke
  - if the frame has cornerRadius > 0 AND any frame ancestor
    has cornerRadius > 0                                        → strip cornerRadius
  - if the frame has shadow AND any frame ancestor has shadow   → strip effects

Each decoration type is checked independently so e.g. a card inside
a shadow-only ancestor still keeps its cornerRadius. Fills are NOT
touched — stripRedundantSectionFills already handles fill heuristics
and a child fill may be an intentional surface change (dark accent
strip inside a white card).

KEEP_DECORATION_ROLES exempts elements that legitimately carry their
own affordance even when nested in a card: button, chip, search-bar,
input, badge, avatar, switch, etc. Those keep their click-target
visual whether or not the parent is decorated.

Wired in apps/web design-canvas-ops.ts at both finalize sites,
running AFTER stripRedundantSectionFills so the fill pass gets first
crack and this pass cleans up the leftover stroke/cornerRadius/
shadow stack.

Tests: 8 cases — basic strip, partial strip (only matched types),
top-level decoration preserved, protected-role exemption, fills
untouched, deep nesting, asymmetric cornerRadius arrays, no-op
return value.
2026-05-11 00:17:02 +08:00
..
src feat(pen-core): strip nested card-style decoration on inner frames 2026-05-11 00:17:02 +08:00
CLAUDE.md fix(canvas): require explicit role:'overlay' for layout-flow escape hatch 2026-04-16 21:07:40 +08:00
LICENSE V0.7.1 (#102) 2026-04-13 21:30:23 +08:00
package.json Merge origin/v0.8.0 into feat/rust-ification 2026-05-03 21:00:00 +08:00
README.md V0.7.1 (#102) 2026-04-13 21:30:23 +08:00
tsconfig.json V0.5.0 (#67) 2026-03-22 09:44:04 +08:00

@zseven-w/pen-core

Core document operations for OpenPencil — tree manipulation, layout engine, design variables, boolean path operations, 3-way merge, and more.

Install

npm install @zseven-w/pen-core
# or
bun add @zseven-w/pen-core

Overview

pen-core is the foundation layer of the OpenPencil stack. It provides pure functions for every document operation — tree CRUD, auto-layout computation, variable resolution, SVG path booleans, and document normalization. All operations are immutable (structural sharing) and framework-free.

Features

Document Tree Operations

Create, query, and mutate the document tree:

import {
  createEmptyDocument,
  findNodeInTree,
  findParentInTree,
  insertNodeInTree,
  removeNodeFromTree,
  updateNodeInTree,
  flattenNodes,
  isDescendantOf,
  getNodeBounds,
} from '@zseven-w/pen-core';

const doc = createEmptyDocument();
const node = findNodeInTree(doc.children, 'node-id');
const parent = findParentInTree(doc.children, 'node-id');
const flat = flattenNodes(doc.children); // all nodes in flat array

Node Cloning

Deep clone nodes with optional ID regeneration:

import { deepCloneNode, cloneNodeWithNewIds, cloneNodesWithNewIds } from '@zseven-w/pen-core';

const clone = deepCloneNode(node); // preserve IDs
const fresh = cloneNodeWithNewIds(node); // new IDs for all descendants
const batch = cloneNodesWithNewIds([a, b, c]); // batch clone

Multi-Page Support

import {
  getActivePage,
  getActivePageChildren,
  setActivePageChildren,
  migrateToPages,
  ensureDocumentNodeIds,
} from '@zseven-w/pen-core';

// Migrate a single-page document to multi-page format
const multiPageDoc = migrateToPages(doc);

Layout Engine

Flexbox-like auto-layout computation supporting fill_container, fit_content, gap, padding, and alignment:

import {
  computeLayoutPositions,
  inferLayout,
  fitContentWidth,
  fitContentHeight,
  resolvePadding,
  getNodeWidth,
  getNodeHeight,
  isNodeVisible,
} from '@zseven-w/pen-core';

// Compute absolute positions for all children in a layout container
const positions = computeLayoutPositions(frame, frame.children);

// Infer layout direction from child arrangement
const layout = inferLayout(children); // 'horizontal' | 'vertical' | 'none'

Text Measurement

Estimate text dimensions for layout without a browser DOM:

import {
  estimateTextWidth,
  estimateTextWidthPrecise,
  estimateTextHeight,
  resolveTextContent,
  hasCjkText,
  defaultLineHeight,
} from '@zseven-w/pen-core';

const width = estimateTextWidth('Hello World', 16, 400); // font size, weight
const height = estimateTextHeight(textNode, containerWidth);
const isCjk = hasCjkText('こんにちは'); // true

Design Variables

Resolve $variable references against document variables and theme axes:

import {
  isVariableRef,
  resolveVariableRef,
  resolveNodeForCanvas,
  replaceVariableRefsInTree,
  getDefaultTheme,
} from '@zseven-w/pen-core';

isVariableRef('$primary'); // true

// Resolve all $refs in a node tree for rendering
const resolved = resolveNodeForCanvas(node, doc.variables, doc.themes);

// Rename $old-name → $new-name across entire tree
replaceVariableRefsInTree(children, 'old-name', 'new-name');

Boolean Path Operations

Union, subtract, intersect, and exclude paths via Paper.js:

import { executeBooleanOp, canBooleanOp, BooleanOpType } from '@zseven-w/pen-core';

if (canBooleanOp(selectedNodes)) {
  const result = executeBooleanOp(selectedNodes, BooleanOpType.Union);
}

Document Normalization

Sanitize and fix documents from external sources (Figma imports, AI generation):

import { normalizePenDocument } from '@zseven-w/pen-core';

const cleaned = normalizePenDocument(rawDoc);
// Fixes: fill type "color" → "solid", gradient stop position → offset,
// sizing strings, padding arrays. Preserves $variable refs.

Layout Normalization

Repair AI-generated layout issues:

import {
  normalizeTreeLayout,
  unwrapFakePhoneMockups,
  stripRedundantSectionFills,
  normalizeStrokeFillSchema,
} from '@zseven-w/pen-core';

// Infer missing layout modes, strip child x/y in layout containers
normalizeTreeLayout(rootNode);

3-Way Document Merge

Diff and merge document trees for collaborative editing and git integration:

import { diffDocuments, mergeDocuments } from '@zseven-w/pen-core';

// One-direction diff: base → current
const patches = diffDocuments(base, current);
// patches: NodePatch[] — add, remove, modify, move

// 3-way merge: base + ours + theirs
const result = mergeDocuments(base, ours, theirs);
// result: { document, conflicts }

Design.md Parser

Parse and generate design specification documents:

import {
  parseDesignMd,
  generateDesignMd,
  designMdColorsToVariables,
  extractDesignMdFromDocument,
} from '@zseven-w/pen-core';

Path Anchors

Convert between anchor point representation and SVG path data:

import {
  anchorsToPathData,
  pathDataToAnchors,
  getPathBoundsFromAnchors,
  inferPathAnchorPointType,
} from '@zseven-w/pen-core';

API Reference

Category Key Functions
Tree CRUD findNodeInTree, insertNodeInTree, removeNodeFromTree, updateNodeInTree, flattenNodes
Cloning deepCloneNode, cloneNodeWithNewIds, cloneNodesWithNewIds
Pages getActivePage, migrateToPages, ensureDocumentNodeIds
Layout computeLayoutPositions, inferLayout, fitContentWidth, fitContentHeight
Text estimateTextWidth, estimateTextHeight, hasCjkText
Variables resolveVariableRef, resolveNodeForCanvas, replaceVariableRefsInTree
Boolean executeBooleanOp, canBooleanOp
Normalize normalizePenDocument, normalizeTreeLayout
Merge diffDocuments, mergeDocuments
IDs generateId

License

MIT