fix(pen-core): repair hex colors missing the leading # prefix

M2.7 food-app run shipped the page root with
  fill: [{ type: 'solid', color: 'FFF8F0' }]
(no leading \`#\`). The renderer's hex parser failed → root frame
fell back to its default gray fill → the warm-food cream page bg
disappeared and the whole design read as a generic gray app
instead of the warm-light theme. Bottom nav and other surfaces
were similarly affected when sub-agents emitted raw 6-digit hex
without the prefix.

normalizer now adds the missing \`#\` in place when:
- entry is a SolidFill with a string color
- color starts with neither \`#\` nor \`$\` (so we don't touch
  variable refs)
- color matches one of the four hex shapes the renderer accepts:
  \`/^[0-9A-Fa-f]{3}([0-9A-Fa-f]([0-9A-Fa-f]{2}([0-9A-Fa-f]{2})?)?)?$/\`
  — exactly 3, 4, 6, or 8 hex digits. 5 and 7 digit strings
  intentionally don't match (those aren't repairable hex).

Same repair applies to:
- gradient stop colors (linear_gradient + radial_gradient)
- stroke.fill colors (M2.7 also drops the prefix on stroke colors)

6 new tests cover: 6-digit repair, 3/4/8-digit shapes, valid hex
unchanged, \$color-* refs unchanged, non-hex strings (named
colors / partial / 5-7 digit) untouched, stroke fill repair,
gradient stop repair. Verified by temporarily commenting out the
repair calls — 4 tests correctly fail "expected '#FFF8F0' to be
'FFF8F0'", confirming the regression coverage actually exercises
the bug condition.
This commit is contained in:
Fini 2026-05-05 12:23:01 +08:00
parent b23fba48dc
commit 07fb3d0d3c
2 changed files with 144 additions and 0 deletions

View file

@ -414,3 +414,91 @@ describe('normalizeStrokeFillSchema — recursion', () => {
expect(rec.strokeWidth).toBeUndefined();
});
});
describe('normalizeStrokeFillSchema — hex prefix repair', () => {
// M2.7 food-app run shipped the page root with
// fill: [{ type: 'solid', color: 'FFF8F0' }]
// (note: no leading `#`). The renderer's hex parser failed → the
// root frame fell back to its default gray fill, the cream warm-
// food page bg disappeared, and the whole design read as a
// generic gray app instead of the warm-light theme. The
// normalizer now repairs the missing prefix in place.
it('adds # prefix to a 6-digit hex without one', () => {
const node = frame({ fill: [{ type: 'solid' as const, color: 'FFF8F0' }] });
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
expect(rec.fill?.[0]?.color).toBe('#FFF8F0');
});
it('repairs 3, 4, and 8-digit raw hex shapes', () => {
const cases: Array<[string, string]> = [
['F00', '#F00'],
['F00A', '#F00A'],
['00FF7733', '#00FF7733'],
];
for (const [raw, expected] of cases) {
const node = frame({ fill: [{ type: 'solid' as const, color: raw }] });
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
expect(rec.fill?.[0]?.color).toBe(expected);
}
});
it('leaves valid # hex unchanged', () => {
const node = frame({ fill: [{ type: 'solid' as const, color: '#1F2937' }] });
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
expect(rec.fill?.[0]?.color).toBe('#1F2937');
});
it('leaves $color-* variable refs unchanged', () => {
const node = frame({ fill: [{ type: 'solid' as const, color: '$color-accent' }] });
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
expect(rec.fill?.[0]?.color).toBe('$color-accent');
});
it('leaves non-hex strings alone (unrecognized formats not in scope)', () => {
// 5 chars, 7 chars, alphabet-other-than-hex — none match the
// 3/4/6/8 hex shape, so the repair is a no-op. This avoids
// accidentally `#`-prefixing model output we don't understand.
const cases = ['12345', '1234567', 'rebeccapurple', 'fake-hex'];
for (const raw of cases) {
const node = frame({ fill: [{ type: 'solid' as const, color: raw }] });
normalizeStrokeFillSchema(node);
const rec = node as unknown as { fill?: Array<{ color?: string }> };
expect(rec.fill?.[0]?.color).toBe(raw);
}
});
it('repairs prefix on stroke.fill colors', () => {
const node = path({
stroke: { thickness: 2, fill: [{ type: 'solid' as const, color: 'F0DCC8' }] },
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as { stroke?: { fill?: Array<{ color?: string }> } };
expect(rec.stroke?.fill?.[0]?.color).toBe('#F0DCC8');
});
it('repairs prefix on gradient stop colors', () => {
const node = frame({
fill: [
{
type: 'linear_gradient' as const,
stops: [
{ offset: 0, color: 'F97316' },
{ offset: 1, color: '#EA580C' }, // already-prefixed survives
],
angle: 90,
},
],
});
normalizeStrokeFillSchema(node);
const rec = node as unknown as {
fill?: Array<{ stops?: Array<{ color?: string }> }>;
};
expect(rec.fill?.[0]?.stops?.[0]?.color).toBe('#F97316');
expect(rec.fill?.[0]?.stops?.[1]?.color).toBe('#EA580C');
});
});

View file

@ -219,6 +219,12 @@ function stripIllegalColorsFromStrokeFill(node: PenNode): void {
if (!stroke || typeof stroke !== 'object') return;
const fillArr = stroke.fill;
if (!Array.isArray(fillArr)) return;
// Repair missing-`#` hex prefixes before the legal-entry filter,
// matching the same repair the fill normalizer does on shape
// fills. Without this, stroke colors like `'F0DCC8'` survive
// isLegalFillEntry (it only rejects CSS keywords, not malformed
// hex) and produce a transparent / gray-fallback stroke at render.
for (const f of fillArr) repairHexPrefixOnFillEntry(f);
(stroke as { fill?: PenFill[] }).fill = fillArr.filter((f) => isLegalFillEntry(f)) as PenFill[];
}
@ -254,6 +260,16 @@ function normalizeNodeFill(node: PenNode): void {
const raw = rec.fill;
if (!raw) return;
if (!Array.isArray(raw)) return;
// Repair fill entries with hex colors missing the leading `#` (e.g.
// `color: 'FFF8F0'` instead of `'#FFF8F0'`). Models like
// MiniMax M2.7 frequently drop the prefix; the renderer's hex
// parser then fails and the node falls back to the default gray
// fill — which is what made the food-app M2.7 run ship with a
// washed-out gray page bg even though the model "set" the cream
// color. Repair in place; doesn't touch other entry shapes.
for (const entry of raw) {
repairHexPrefixOnFillEntry(entry);
}
// Separate legal entries from CSS-keyword illegal entries.
const cleaned = raw.filter((f) => isLegalFillEntry(f));
if (cleaned.length > 0) {
@ -295,3 +311,43 @@ function isLegalFillEntry(entry: unknown): boolean {
}
return true;
}
/**
* 3, 4, 6, or 8 hex digits — the four shapes the renderer's hex
* parser accepts (#RGB / #RGBA / #RRGGBB / #RRGGBBAA). Anything
* else (named colors, malformed strings, partials) we leave alone
* so we don't mask other classes of bugs.
*/
const RAW_HEX_RE = /^[0-9A-Fa-f]{3}([0-9A-Fa-f]([0-9A-Fa-f]{2}([0-9A-Fa-f]{2})?)?)?$/;
/**
* Repair a single fill entry's color when it's a hex string missing
* the leading `#`. Mutates the entry in place. Solid entries only —
* gradient stops are repaired separately at the stop level (they're
* still PenFill SolidFill-shaped objects, just nested).
*/
function repairHexPrefixOnFillEntry(entry: unknown): void {
if (!entry || typeof entry !== 'object') return;
const e = entry as { type?: unknown; color?: unknown; stops?: unknown };
if (e.type === 'solid' && typeof e.color === 'string') {
const c = e.color.trim();
if (!c.startsWith('#') && !c.startsWith('$') && RAW_HEX_RE.test(c)) {
e.color = `#${c}`;
}
} else if (
(e.type === 'linear_gradient' || e.type === 'radial_gradient') &&
Array.isArray(e.stops)
) {
for (const stop of e.stops) {
if (stop && typeof stop === 'object') {
const s = stop as { color?: unknown };
if (typeof s.color === 'string') {
const c = s.color.trim();
if (!c.startsWith('#') && !c.startsWith('$') && RAW_HEX_RE.test(c)) {
s.color = `#${c}`;
}
}
}
}
}
}