@zseven-w/pen-core 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/node-helpers.test.ts +21 -11
- package/src/__tests__/normalize-tree-layout.test.ts +12 -12
- package/src/__tests__/strip-redundant-section-fills.test.ts +52 -0
- package/src/index.ts +1 -1
- package/src/layout/engine.ts +7 -7
- package/src/layout/normalize-tree.ts +9 -9
- package/src/layout/strip-redundant-section-fills.ts +29 -1
- package/src/node-helpers.ts +22 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zseven-w/pen-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
|
|
5
5
|
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-core",
|
|
6
6
|
"bugs": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zseven-w/pen-types": "0.7.
|
|
33
|
+
"@zseven-w/pen-types": "0.7.4",
|
|
34
34
|
"nanoid": "^5.1.6",
|
|
35
35
|
"paper": "^0.12.18"
|
|
36
36
|
},
|
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import type { PenNode } from '@zseven-w/pen-types';
|
|
3
|
-
import {
|
|
3
|
+
import { isOverlayNode } from '../node-helpers';
|
|
4
4
|
|
|
5
|
-
describe('
|
|
6
|
-
it('returns true for
|
|
5
|
+
describe('isOverlayNode', () => {
|
|
6
|
+
it('returns true for overlay role', () => {
|
|
7
|
+
const node: PenNode = { id: '1', type: 'rectangle', role: 'overlay' };
|
|
8
|
+
expect(isOverlayNode(node)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns false for badge role (inline component, not overlay)', () => {
|
|
7
12
|
const node: PenNode = { id: '1', type: 'rectangle', role: 'badge' };
|
|
8
|
-
expect(
|
|
13
|
+
expect(isOverlayNode(node)).toBe(false);
|
|
9
14
|
});
|
|
10
15
|
|
|
11
|
-
it('returns
|
|
16
|
+
it('returns false for pill role (inline component, not overlay)', () => {
|
|
12
17
|
const node: PenNode = { id: '1', type: 'rectangle', role: 'pill' };
|
|
13
|
-
expect(
|
|
18
|
+
expect(isOverlayNode(node)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns false for tag role (inline component, not overlay)', () => {
|
|
22
|
+
const node: PenNode = { id: '1', type: 'rectangle', role: 'tag' };
|
|
23
|
+
expect(isOverlayNode(node)).toBe(false);
|
|
14
24
|
});
|
|
15
25
|
|
|
16
|
-
it('returns
|
|
26
|
+
it('returns false for name containing "badge" without explicit role', () => {
|
|
17
27
|
const node: PenNode = { id: '1', type: 'rectangle', name: 'Notification Badge' };
|
|
18
|
-
expect(
|
|
28
|
+
expect(isOverlayNode(node)).toBe(false);
|
|
19
29
|
});
|
|
20
30
|
|
|
21
|
-
it('returns
|
|
31
|
+
it('returns false for name containing "overlay" without explicit role', () => {
|
|
22
32
|
const node: PenNode = { id: '1', type: 'rectangle', name: 'Image Overlay' };
|
|
23
|
-
expect(
|
|
33
|
+
expect(isOverlayNode(node)).toBe(false);
|
|
24
34
|
});
|
|
25
35
|
|
|
26
36
|
it('returns false for regular nodes', () => {
|
|
27
37
|
const node: PenNode = { id: '1', type: 'rectangle', name: 'Button' };
|
|
28
|
-
expect(
|
|
38
|
+
expect(isOverlayNode(node)).toBe(false);
|
|
29
39
|
});
|
|
30
40
|
});
|
|
@@ -126,11 +126,11 @@ describe('normalizeTreeLayout', () => {
|
|
|
126
126
|
expect('y' in kids[1]).toBe(false);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
it('keeps x and y on
|
|
130
|
-
const
|
|
131
|
-
id: '
|
|
129
|
+
it('keeps x and y on overlay children', () => {
|
|
130
|
+
const overlay: PenNode = {
|
|
131
|
+
id: 'overlay1',
|
|
132
132
|
type: 'rectangle',
|
|
133
|
-
role: '
|
|
133
|
+
role: 'overlay',
|
|
134
134
|
width: 8,
|
|
135
135
|
height: 8,
|
|
136
136
|
x: 40,
|
|
@@ -138,7 +138,7 @@ describe('normalizeTreeLayout', () => {
|
|
|
138
138
|
} as PenNode;
|
|
139
139
|
const node = frame({
|
|
140
140
|
layout: 'horizontal',
|
|
141
|
-
children: [rect('a', { x: 5, y: 5 }),
|
|
141
|
+
children: [rect('a', { x: 5, y: 5 }), overlay],
|
|
142
142
|
});
|
|
143
143
|
normalizeTreeLayout(node);
|
|
144
144
|
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
@@ -178,25 +178,25 @@ describe('normalizeTreeLayout', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it('overlay-only children do not block the vertical fallback', () => {
|
|
181
|
-
// A container whose only positioned children are overlays
|
|
182
|
-
//
|
|
181
|
+
// A container whose only positioned children are overlays is still
|
|
182
|
+
// considered "model forgot layout" — the overlays retain their
|
|
183
183
|
// coordinates while the base frame gets a vertical layout for the rest.
|
|
184
|
-
const
|
|
185
|
-
id: '
|
|
184
|
+
const overlay: PenNode = {
|
|
185
|
+
id: 'overlay1',
|
|
186
186
|
type: 'rectangle',
|
|
187
|
-
role: '
|
|
187
|
+
role: 'overlay',
|
|
188
188
|
width: 8,
|
|
189
189
|
height: 8,
|
|
190
190
|
x: 40,
|
|
191
191
|
y: 0,
|
|
192
192
|
} as PenNode;
|
|
193
193
|
const node = frame({
|
|
194
|
-
children: [rect('a'), rect('b'),
|
|
194
|
+
children: [rect('a'), rect('b'), overlay],
|
|
195
195
|
});
|
|
196
196
|
normalizeTreeLayout(node);
|
|
197
197
|
expect((node as PenNode & { layout?: string }).layout).toBe('vertical');
|
|
198
198
|
const kids = (node as PenNode & { children: PenNode[] }).children;
|
|
199
|
-
//
|
|
199
|
+
// overlay still carries its absolute coordinates
|
|
200
200
|
expect(kids[2].x).toBe(40);
|
|
201
201
|
expect(kids[2].y).toBe(0);
|
|
202
202
|
});
|
|
@@ -235,6 +235,58 @@ describe('stripRedundantSectionFills', () => {
|
|
|
235
235
|
expect((deepInner as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
|
|
236
236
|
});
|
|
237
237
|
|
|
238
|
+
it('strips stale #FFFFFF section fills on a dark root (legacy alternation residue)', () => {
|
|
239
|
+
// Regression guard for 2026-04-15: the legacy fixSectionAlternation
|
|
240
|
+
// painted #FFFFFF / #F8FAFC on unfilled section runs regardless of
|
|
241
|
+
// page theme. After the alternation skip for dark parents landed,
|
|
242
|
+
// stale docs (and weak-model hedges) still carry those whites.
|
|
243
|
+
// stripRedundantSectionFills must now clean them up.
|
|
244
|
+
const section1 = frame({
|
|
245
|
+
id: 's1',
|
|
246
|
+
name: 'Hero',
|
|
247
|
+
role: 'hero',
|
|
248
|
+
fill: solidFill('#FFFFFF'),
|
|
249
|
+
});
|
|
250
|
+
const section2 = frame({
|
|
251
|
+
id: 's2',
|
|
252
|
+
name: 'Stats',
|
|
253
|
+
role: 'stats-section',
|
|
254
|
+
fill: solidFill('#F8FAFC'),
|
|
255
|
+
});
|
|
256
|
+
const section3 = frame({
|
|
257
|
+
id: 's3',
|
|
258
|
+
name: 'CTA',
|
|
259
|
+
role: 'cta-section',
|
|
260
|
+
fill: solidFill('#FFFFFF'),
|
|
261
|
+
});
|
|
262
|
+
const root = frame({
|
|
263
|
+
id: 'root',
|
|
264
|
+
fill: solidFill('#111111'),
|
|
265
|
+
children: [section1, section2, section3],
|
|
266
|
+
});
|
|
267
|
+
const changed = stripRedundantSectionFills(root);
|
|
268
|
+
expect(changed).toBe(true);
|
|
269
|
+
expect((section1 as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
270
|
+
expect((section2 as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
271
|
+
expect((section3 as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('strips a safe-light hedge even when the root has no fill', () => {
|
|
275
|
+
// Mirror of the existing "root frame without a fill" dark case: a
|
|
276
|
+
// bare #FFFFFF on a section root is almost certainly the sub-agent
|
|
277
|
+
// hedging against a missing background spec, not a deliberate choice.
|
|
278
|
+
const section = frame({
|
|
279
|
+
id: 'sec',
|
|
280
|
+
fill: solidFill('#FAFAFA'),
|
|
281
|
+
});
|
|
282
|
+
const root = frame({
|
|
283
|
+
id: 'root',
|
|
284
|
+
children: [section],
|
|
285
|
+
});
|
|
286
|
+
stripRedundantSectionFills(root);
|
|
287
|
+
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
|
|
288
|
+
});
|
|
289
|
+
|
|
238
290
|
it('reproduces the M2.7 health-tracker case', () => {
|
|
239
291
|
// Direct repro of the actual failure: root #1a1a2e, six section roots
|
|
240
292
|
// all hardcoded #0A0A0A, including one real card. The six section
|
package/src/index.ts
CHANGED
|
@@ -143,7 +143,7 @@ export { type BooleanOpType, canBooleanOp, executeBooleanOp } from './boolean-op
|
|
|
143
143
|
export { cssFontFamily } from './font-utils.js';
|
|
144
144
|
|
|
145
145
|
// Node helpers
|
|
146
|
-
export { isBadgeOverlayNode, sanitizeName } from './node-helpers.js';
|
|
146
|
+
export { isOverlayNode, isBadgeOverlayNode, sanitizeName } from './node-helpers.js';
|
|
147
147
|
|
|
148
148
|
// Design-MD parser
|
|
149
149
|
export {
|
package/src/layout/engine.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PenNode, ContainerProps, SizingBehavior, Padding } from '@zseven-w/pen-types';
|
|
2
2
|
export type { Padding } from '@zseven-w/pen-types';
|
|
3
|
-
import {
|
|
3
|
+
import { isOverlayNode } from '../node-helpers.js';
|
|
4
4
|
import {
|
|
5
5
|
parseSizing,
|
|
6
6
|
estimateTextWidth,
|
|
@@ -119,7 +119,7 @@ export function inferLayout(node: PenNode): 'horizontal' | undefined {
|
|
|
119
119
|
export function fitContentWidth(node: PenNode, parentAvail?: number): number {
|
|
120
120
|
if (!('children' in node) || !node.children?.length) return 0;
|
|
121
121
|
const visibleChildren = node.children.filter(
|
|
122
|
-
(child) => isNodeVisible(child) && !
|
|
122
|
+
(child) => isNodeVisible(child) && !isOverlayNode(child),
|
|
123
123
|
);
|
|
124
124
|
if (visibleChildren.length === 0) return 0;
|
|
125
125
|
const c = node as PenNode & ContainerProps;
|
|
@@ -147,7 +147,7 @@ export function fitContentWidth(node: PenNode, parentAvail?: number): number {
|
|
|
147
147
|
export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
|
|
148
148
|
if (!('children' in node) || !node.children?.length) return 0;
|
|
149
149
|
const visibleChildren = node.children.filter(
|
|
150
|
-
(child) => isNodeVisible(child) && !
|
|
150
|
+
(child) => isNodeVisible(child) && !isOverlayNode(child),
|
|
151
151
|
);
|
|
152
152
|
if (visibleChildren.length === 0) return 0;
|
|
153
153
|
const c = node as PenNode & ContainerProps;
|
|
@@ -254,8 +254,8 @@ export function computeLayoutPositions(parent: PenNode, children: PenNode[]): Pe
|
|
|
254
254
|
const layout = c.layout || inferLayout(parent);
|
|
255
255
|
if (!layout || layout === 'none') return visibleChildren;
|
|
256
256
|
|
|
257
|
-
const
|
|
258
|
-
const layoutChildren = visibleChildren.filter((ch) => !
|
|
257
|
+
const overlayNodes = visibleChildren.filter(isOverlayNode);
|
|
258
|
+
const layoutChildren = visibleChildren.filter((ch) => !isOverlayNode(ch));
|
|
259
259
|
if (layoutChildren.length === 0) return visibleChildren;
|
|
260
260
|
|
|
261
261
|
const pW = parseSizing(c.width);
|
|
@@ -399,8 +399,8 @@ export function computeLayoutPositions(parent: PenNode, children: PenNode[]): Pe
|
|
|
399
399
|
return out as unknown as PenNode;
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
-
if (
|
|
403
|
-
return [...
|
|
402
|
+
if (overlayNodes.length > 0) {
|
|
403
|
+
return [...overlayNodes, ...positioned];
|
|
404
404
|
}
|
|
405
405
|
return positioned;
|
|
406
406
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PenNode, ContainerProps } from '@zseven-w/pen-types';
|
|
2
|
-
import {
|
|
2
|
+
import { isOverlayNode } from '../node-helpers.js';
|
|
3
3
|
import { inferLayout } from './engine.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -17,8 +17,8 @@ import { inferLayout } from './engine.js';
|
|
|
17
17
|
* images with floating overlays, etc.) and we leave it alone.
|
|
18
18
|
*
|
|
19
19
|
* 2. When a frame has an active layout (`vertical` or `horizontal`), strip
|
|
20
|
-
* `x`/`y` from every non-overlay child. Overlay children (
|
|
21
|
-
*
|
|
20
|
+
* `x`/`y` from every non-overlay child. Overlay children (opt-in via
|
|
21
|
+
* `role: 'overlay'`) keep their absolute coordinates.
|
|
22
22
|
*
|
|
23
23
|
* Used as a post-generation pass after an AI model produces a subtree. It
|
|
24
24
|
* corrects two common model mistakes:
|
|
@@ -58,7 +58,7 @@ export function normalizeTreeLayout(node: PenNode): void {
|
|
|
58
58
|
// (2) Strip x/y from non-overlay children of active-layout frames.
|
|
59
59
|
if (c.layout === 'vertical' || c.layout === 'horizontal') {
|
|
60
60
|
for (const child of children) {
|
|
61
|
-
if (!
|
|
61
|
+
if (!isOverlayNode(child)) {
|
|
62
62
|
if ('x' in child) delete (child as { x?: number }).x;
|
|
63
63
|
if ('y' in child) delete (child as { y?: number }).y;
|
|
64
64
|
}
|
|
@@ -105,9 +105,9 @@ export function normalizeTreeLayout(node: PenNode): void {
|
|
|
105
105
|
* still get the vertical fallback, because those are typically
|
|
106
106
|
* content stacks where verticalization is the right call.
|
|
107
107
|
*
|
|
108
|
-
* Overlay nodes (
|
|
109
|
-
* from the count — they legitimately carry x/y inside
|
|
110
|
-
* and shouldn't tip the heuristic.
|
|
108
|
+
* Overlay nodes (opt-in via `role: 'overlay'`, detected by `isOverlayNode`)
|
|
109
|
+
* are excluded from the count — they legitimately carry x/y inside
|
|
110
|
+
* auto-layout frames and shouldn't tip the heuristic.
|
|
111
111
|
*
|
|
112
112
|
* This is intentionally conservative: it accepts a few false negatives
|
|
113
113
|
* (some genuinely vertical all-shape stacks will be left un-normalized,
|
|
@@ -120,7 +120,7 @@ const COMPOSITION_PRIMITIVE_TYPES = new Set(['frame', 'ellipse', 'path']);
|
|
|
120
120
|
function hasAbsolutePositionedChild(children: PenNode[]): boolean {
|
|
121
121
|
// Signal 1: explicit numeric x/y on any non-overlay child.
|
|
122
122
|
for (const child of children) {
|
|
123
|
-
if (
|
|
123
|
+
if (isOverlayNode(child)) continue;
|
|
124
124
|
const c = child as PenNode & { x?: number; y?: number };
|
|
125
125
|
if (typeof c.x === 'number' || typeof c.y === 'number') return true;
|
|
126
126
|
}
|
|
@@ -130,7 +130,7 @@ function hasAbsolutePositionedChild(children: PenNode[]): boolean {
|
|
|
130
130
|
let nonOverlayCount = 0;
|
|
131
131
|
let primitiveCount = 0;
|
|
132
132
|
for (const child of children) {
|
|
133
|
-
if (
|
|
133
|
+
if (isOverlayNode(child)) continue;
|
|
134
134
|
nonOverlayCount++;
|
|
135
135
|
if (COMPOSITION_PRIMITIVE_TYPES.has(child.type)) primitiveCount++;
|
|
136
136
|
}
|
|
@@ -126,13 +126,41 @@ const SAFE_DARK_HEXES = new Set([
|
|
|
126
126
|
'#202020',
|
|
127
127
|
]);
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Light-mode counterparts of SAFE_DARK_HEXES. Two sources produce these
|
|
131
|
+
* on section roots:
|
|
132
|
+
* 1. Weaker sub-agents that hedge with pure white / near-white instead
|
|
133
|
+
* of the role-correct transparent fill.
|
|
134
|
+
* 2. The legacy `fixSectionAlternation` post-pass that painted an
|
|
135
|
+
* #FFFFFF / #F8FAFC ladder on every run of ≥3 unfilled sections.
|
|
136
|
+
* On dark pages that ladder leaves visible white strips; if a doc
|
|
137
|
+
* with those stale fills is re-opened or re-rendered after the
|
|
138
|
+
* alternation skip landed, the fills are still there and the skip
|
|
139
|
+
* alone won't remove them.
|
|
140
|
+
* Treat these the same as safe-dark hedges: strip on any section root.
|
|
141
|
+
*/
|
|
142
|
+
const SAFE_LIGHT_HEXES = new Set([
|
|
143
|
+
'#ffffff',
|
|
144
|
+
'#fff',
|
|
145
|
+
'#fefefe',
|
|
146
|
+
'#fdfdfd',
|
|
147
|
+
'#fcfcfc',
|
|
148
|
+
'#fafafa',
|
|
149
|
+
'#f9fafb',
|
|
150
|
+
'#f8f8f8',
|
|
151
|
+
'#f8fafc',
|
|
152
|
+
'#f5f5f5',
|
|
153
|
+
'#f4f4f5',
|
|
154
|
+
'#f3f4f6',
|
|
155
|
+
]);
|
|
156
|
+
|
|
129
157
|
function shouldStripFill(childFill: string, rootFill: string | null): boolean {
|
|
130
158
|
const childKey = normalizeHex(childFill);
|
|
131
159
|
if (rootFill) {
|
|
132
160
|
const rootKey = normalizeHex(rootFill);
|
|
133
161
|
if (childKey === rootKey) return true;
|
|
134
162
|
}
|
|
135
|
-
return SAFE_DARK_HEXES.has(childKey);
|
|
163
|
+
return SAFE_DARK_HEXES.has(childKey) || SAFE_LIGHT_HEXES.has(childKey);
|
|
136
164
|
}
|
|
137
165
|
|
|
138
166
|
function normalizeHex(color: string): string {
|
package/src/node-helpers.ts
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import type { PenNode } from '@zseven-w/pen-types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Check if a node is
|
|
5
|
-
*
|
|
4
|
+
* Check if a node is an overlay that uses absolute positioning and should
|
|
5
|
+
* not participate in layout flow.
|
|
6
|
+
*
|
|
7
|
+
* Requires explicit `role: 'overlay'`. Earlier versions matched on
|
|
8
|
+
* `role: 'badge' | 'pill' | 'tag'` plus name regexes, but those are
|
|
9
|
+
* inline-component markers in this repo (see `role-resolver.ts` and
|
|
10
|
+
* `strip-redundant-section-fills.ts` PROTECTED_ROLES) — pulling them out
|
|
11
|
+
* of layout flow collapsed them to (0,0) of their parent and stacked
|
|
12
|
+
* them on top of siblings. `role: 'overlay'` is the dedicated opt-in for
|
|
13
|
+
* notification dots and true floating decorations.
|
|
6
14
|
*/
|
|
7
|
-
export function
|
|
15
|
+
export function isOverlayNode(node: PenNode): boolean {
|
|
8
16
|
if ('role' in node) {
|
|
9
17
|
const role = (node as { role?: string }).role;
|
|
10
|
-
if (role === '
|
|
18
|
+
if (role === 'overlay') return true;
|
|
11
19
|
}
|
|
12
|
-
|
|
13
|
-
return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name);
|
|
20
|
+
return false;
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @deprecated Renamed to `isOverlayNode`. Semantics also tightened:
|
|
25
|
+
* this alias no longer returns true for `role: 'badge' | 'pill' | 'tag'`
|
|
26
|
+
* (those are inline-component roles in this repo and should flow in
|
|
27
|
+
* auto-layout, not float). Use `isOverlayNode` and mark true floating
|
|
28
|
+
* decorations with `role: 'overlay'`.
|
|
29
|
+
*/
|
|
30
|
+
export const isBadgeOverlayNode = isOverlayNode;
|
|
31
|
+
|
|
16
32
|
/**
|
|
17
33
|
* Convert a name string to PascalCase.
|
|
18
34
|
* Strips non-alphanumeric characters and joins words.
|