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:
parent
b23fba48dc
commit
07fb3d0d3c
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue