fix(ai): contrast detector skips node-level opacity=0 / visible=false too

Second Codex stop-hook caught: the previous fix only guarded fill-level
opacity (`fill.opacity === 0` / 8-hex alpha 00). PenNodeBase has its
own `opacity?: number | string` and `visible?: boolean` fields that
hide the WHOLE wrapper including its fill. A wrapper with

    { fill: [{type:'solid', color:'#FFFFFF'}], opacity: 0, ... }

was still being treated as a white bg and masking the real bg further
up the chain.

ancestorBgColor() now skips ancestors whose node-level
`opacity === 0` or `visible === false`, complementing the
firstSolidColor fill-level guard. `opacity` can be a `$variable` ref
in PenDocument; resolving that to a literal 0 is not yet covered —
we only catch the literal-0 case for now (which is the AI-output
shape the corpus produces).

Two new test cases cover both paths.
This commit is contained in:
Fini 2026-05-10 14:58:00 +08:00
parent 5a841f011f
commit 8ddefec481
2 changed files with 43 additions and 1 deletions

View file

@ -216,6 +216,40 @@ describe('detectTextBgContrast', () => {
expect(issues[0].reason).toMatch(/bg=#FFF8E7/);
});
it('skips wrapper with NODE-LEVEL opacity=0 and uses real bg further up', () => {
// 2026-05-10 second Codex stop-hook — node-level opacity is a
// separate path from fill-level opacity. A wrapper with white fill
// and node opacity=0 is fully invisible; the detector must not
// pick up its white as the bg.
const wrap: PenNode = {
id: 'wrap',
type: 'frame',
layout: 'vertical',
fill: solid('#FFFFFF'),
opacity: 0,
children: [text('t1', solid('#FFF8E7'))],
} as unknown as PenNode;
const root = frame('page', [wrap], solid('#FFF8E7'));
const issues = detectTextBgContrast(root, emptyDoc);
expect(issues).toHaveLength(1);
expect(issues[0].reason).toMatch(/bg=#FFF8E7/);
});
it('skips wrapper with visible=false and uses real bg further up', () => {
const wrap: PenNode = {
id: 'wrap',
type: 'frame',
layout: 'vertical',
fill: solid('#FFFFFF'),
visible: false,
children: [text('t1', solid('#FFF8E7'))],
} as unknown as PenNode;
const root = frame('page', [wrap], solid('#FFF8E7'));
const issues = detectTextBgContrast(root, emptyDoc);
expect(issues).toHaveLength(1);
expect(issues[0].reason).toMatch(/bg=#FFF8E7/);
});
it('still treats opacity=0.5 as visible (only opacity=0 is the alpha-0 sentinel)', () => {
// We don't try to math a 50% wash against the layer below; that is
// outside the detector's scope. The fill stays as the bg.

View file

@ -150,7 +150,15 @@ export function detectTextBgContrast(
// default and avoids the "everything fails" report when a doc has
// no explicit page fill.
for (let i = ancestors.length - 1; i >= 0; i--) {
const fill = (ancestors[i] as unknown as { fill?: unknown }).fill;
// Node-level opacity=0 / visible=false hides the WHOLE wrapper
// including its fill. The 2026-05-10 Codex review caught this as
// a separate path from fill-level opacity (the inner `firstSolid
// Color` guard) — both have to skip or a node-opacity-0 wrapper
// masks the real bg further up.
const ancestor = ancestors[i] as PenNode & { opacity?: unknown; visible?: unknown };
if (ancestor.opacity === 0) continue;
if (ancestor.visible === false) continue;
const fill = (ancestor as unknown as { fill?: unknown }).fill;
const raw = firstSolidColor(fill);
if (!raw) continue;
const resolved = resolveColorRef(raw, variables, theme);