@zseven-w/pen-core 0.7.3 → 0.7.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zseven-w/pen-core",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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.3",
33
+ "@zseven-w/pen-types": "0.7.5",
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 { isBadgeOverlayNode } from '../node-helpers';
3
+ import { isOverlayNode } from '../node-helpers';
4
4
 
5
- describe('isBadgeOverlayNode', () => {
6
- it('returns true for badge role', () => {
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(isBadgeOverlayNode(node)).toBe(true);
13
+ expect(isOverlayNode(node)).toBe(false);
9
14
  });
10
15
 
11
- it('returns true for pill role', () => {
16
+ it('returns false for pill role (inline component, not overlay)', () => {
12
17
  const node: PenNode = { id: '1', type: 'rectangle', role: 'pill' };
13
- expect(isBadgeOverlayNode(node)).toBe(true);
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 true for name containing "badge"', () => {
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(isBadgeOverlayNode(node)).toBe(true);
28
+ expect(isOverlayNode(node)).toBe(false);
19
29
  });
20
30
 
21
- it('returns true for name containing "overlay"', () => {
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(isBadgeOverlayNode(node)).toBe(true);
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(isBadgeOverlayNode(node)).toBe(false);
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 badge overlay children', () => {
130
- const badge: PenNode = {
131
- id: 'badge1',
129
+ it('keeps x and y on overlay children', () => {
130
+ const overlay: PenNode = {
131
+ id: 'overlay1',
132
132
  type: 'rectangle',
133
- role: 'badge',
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 }), badge],
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 (badges) is
182
- // still considered "model forgot layout" — the overlays retain their
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 badge: PenNode = {
185
- id: 'badge1',
184
+ const overlay: PenNode = {
185
+ id: 'overlay1',
186
186
  type: 'rectangle',
187
- role: 'badge',
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'), badge],
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
- // badge still carries its absolute coordinates
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
  });
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 {
@@ -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 { isBadgeOverlayNode } from '../node-helpers.js';
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) && !isBadgeOverlayNode(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) && !isBadgeOverlayNode(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 badgeNodes = visibleChildren.filter(isBadgeOverlayNode);
258
- const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(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 (badgeNodes.length > 0) {
403
- return [...badgeNodes, ...positioned];
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 { isBadgeOverlayNode } from '../node-helpers.js';
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 (badges, pills,
21
- * tags, floating indicators) keep their absolute coordinates.
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 (!isBadgeOverlayNode(child)) {
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 (badges/pills/tags via `isBadgeOverlayNode`) are excluded
109
- * from the count — they legitimately carry x/y inside auto-layout frames
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 (isBadgeOverlayNode(child)) continue;
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 (isBadgeOverlayNode(child)) continue;
133
+ if (isOverlayNode(child)) continue;
134
134
  nonOverlayCount++;
135
135
  if (COMPOSITION_PRIMITIVE_TYPES.has(child.type)) primitiveCount++;
136
136
  }
@@ -1,18 +1,34 @@
1
1
  import type { PenNode } from '@zseven-w/pen-types';
2
2
 
3
3
  /**
4
- * Check if a node is a badge/overlay that uses absolute positioning
5
- * and should not participate in layout flow.
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 isBadgeOverlayNode(node: PenNode): boolean {
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 === 'badge' || role === 'pill' || role === 'tag') return true;
18
+ if (role === 'overlay') return true;
11
19
  }
12
- const name = (node.name ?? '').toLowerCase();
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.