diff --git a/apps/web/src/canvas/canvas-layout-engine.ts b/apps/web/src/canvas/canvas-layout-engine.ts index 7d717f4c6..a465c4f1d 100644 --- a/apps/web/src/canvas/canvas-layout-engine.ts +++ b/apps/web/src/canvas/canvas-layout-engine.ts @@ -15,6 +15,7 @@ export { normalizeTreeLayout, unwrapFakePhoneMockups, stripRedundantSectionFills, + stripNestedCardDecoration, clipCardImageCorners, injectMissingNavSurfaceFill, expandOverflowingFixedHeightCards, diff --git a/apps/web/src/services/ai/design-canvas-ops.ts b/apps/web/src/services/ai/design-canvas-ops.ts index 5bc2aac2d..cadb5c1ed 100644 --- a/apps/web/src/services/ai/design-canvas-ops.ts +++ b/apps/web/src/services/ai/design-canvas-ops.ts @@ -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 diff --git a/packages/pen-core/src/__tests__/strip-nested-card-decoration.test.ts b/packages/pen-core/src/__tests__/strip-nested-card-decoration.test.ts new file mode 100644 index 000000000..2b8e91a0b --- /dev/null +++ b/packages/pen-core/src/__tests__/strip-nested-card-decoration.test.ts @@ -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 & { 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); + }); +}); diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 018070680..3d866aedb 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -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'; diff --git a/packages/pen-core/src/layout/strip-nested-card-decoration.ts b/packages/pen-core/src/layout/strip-nested-card-decoration.ts new file mode 100644 index 000000000..03a0276e4 --- /dev/null +++ b/packages/pen-core/src/layout/strip-nested-card-decoration.ts @@ -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); + } + } +}