fix(ai): preserve explicit no-fill intent when normalizing CSS keyword fills

Follow-up on 62b24bd. When every entry in a node's fill array was an
illegal CSS keyword ("none" / "transparent"), normalizeNodeFill was
calling `delete rec.fill` to remove the field. That was wrong:

    rec.fill = [{type:'solid', color:'none'}]
    ↓ normalize ↓
    rec.fill = undefined     (field deleted)
    ↓ canvas render ↓
    DEFAULT_FILL gray        (canvas-object-factory fallback)

But the AI's intent was the opposite — "no fill, transparent shape" —
which is critical for things like activity rings (hollow interior with
only a stroke) and chart line backgrounds. Deleting the field silently
flipped those from transparent to opaque gray.

Replace the delete path with an explicit #00000000 transparent hex so
the renderer sees the intent and honours it. Only applies when EVERY
original entry was illegal; mixed arrays (one illegal, one legal) drop
the illegal ones and keep the real colors untouched.

stroke handling is unchanged — a stroke whose fill array is empty
really is meaningless (nothing to draw) and is still removed entirely.

Updated the M2.7 heart-rate chart line end-to-end regression test to
assert the transparent hex instead of the old "undefined or empty"
contract, plus a new mixed-array test covering the partial-cleanup
path. pen-core suite: 189 committed tests, all green.

Also cleaned up a handful of stale TypeScript casts in the test file
(PenNode['stroke'] / Partial<PenNode> annotations that no longer
compile since PenNode is a tagged union where only some variants
carry stroke). Test helpers now accept an untyped Record<string,
unknown> bag — appropriate for a normalizer test suite whose inputs
are intentionally schema violations.
This commit is contained in:
Fini 2026-04-06 19:52:54 +08:00
parent 62b24bda76
commit 239f635d2e
2 changed files with 94 additions and 42 deletions

View file

