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.
This commit is contained in:
parent
f93a9437aa
commit
ec4fe44368
|
|
@ -15,6 +15,7 @@ export {
|
|||
normalizeTreeLayout,
|
||||
unwrapFakePhoneMockups,
|
||||
stripRedundantSectionFills,
|
||||
stripNestedCardDecoration,
|
||||
clipCardImageCorners,
|
||||
injectMissingNavSurfaceFill,
|
||||
expandOverflowingFixedHeightCards,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
normalizeTreeLayout,
|
||||
unwrapFakePhoneMockups,
|
||||
stripRedundantSectionFills,
|
||||
stripNestedCardDecoration,
|
||||
clipCardImageCorners,
|
||||
injectMissingNavSurfaceFill,
|
||||
expandOverflowingFixedHeightCards,
|
||||
|
|
@ -420,6 +421,12 @@ function finalizePageRootAfterApply(): void {
|
|||
if (stripRedundantSectionFills(node)) {
|
||||
anyChanged = true;
|
||||
}
|
||||
// Run AFTER stripRedundantSectionFills so the fill heuristic gets
|
||||
// first crack at fill decisions; then this pass cleans up nested
|
||||
// card-style decoration (stroke / cornerRadius / shadow stack).
|
||||
if (stripNestedCardDecoration(node)) {
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
if (anyChanged) forcePageResync();
|
||||
}
|
||||
|
|
@ -755,6 +762,14 @@ export function applyPostStreamingTreeHeuristics(rootNodeId: string): void {
|
|||
const parentOfRoot = useDocumentStore.getState().getParentOf(rootNodeId);
|
||||
const pageRoot = parentOfRoot && parentOfRoot.type === 'frame' ? parentOfRoot : freshRoot;
|
||||
stripRedundantSectionFills(pageRoot);
|
||||
// 2026-05-11 user-reported "Popular Restaurants" card-within-a-card —
|
||||
// outer `role: card` carried stroke + cornerRadius + 2-shadow elevation
|
||||
// and the inner `Card Info` frame ALSO had role: card with the same
|
||||
// shadow stack and own cornerRadius. The doubled decoration rendered
|
||||
// as a visible "border" / box-in-box. This pass strips the redundant
|
||||
// inner stroke / cornerRadius / shadow when an ancestor already
|
||||
// carries the same kind of decoration.
|
||||
stripNestedCardDecoration(pageRoot);
|
||||
// Add a default surface fill on top-level navigation frames the
|
||||
// sub-agent left transparent — without this the bottom nav floats on
|
||||
// the cream root background with no surface to anchor it. See
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type { PenNode } from '@zseven-w/pen-types';
|
||||
import { stripNestedCardDecoration } from '../layout/strip-nested-card-decoration';
|
||||
|
||||
const frame = (
|
||||
props: Partial<PenNode> & { children?: PenNode[]; role?: string; cornerRadius?: unknown },
|
||||
): PenNode =>
|
||||
({
|
||||
id: 'f',
|
||||
type: 'frame',
|
||||
...props,
|
||||
}) as PenNode;
|
||||
|
||||
const shadow = () => [
|
||||
{ type: 'shadow' as const, offsetX: 0, offsetY: 1, blur: 3, spread: 0, color: '#0000001A' },
|
||||
];
|
||||
const stroke = () => ({ thickness: 1, fill: [{ type: 'solid' as const, color: '#E2E8F0' }] });
|
||||
|
||||
describe('stripNestedCardDecoration', () => {
|
||||
// 2026-05-11 user-reported "Popular Restaurants" — outer `role: card`
|
||||
// had stroke + cornerRadius + 2-shadow stack, then nested an inner
|
||||
// `Card Info` frame ALSO with role:card and the same decoration.
|
||||
// Visual result: card-within-a-card "extra border".
|
||||
|
||||
it('strips stroke + cornerRadius + shadow on inner card-roled frame inside an outer card', () => {
|
||||
const inner = frame({
|
||||
id: 'inner',
|
||||
role: 'card',
|
||||
cornerRadius: 12,
|
||||
stroke: stroke(),
|
||||
effects: shadow(),
|
||||
} as never);
|
||||
const outer = frame({
|
||||
id: 'outer',
|
||||
role: 'card',
|
||||
cornerRadius: 16,
|
||||
stroke: stroke(),
|
||||
effects: shadow(),
|
||||
children: [inner],
|
||||
} as never);
|
||||
|
||||
const changed = stripNestedCardDecoration(outer);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect((inner as PenNode & { stroke?: unknown }).stroke).toBeUndefined();
|
||||
expect((inner as PenNode & { cornerRadius?: unknown }).cornerRadius).toBeUndefined();
|
||||
expect((inner as PenNode & { effects?: unknown }).effects).toBeUndefined();
|
||||
// Outer keeps its decoration.
|
||||
expect((outer as PenNode & { stroke?: unknown }).stroke).toBeDefined();
|
||||
expect((outer as PenNode & { cornerRadius?: unknown }).cornerRadius).toBe(16);
|
||||
expect((outer as PenNode & { effects?: unknown }).effects).toBeDefined();
|
||||
});
|
||||
|
||||
it('only strips the decoration types the ancestor actually has', () => {
|
||||
// Outer has shadow only; inner has shadow + cornerRadius. Inner's
|
||||
// shadow gets stripped (ancestor also has it) but cornerRadius
|
||||
// stays (ancestor has none → not redundant).
|
||||
const inner = frame({
|
||||
id: 'inner',
|
||||
role: 'card',
|
||||
cornerRadius: 12,
|
||||
effects: shadow(),
|
||||
} as never);
|
||||
const outer = frame({
|
||||
id: 'outer',
|
||||
role: 'card',
|
||||
effects: shadow(),
|
||||
children: [inner],
|
||||
} as never);
|
||||
|
||||
stripNestedCardDecoration(outer);
|
||||
|
||||
expect((inner as PenNode & { effects?: unknown }).effects).toBeUndefined();
|
||||
expect((inner as PenNode & { cornerRadius?: unknown }).cornerRadius).toBe(12);
|
||||
});
|
||||
|
||||
it('does NOT strip top-level decoration (no decorated ancestor)', () => {
|
||||
const card = frame({
|
||||
id: 'card',
|
||||
role: 'card',
|
||||
cornerRadius: 16,
|
||||
stroke: stroke(),
|
||||
effects: shadow(),
|
||||
children: [],
|
||||
} as never);
|
||||
|
||||
const changed = stripNestedCardDecoration(card);
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect((card as PenNode & { stroke?: unknown }).stroke).toBeDefined();
|
||||
expect((card as PenNode & { cornerRadius?: unknown }).cornerRadius).toBe(16);
|
||||
expect((card as PenNode & { effects?: unknown }).effects).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves decoration on protected roles (button / chip / search-bar / input / badge / avatar)', () => {
|
||||
// A button or input inside a card legitimately keeps its own
|
||||
// affordance — the user expects the click target to be visually
|
||||
// distinct from the card surface.
|
||||
const protectedCases = ['button', 'chip', 'search-bar', 'input', 'badge', 'avatar', 'tag'];
|
||||
for (const role of protectedCases) {
|
||||
const inner = frame({
|
||||
id: `inner-${role}`,
|
||||
role,
|
||||
cornerRadius: 8,
|
||||
stroke: stroke(),
|
||||
} as never);
|
||||
const outer = frame({
|
||||
id: 'outer',
|
||||
role: 'card',
|
||||
stroke: stroke(),
|
||||
cornerRadius: 16,
|
||||
children: [inner],
|
||||
} as never);
|
||||
|
||||
stripNestedCardDecoration(outer);
|
||||
|
||||
expect(
|
||||
(inner as PenNode & { stroke?: unknown }).stroke,
|
||||
`${role} should retain its stroke`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(inner as PenNode & { cornerRadius?: unknown }).cornerRadius,
|
||||
`${role} should retain its cornerRadius`,
|
||||
).toBe(8);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT touch fills (stripRedundantSectionFills handles that)', () => {
|
||||
const inner = frame({
|
||||
id: 'inner',
|
||||
role: 'card',
|
||||
stroke: stroke(),
|
||||
fill: [{ type: 'solid' as const, color: '#FFFFFF' }],
|
||||
} as never);
|
||||
const outer = frame({
|
||||
id: 'outer',
|
||||
role: 'card',
|
||||
stroke: stroke(),
|
||||
fill: [{ type: 'solid' as const, color: '#F1F5F9' }],
|
||||
children: [inner],
|
||||
} as never);
|
||||
|
||||
stripNestedCardDecoration(outer);
|
||||
|
||||
expect((inner as PenNode & { fill?: unknown }).fill).toBeDefined();
|
||||
});
|
||||
|
||||
it('walks deep — strips on grandchild whose grandparent is decorated', () => {
|
||||
const grandchild = frame({
|
||||
id: 'gc',
|
||||
role: 'card',
|
||||
cornerRadius: 8,
|
||||
effects: shadow(),
|
||||
} as never);
|
||||
const middle = frame({
|
||||
id: 'mid',
|
||||
children: [grandchild],
|
||||
} as never);
|
||||
const outer = frame({
|
||||
id: 'outer',
|
||||
role: 'card',
|
||||
cornerRadius: 16,
|
||||
effects: shadow(),
|
||||
children: [middle],
|
||||
} as never);
|
||||
|
||||
stripNestedCardDecoration(outer);
|
||||
|
||||
expect((grandchild as PenNode & { cornerRadius?: unknown }).cornerRadius).toBeUndefined();
|
||||
expect((grandchild as PenNode & { effects?: unknown }).effects).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles cornerRadius arrays (asymmetric per-corner)', () => {
|
||||
const inner = frame({
|
||||
id: 'inner',
|
||||
role: 'card',
|
||||
cornerRadius: [12, 12, 0, 0],
|
||||
stroke: stroke(),
|
||||
} as never);
|
||||
const outer = frame({
|
||||
id: 'outer',
|
||||
role: 'card',
|
||||
cornerRadius: 16,
|
||||
children: [inner],
|
||||
} as never);
|
||||
|
||||
stripNestedCardDecoration(outer);
|
||||
|
||||
expect((inner as PenNode & { cornerRadius?: unknown }).cornerRadius).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns false when nothing was modified', () => {
|
||||
const card = frame({
|
||||
id: 'plain-card',
|
||||
role: 'card',
|
||||
stroke: stroke(),
|
||||
cornerRadius: 8,
|
||||
children: [
|
||||
frame({ id: 'text-only', role: 'body', children: [] } as never), // no decoration
|
||||
],
|
||||
} as never);
|
||||
|
||||
const changed = stripNestedCardDecoration(card);
|
||||
|
||||
expect(changed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -73,6 +73,7 @@ export {
|
|||
export { normalizeTreeLayout } from './layout/normalize-tree.js';
|
||||
export { unwrapFakePhoneMockups } from './layout/unwrap-fake-phone-mockup.js';
|
||||
export { stripRedundantSectionFills } from './layout/strip-redundant-section-fills.js';
|
||||
export { stripNestedCardDecoration } from './layout/strip-nested-card-decoration.js';
|
||||
export { clipCardImageCorners } from './layout/clip-card-image-corners.js';
|
||||
export { injectMissingNavSurfaceFill } from './layout/inject-nav-surface-fill.js';
|
||||
export { expandOverflowingFixedHeightCards } from './layout/expand-overflowing-fixed-height-cards.js';
|
||||
|
|
|
|||
134
packages/pen-core/src/layout/strip-nested-card-decoration.ts
Normal file
134
packages/pen-core/src/layout/strip-nested-card-decoration.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import type { PenNode } from '@zseven-w/pen-types';
|
||||
|
||||
/**
|
||||
* Strip redundant card-style decoration on a frame whose ancestor is
|
||||
* already a decorated card.
|
||||
*
|
||||
* 2026-05-11 user-reported "popular restaurant" mobile design — the LLM
|
||||
* built each restaurant row as `role: card` (outer) with cornerRadius +
|
||||
* stroke + 2-shadow elevation, then nested an inner `Card Info` frame
|
||||
* ALSO with `role: card` + cornerRadius + 2 shadows for the right-side
|
||||
* text column. The inner decoration rendered as a visible "border" /
|
||||
* card-within-a-card box that the outer card already provided.
|
||||
*
|
||||
* Element builders (N-tools) emit decoration deterministically without
|
||||
* knowing their context — when an LLM places a v1 card builder inside
|
||||
* another card, the inner stroke / cornerRadius / shadow stack on top
|
||||
* of the outer's, producing the doubled box look. This pass walks the
|
||||
* tree and removes the inner decoration when it's redundant with an
|
||||
* ancestor's.
|
||||
*
|
||||
* Strip rule for a frame F:
|
||||
* - F has `stroke` OR `cornerRadius > 0` OR shadow effects, AND
|
||||
* - F's ancestor chain contains another frame with the same kind of
|
||||
* decoration (stroke / cornerRadius / shadow), AND
|
||||
* - F is not a role that legitimately wants decoration even nested
|
||||
* (button, tag, chip, input, search-bar, badge, avatar) — these
|
||||
* elements often live inside cards and need their own affordance
|
||||
*
|
||||
* Conservative: fills are NOT stripped. A child fill might be an
|
||||
* intentional surface change (e.g. dark accent strip inside a white
|
||||
* card). stripRedundantSectionFills handles the fill-redundancy
|
||||
* heuristic separately.
|
||||
*/
|
||||
|
||||
const KEEP_DECORATION_ROLES = new Set([
|
||||
'button',
|
||||
'icon-button',
|
||||
'fab',
|
||||
'tag',
|
||||
'chip',
|
||||
'badge',
|
||||
'status-badge',
|
||||
'pill',
|
||||
'input',
|
||||
'search-bar',
|
||||
'form-field',
|
||||
'textarea',
|
||||
'select',
|
||||
'combobox',
|
||||
'avatar',
|
||||
'avatar-stack',
|
||||
'switch',
|
||||
'checkbox',
|
||||
'radio',
|
||||
'toolbar',
|
||||
'segmented-control',
|
||||
]);
|
||||
|
||||
interface DecoFlags {
|
||||
hasStroke: boolean;
|
||||
hasCornerRadius: boolean;
|
||||
hasShadow: boolean;
|
||||
}
|
||||
|
||||
function readDecoration(node: PenNode): DecoFlags {
|
||||
const n = node as PenNode & {
|
||||
stroke?: { thickness?: number };
|
||||
cornerRadius?: number | number[];
|
||||
effects?: Array<{ type?: string }>;
|
||||
};
|
||||
const strokeThick = n.stroke?.thickness ?? 0;
|
||||
const hasStroke = typeof strokeThick === 'number' && strokeThick > 0;
|
||||
const cr = n.cornerRadius;
|
||||
const hasCornerRadius =
|
||||
typeof cr === 'number' ? cr > 0 : Array.isArray(cr) && cr.some((v) => Number(v) > 0);
|
||||
const hasShadow = Array.isArray(n.effects) && n.effects.some((e) => e?.type === 'shadow');
|
||||
return { hasStroke, hasCornerRadius, hasShadow };
|
||||
}
|
||||
|
||||
function isRoleProtected(node: PenNode): boolean {
|
||||
const role = ((node as { role?: string }).role ?? '').toLowerCase();
|
||||
return KEEP_DECORATION_ROLES.has(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any node was modified.
|
||||
*/
|
||||
export function stripNestedCardDecoration(root: PenNode): boolean {
|
||||
let changed = false;
|
||||
walk(root, []);
|
||||
return changed;
|
||||
|
||||
function walk(node: PenNode, ancestors: PenNode[]): void {
|
||||
if (node.type === 'frame') {
|
||||
const ancestorDeco = ancestors
|
||||
.filter((a) => a.type === 'frame')
|
||||
.map(readDecoration)
|
||||
.reduce(
|
||||
(acc, d) => ({
|
||||
hasStroke: acc.hasStroke || d.hasStroke,
|
||||
hasCornerRadius: acc.hasCornerRadius || d.hasCornerRadius,
|
||||
hasShadow: acc.hasShadow || d.hasShadow,
|
||||
}),
|
||||
{ hasStroke: false, hasCornerRadius: false, hasShadow: false },
|
||||
);
|
||||
const own = readDecoration(node);
|
||||
const isProtected = isRoleProtected(node);
|
||||
if (!isProtected) {
|
||||
const n = node as PenNode & {
|
||||
stroke?: unknown;
|
||||
cornerRadius?: unknown;
|
||||
effects?: unknown;
|
||||
};
|
||||
if (own.hasStroke && ancestorDeco.hasStroke) {
|
||||
delete n.stroke;
|
||||
changed = true;
|
||||
}
|
||||
if (own.hasCornerRadius && ancestorDeco.hasCornerRadius) {
|
||||
delete n.cornerRadius;
|
||||
changed = true;
|
||||
}
|
||||
if (own.hasShadow && ancestorDeco.hasShadow) {
|
||||
delete n.effects;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
const nextAncestors = ancestors.concat(node);
|
||||
for (const child of node.children) walk(child, nextAncestors);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue