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.
|
||
|---|---|---|
| .. | ||
| src | ||
| CLAUDE.md | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
@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 |