@ -2,7 +2,13 @@ import { describe, it, expect } from 'vitest';
import type { PenNode } from '@zseven-w/pen-types';
import { normalizeStrokeFillSchema } from '../normalize/normalize-stroke-fill-schema';
const path = (props: Partial<PenNode> = {}): PenNode =>
// Test helpers accept untyped property bags because most assertions here
// intentionally put malformed (schema-violating) data on the node — that
// is exactly the input shape the normalizer is designed to repair. A
// strongly-typed signature would reject the very cases we need to cover.
type NodeProps = Record<string, unknown>;
const path = (props: NodeProps = {}): PenNode =>
({
id: 'p',
type: 'path',
@ -10,25 +16,25 @@ const path = (props: Partial<PenNode> = {}): PenNode =>
width: 100,
height: 50,
...props,
}) as PenNode;
}) as unknown as PenNode;
const ellipse = (props: Partial<PenNode> = {}): PenNode =>
const ellipse = (props: NodeProps = {}): PenNode =>
({
id: 'e',
type: 'ellipse',
width: 100,
height: 100,
...props,
}) as PenNode;
}) as unknown as PenNode;
const frame = (props: Partial<PenNode> & { children?: PenNode[] } = {}): PenNode =>
const frame = (props: NodeProps & { children?: PenNode[] } = {}): PenNode =>
({
id: 'f',
type: 'frame',
width: 200,
height: 200,
...props,
}) as PenNode;
}) as unknown as PenNode;
const validFill = [{ type: 'solid' as const, color: '#C4F82A' }];
@ -37,7 +43,7 @@ describe('normalizeStrokeFillSchema — stroke array unwrap', () => {
const node = ellipse({
stroke: [
{ thickness: 12, fill: validFill },
] as unknown as PenNode['stroke'],
],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown } };
@ -48,7 +54,7 @@ describe('normalizeStrokeFillSchema — stroke array unwrap', () => {
it('leaves a stroke that is already a proper object alone', () => {
const node = ellipse({
stroke: { thickness: 4, fill: validFill } as PenNode['stroke'],
stroke: { thickness: 4, fill: validFill },
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown } };
@ -60,9 +66,9 @@ describe('normalizeStrokeFillSchema — stroke array unwrap', () => {
// {type, color} (fill shape) instead of {thickness, fill}. strokeWidth
// lives as a top-level node field.
const node = path({
stroke: [{ type: 'solid', color: '#C4F82A' }] as unknown as PenNode['stroke'],
stroke: [{ type: 'solid', color: '#C4F82A' }],
strokeWidth: 2.5,
} as Partial<PenNode> & { strokeWidth?: number });
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as {
stroke?: { thickness?: number; fill?: Array<{ color?: string }> };
@ -76,7 +82,7 @@ describe('normalizeStrokeFillSchema — stroke array unwrap', () => {
it('uses a default thickness when neither strokeWidth nor thickness is present', () => {
const node = path({
stroke: [{ type: 'solid', color: '#111' }] as unknown as PenNode['stroke'],
stroke: [{ type: 'solid', color: '#111' }],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown } };
@ -88,9 +94,9 @@ describe('normalizeStrokeFillSchema — stroke array unwrap', () => {
// Some sub-agents emit { stroke: { type: "solid", color: "#fff" } }
// (object, not array) — still a fill shape. Same recovery rules apply.
const node = path({
stroke: { type: 'solid', color: '#FFFFFF' } as unknown as PenNode['stroke'],
stroke: { type: 'solid', color: '#FFFFFF' },
strokeWidth: 3,
} as Partial<PenNode> & { strokeWidth?: number });
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as {
stroke?: { thickness?: number; fill?: Array<{ color?: string }> };
@ -100,32 +106,54 @@ describe('normalizeStrokeFillSchema — stroke array unwrap', () => {
});
});
describe('normalizeStrokeFillSchema — illegal fill color drops', () => {
it('drops fills whose color is "none"', () => {
describe('normalizeStrokeFillSchema — illegal fill color replacement', () => {
it('replaces a sole "none" fill with explicit transparent hex', () => {
// "none" and "transparent" are CSS keywords, not valid PenFill colors.
// But they express an explicit "no fill" intent (e.g. an activity ring
// that should be hollow). Deleting the fill field entirely would make
// the canvas renderer fall back to a default gray fill — the opposite
// of what the AI meant. Replace with the 8-digit transparent hex so
// the renderer sees an explicit transparent color and respects it.
const node = path({
fill: [{ type: 'solid', color: 'none' }] as unknown as PenNode['fill'],
fill: [{ type: 'solid', color: 'none' }],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: unknown };
// Either absent or empty array is acceptable — the important thing
// is that no { color: "none" } entry remains to confuse the renderer.
const f = rec.fill as unknown[] | undefined;
expect(f === undefined || (Array.isArray(f) && f.length === 0)).toBe(true);
const rec = node as unknown as { fill?: Array<{ type?: string; color?: string }> };
expect(rec.fill).toBeDefined();
expect(rec.fill).toHaveLength(1);
expect(rec.fill?.[0]?.type).toBe('solid');
expect(rec.fill?.[0]?.color?.toLowerCase()).toBe('#00000000');
});
it('drops fills whose color is "transparent"', () => {
it('replaces a sole "transparent" fill with explicit transparent hex', () => {
const node = path({
fill: [{ type: 'solid', color: 'transparent' }] as unknown as PenNode['fill'],
fill: [{ type: 'solid', color: 'transparent' }],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: unknown };
const f = rec.fill as unknown[] | undefined;
expect(f === undefined || (Array.isArray(f) && f.length === 0)).toBe(true);
const rec = node as unknown as { fill?: Array<{ type?: string; color?: string }> };
expect(rec.fill).toBeDefined();
expect(rec.fill).toHaveLength(1);
expect(rec.fill?.[0]?.color?.toLowerCase()).toBe('#00000000');
});
it('drops a "none" entry but keeps other legitimate entries in the same array', () => {
// Mixed array: one illegal, one legal. Just drop the illegal one and
// keep the real color — no transparent fallback needed.
const node = path({
fill: [
{ type: 'solid', color: 'none' },
{ type: 'solid', color: '#FF0000' },
],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
expect(rec.fill).toHaveLength(1);
expect(rec.fill?.[0]?.color).toBe('#FF0000');
});
it('keeps legitimate hex fills alone', () => {
const node = path({
fill: [{ type: 'solid', color: '#C4F82A' }] as unknown as PenNode['fill'],
fill: [{ type: 'solid', color: '#C4F82A' }],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
@ -136,7 +164,7 @@ describe('normalizeStrokeFillSchema — illegal fill color drops', () => {
// The 8-digit hex IS a valid color string (alpha channel). Only the
// CSS keywords "none" and "transparent" are the unsupported forms.
const node = ellipse({
fill: [{ type: 'solid', color: '#00000000' }] as unknown as PenNode['fill'],
fill: [{ type: 'solid', color: '#00000000' }],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
@ -145,7 +173,7 @@ describe('normalizeStrokeFillSchema — illegal fill color drops', () => {
it('also strips illegal colors from stroke.fill arrays', () => {
const node = path({
stroke: { thickness: 2, fill: [{ type: 'solid', color: 'none' }] } as unknown as PenNode['stroke'],
stroke: { thickness: 2, fill: [{ type: 'solid', color: 'none' }] },
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { stroke?: { thickness?: number; fill?: unknown[] } };
@ -161,7 +189,7 @@ describe('normalizeStrokeFillSchema — recursion', () => {
it('recurses into children', () => {
const inner = ellipse({
id: 'inner',
stroke: [{ thickness: 8, fill: validFill }] as unknown as PenNode['stroke'],
stroke: [{ thickness: 8, fill: validFill }],
});
const root = frame({ id: 'root', children: [inner] });
normalizeStrokeFillSchema(root);
@ -176,10 +204,10 @@ describe('normalizeStrokeFillSchema — recursion', () => {
name: 'Steps Circle',
width: 100,
height: 100,
fill: [{ type: 'solid', color: '#00000000' }] as unknown as PenNode['fill'],
fill: [{ type: 'solid', color: '#00000000' }],
stroke: [
{ thickness: 12, fill: [{ type: 'solid', color: '#C4F82A' }] },
] as unknown as PenNode['stroke'],
],
});
normalizeStrokeFillSchema(ring);
const rec = ring as unknown as {
@ -198,21 +226,24 @@ describe('normalizeStrokeFillSchema — recursion', () => {
id: 'line',
name: 'Chart Line',
d: 'M0 50 L100 20',
fill: [{ type: 'solid', color: 'none' }] as unknown as PenNode['fill'],
stroke: [{ type: 'solid', color: '#C4F82A' }] as unknown as PenNode['stroke'],
fill: [{ type: 'solid', color: 'none' }],
stroke: [{ type: 'solid', color: '#C4F82A' }],
strokeWidth: 2.5,
} as Partial<PenNode> & { strokeWidth?: number });
});
normalizeStrokeFillSchema(line);
const rec = line as unknown as {
fill?: unknown[];
fill?: Array<{ color?: string }>;
stroke?: { thickness?: number; fill?: Array<{ color?: string }> };
strokeWidth?: number;
};
expect(rec.stroke?.thickness).toBe(2.5);
expect(rec.stroke?.fill?.[0]?.color).toBe('#C4F82A');
// "none" fill is gone
const f = rec.fill;
expect(f === undefined || (Array.isArray(f) && f.length === 0)).toBe(true);
// "none" fill was replaced with explicit transparent — NOT deleted.
// Deleting would let the canvas factory fall back to the default gray
// fill and the chart line's background would bleed through as a
// solid grey rectangle.
expect(rec.fill).toBeDefined();
expect(rec.fill?.[0]?.color?.toLowerCase()).toBe('#00000000');
expect(rec.strokeWidth).toBeUndefined();
});
});

View file

@ -137,16 +137,37 @@ function stripIllegalColorsFromStrokeFill(node: PenNode): void {
// Fill normalization
// ---------------------------------------------------------------------------
/**
* Explicit transparent hex. Used when we need to preserve a node's
* "no fill" intent but cannot leave the fill field absent (which would
* make canvas-object-factory fall back to an opaque default gray fill).
*/
const EXPLICIT_TRANSPARENT_FILL: SolidFill = {
type: 'solid',
color: '#00000000',
};
function normalizeNodeFill(node: PenNode): void {
const rec = node as unknown as { fill?: unknown };
const raw = rec.fill;
if (!raw) return;
if (!Array.isArray(raw)) return;
// Separate legal entries from CSS-keyword illegal entries.
const cleaned = raw.filter((f) => isLegalFillEntry(f));
if (cleaned.length === 0) {
delete rec.fill;
} else {
if (cleaned.length > 0) {
rec.fill = cleaned as PenFill[];
return;
}
// Every original entry was a CSS keyword ("none" / "transparent").
// The AI's intent was "no fill" — but DELETING the field would let
// canvas-object-factory fall back to its default opaque gray fill,
// which is the opposite of no-fill. Replace with an explicit
// transparent hex so the renderer honours the intent.
if (raw.length > 0) {
rec.fill = [EXPLICIT_TRANSPARENT_FILL] as PenFill[];
} else {
// Empty array in, empty array out — leave unchanged.
rec.fill = [] as PenFill[];
}
}