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:
Fini 2026-05-11 00:17:02 +08:00
parent f93a9437aa
commit ec4fe44368
5 changed files with 358 additions and 0 deletions

View file

@ -15,6 +15,7 @@ export {
normalizeTreeLayout,
unwrapFakePhoneMockups,
stripRedundantSectionFills,
stripNestedCardDecoration,
clipCardImageCorners,
injectMissingNavSurfaceFill,
expandOverflowingFixedHeightCards,

View file

@ -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

View file

@ -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);
});
});

View file

@ -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';

View 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);
}
}
}