From 9abc531dca993fb371c19a17ea4a9c05e78d688b Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 06:13:18 +0800 Subject: [PATCH] fix(runtime): backgroundColor="" on a Text opts out of the inherited Box bg (Ink parity) (#83) An explicit backgroundColor="" on a descendant Text now renders bare glyphs instead of bleeding the inherited Box background. Mirrors Ink Text.tsx:103-106 (effectiveBg = backgroundColor ?? inheritedBg; colorize only when truthy): undefined inherits, "" opts out. The trailing padding still uses the inherited Box bg (Box fill), so glyphs carry their effective bg while the Box fills the rest -- byte-identical to Ink. Corrects the background-color.test.tsx mixed snapshot, which had encoded the buggy green-bleed output. Adds the opt-out test. Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/background-color.test.tsx | 22 +++++++++++++++- packages/runtime/src/paint/paint.ts | 25 ++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx index e35fb23..81a1553 100644 --- a/packages/runtime-tests/integration/components/background-color.test.tsx +++ b/packages/runtime-tests/integration/components/background-color.test.tsx @@ -146,6 +146,24 @@ test("Multiple Text elements inherit same background", async ({ expect }) => { expect(lastFrame()).toMatchInlineSnapshot(`"Hello World"`); }); +// Ink parity (Text.tsx:103-106): a child 's effective background is +// `backgroundColor ?? inheritedBackgroundColor`, and the bg wrap is applied only +// when that value is truthy. An explicit `backgroundColor=""` is NOT undefined, +// so it does NOT inherit — it resolves to `""` (falsy) and OPTS OUT of the +// inherited Box background, rendering the glyphs with no bg. +test("Text backgroundColor='' opts out of inherited Box background", async ({ expect }) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + No BG + + )), + { columns: 100 }, + ); + // Bare text, no green (42) bleed and no bg reset (49) at all. + expect(lastFrame()).toBe("No BG"); +}); + test("Mixed text with and without background inheritance", async ({ expect }) => { const { lastFrame } = await render( defineComponent(() => () => ( @@ -157,7 +175,9 @@ test("Mixed text with and without background inheritance", async ({ expect }) => )), { columns: 100 }, ); - expect(lastFrame()).toMatchInlineSnapshot(`"Inherited No BG Red BG"`); + // Matches Ink background.tsx:106-116: bgGreen('Inherited ') + 'No BG ' + bgRed('Red BG'). + // The "" Text opts out, so green is reset (49) before "No BG " and it renders bare. + expect(lastFrame()).toMatchInlineSnapshot(`"Inherited No BG Red BG"`); }); test("Complex nested structure with background inheritance", async ({ expect }) => { diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index e922828..f29d32e 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -553,18 +553,37 @@ function paintNode( } case "text": { const layout = node.yoga.getComputedLayout(); - const bgProps: TextProps = inheritedBg ? { backgroundColor: inheritedBg } : {}; + // Ink Text.tsx:103-106: a Text's effective background is its OWN + // backgroundColor if defined (`??`, so an explicit "" is honored), else the + // inherited Box background; the bg is applied only when truthy. Passing the + // effective value as the squash base means an explicit "" opts OUT (renders + // bare glyphs) while `undefined` inherits — matching Ink's `??` semantics. + // (renderTextWithInlineStyles still lets the node's own props override this + // base, so the result is identical, but stating the effective value here + // keeps the intent explicit.) + const effectiveBg = (node.props["backgroundColor"] as string | undefined) ?? inheritedBg; + const bgProps: TextProps = effectiveBg ? { backgroundColor: effectiveBg } : {}; const text = renderTextWithInlineStyles(node, bgProps); // Skip writing empty text — avoids applying line transformers to empty // content, which matches Ink's behavior of not writing empty text nodes. if (text === "") return; const cellWidth = Math.max(1, Math.floor(layout.width)); const wrapped = wrapText(text, cellWidth, node.props.wrap ?? "wrap"); + // Pad each line to the cell width with the INHERITED Box background only — + // this fills the space behind the text with the Box's bg (the Box also fills + // it via fillBackground), and is the reason a Box bg pads to full width while + // a text-only bg does not. The padding uses `inheritedBg`, NOT the effective + // bg: a Text that overrides or opts out (backgroundColor / "") only recolors + // its OWN glyphs, never the surrounding Box fill. The already-rendered glyphs + // in `wrapped[i]` keep their effective bg, so a `backgroundColor=""` Text + // stays bare even though we pad the trailing cells with the inherited bg. if (inheritedBg) { + const padProps: TextProps = { backgroundColor: inheritedBg }; for (let i = 0; i < wrapped.length; i++) { const pad = cellWidth - stringWidth(wrapped[i]!); - const padStr = pad > 0 ? " ".repeat(pad) : ""; - wrapped[i] = applyChalk(wrapped[i]! + padStr, bgProps); + if (pad > 0) { + wrapped[i] = wrapped[i]! + applyChalk(" ".repeat(pad), padProps); + } } } output.write(x0 + layout.left, y0 + layout.top, wrapped, transformers);