From 5f55b59b70ab1e55a67dd35d9679c9e92df01756 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Wed, 3 Jun 2026 03:09:09 +0800 Subject: [PATCH] fix(runtime): backgroundColor of a chalk-modifier name throws (Ink parity, drop A12 divergence) (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ink's colorize throws on a chalk-modifier-name backgroundColor (e.g. "bold": 'bold' in chalk but chalk.bgBold is undefined -> "chalk.bgBold is not a function"); vue-tui degraded to bare text. Align: validate backgroundColor at component render (Text + Box own bg + drawn border edges) so the throw is caught by the error boundary, not the post-flush paint pass (a throw there wedges Vue's scheduler — cf. borderStyle #124). Detection mirrors Ink exactly: only the in-chalk-but-no-bg-method case throws; valid colors / hex / ansi256 / rgb / [r,g,b] / non-chalk strings and foreground modifiers (color="bold" still bolds) are unaffected. Border bgs are gated to Ink's render-border conditions (borderStyle + drawn edge + perEdge ?? general); empty/hidden elements don't throw. Since Ink throws lazily at paint (with layout/squash info) while vue must validate eagerly at render, a few degenerate cases (content-area<=0 box, degenerate top/bottom border, nested-empty text) over-throw on the invalid modifier input — documented in code as architecturally irreducible. Removes the A12 entry from ink-divergences.md. Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/docs/ink-divergences.md | 12 - .../components/background-color.test.tsx | 299 +++++++++++++++++- packages/runtime/src/components/Box.ts | 79 +++++ packages/runtime/src/components/Text.ts | 49 +++ packages/runtime/src/paint/text-style.test.ts | 67 +++- packages/runtime/src/paint/text-style.ts | 44 +++ 6 files changed, 536 insertions(+), 14 deletions(-) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index 4897bff..a9a5682 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -175,18 +175,6 @@ false` to handle Ctrl+C itself: under the lazy model its opt-out is silently tuple. The tuple is part of the typed surface (not a TS-bypass), so it's a supported input, not undefined behavior. Tested. -### `backgroundColor` of a chalk modifier name degrades to bare text - -- **Ink:** `backgroundColor='bold'` (any chalk **modifier** name, not a color) resolves - `isNamedColor('bold')` true (`'bold' in chalk`), then calls `chalk['bgBold']` — which doesn't - exist — and **throws** ("chalk.bgBold is not a function"). -- **vue-tui:** `applyColor`'s `typeof named === 'function'` guard sees `chalk['bgBold']` is - `undefined`, falls through `#`/`ansi256`/`rgb` (all non-matching), and returns the text - **unstyled** — no SGR, no throw. -- **Why:** same fallback policy vue already applies to an unparseable `ansi256(...)`/`rgb(...)` - string ("no match → bare text"). A non-color background name is junk input; degrading to bare - text is more robust than crashing the render. Additive robustness. - ### `useAnimation()` outside a render tree drives a real standalone animation - **Ink:** the default `AnimationContext.subscribe()` is a no-op (`{startTime: 0, diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx index 3de0352..3af05df 100644 --- a/packages/runtime-tests/integration/components/background-color.test.tsx +++ b/packages/runtime-tests/integration/components/background-color.test.tsx @@ -1,7 +1,7 @@ import { defineComponent, shallowRef, nextTick } from "vue"; import { test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text } from "@vue-tui/runtime"; +import { Box, Text, renderToString } from "@vue-tui/runtime"; const BG_BLUE = "\x1b[44m"; const BG_CYAN = "\x1b[46m"; @@ -677,3 +677,300 @@ test("foreground, background and dim combine correctly", async ({ expect }) => { expect(topLine).toContain("\x1b[2m\x1b[46m\x1b[31m"); expect(topLine).toContain("\x1b[39m\x1b[49m\x1b[22m"); }); + +// --- A12: chalk-modifier-name backgroundColor aligns to Ink's throw --- +// +// Ink colorize.ts (commit 40b3a75): for a BACKGROUND, `isNamedColor(color)` +// (`color in chalk`) is true for a chalk MODIFIER name ("bold","dim","italic", +// "underline","inverse","hidden","strikethrough",…), so it builds +// methodName = `bg${Capitalize(color)}` and calls `chalk[methodName]` — which is +// undefined for a modifier (chalk has no `bgBold`/`bgDim`/…) — and THROWS +// "chalk.bgBold is not a function". A chalk COLOR name resolves to a real `bg*` +// method and works; a string NOT in chalk falls through to bare text (no throw); +// foreground `color="bold"` resolves `chalk.bold` (a real fn) and bolds (no throw). +// borderBackgroundColor route through the same colorize('background') in +// render-border.ts stylePiece, so Ink throws there too. vue-tui must validate +// during component RENDER (so the throw lands in the error boundary, not the +// post-flush paint where it would wedge Vue's scheduler — cf. the borderStyle fix #124). + +const BG_MODIFIER_NAMES = [ + "bold", + "dim", + "italic", + "underline", + "inverse", + "hidden", + "strikethrough", + "reset", + "overline", +] as const; + +for (const modifier of BG_MODIFIER_NAMES) { + test(` (chalk modifier name) throws (Ink parity)`, async ({ + expect, + }) => { + await expect( + render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ), + ).rejects.toThrow(/backgroundColor/i); + }); + + test(` (chalk modifier name) throws (Ink parity)`, async ({ + expect, + }) => { + await expect( + render( + defineComponent(() => () => Hi), + { columns: 100 }, + ), + ).rejects.toThrow(/backgroundColor/i); + }); + + test(` (chalk modifier name) throws (Ink parity)`, async ({ + expect, + }) => { + await expect( + render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ), + ).rejects.toThrow(/backgroundColor/i); + }); +} + +// Per-edge border backgrounds route through the same colorize('background') in +// Ink's render-border.ts stylePiece, so a modifier name on any edge throws too. +for (const edgeProp of [ + "borderTopBackgroundColor", + "borderBottomBackgroundColor", + "borderLeftBackgroundColor", + "borderRightBackgroundColor", +] as const) { + test(` (chalk modifier name) throws (Ink parity)`, async ({ expect }) => { + await expect( + render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ), + ).rejects.toThrow(/backgroundColor/i); + }); +} + +// MUST NOT throw: a chalk COLOR name has a real `bg*` method and works. +test("backgroundColor of a real color name (a bg* method exists) does NOT throw", async ({ + expect, +}) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + expect(lastFrame()).toContain("Hi"); +}); + +// MUST NOT throw: a string NOT in chalk falls through to bare text in Ink (no +// throw). vue-tui keeps that degrade — only the in-chalk modifier names throw. +test("backgroundColor of an unknown non-chalk string degrades to bare text (no throw)", async ({ + expect, +}) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + // No SGR background codes, just bare text. + expect(lastFrame()).toBe("Hi"); +}); + +// MUST NOT throw: hex / ansi256 / rgb / [r,g,b] backgrounds are valid forms. +test("backgroundColor hex / ansi256 / rgb / [r,g,b] do NOT throw", async ({ expect }) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + A + + + B + + + C + + + D + + + )), + { columns: 100 }, + ); + const out = lastFrame()!; + expect(out).toContain("A"); + expect(out).toContain("D"); +}); + +// MUST NOT throw: foreground `color="bold"` resolves `chalk.bold` (a real fn) and +// applies the modifier — Ink works here (no throw), and vue-tui must match. +test('foreground color="bold" (a chalk modifier) bolds, does NOT throw', async ({ expect }) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + // chalk.bold => ESC[1m … ESC[22m + expect(lastFrame()).toContain("\x1b[1m"); +}); + +// --- A12 gating: vue must throw WHERE Ink colorizes, and NOT elsewhere --- +// +// Ink throws on a chalk-modifier-name bg LAZILY — only when it actually +// colorizes a rendered piece (render-border.ts gates on `borderStyle` truthy + +// the edge being DRAWN and uses `borderBackgroundColor ?? borderBackgroundColor`; +// render-background/Text skip empty/hidden nodes). vue-tui's component-render +// validation must mirror those gates, or it OVER-THROWS where Ink renders fine. +// These pin the now-correct NON-throwing cases (RED before the gating fix). + +// No borderStyle → render-border.ts:28 gate is false → no border colorize at all. +// So a modifier-name borderBackgroundColor with NO borderStyle must NOT throw. +test("borderBackgroundColor modifier name with NO borderStyle does NOT throw (Ink: gate off)", async ({ + expect, +}) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + // No border is drawn (no borderStyle); the bg is never colorized. + expect(lastFrame()).toContain("Hi"); +}); + +// A per-edge modifier-name bg on a DISABLED edge must NOT throw: Ink only +// colorizes the edge when it is drawn (`border !== false`). Here the top +// edge is disabled, so its bg never reaches colorize. +test("borderTopBackgroundColor modifier name on a DISABLED top edge does NOT throw (Ink: edge not drawn)", async ({ + expect, +}) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + // Top edge not drawn → its modifier-name bg never colorized → no throw. + expect(lastFrame()).toContain("Hi"); +}); + +// A bad GENERAL borderBackgroundColor is harmless if every DRAWN edge overrides +// it with a valid per-edge value: in Ink the general value is only the fallback +// (`borderBackgroundColor ?? borderBackgroundColor`), so when all four +// edges supply a valid override the general value never reaches colorize. +test("bad general borderBackgroundColor with valid per-edge on every drawn edge does NOT throw", async ({ + expect, +}) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + // Every drawn edge resolves to a valid "blue"; the bad general value is never used. + expect(lastFrame()).toContain("Hi"); +}); + +// Empty {""}: Ink's Text returns null for +// empty children BEFORE attaching its colorizing transform, so colorize never +// runs. vue-tui validates AFTER the empty early-return, so this must NOT throw. +test('empty {""} does NOT throw (Ink: returns null first)', async ({ + expect, +}) => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + {""} + + )), + { columns: 100 }, + ); + // Empty text renders nothing and never colorizes. + expect(lastFrame()).toBe(""); +}); + +// A screen-reader-hidden / with a modifier-name bg must NOT throw: +// Ink emits no node for an aria-hidden element under a screen reader, so it +// never colorizes. vue-tui validates AFTER the screen-reader-hidden early-return. +// Uses renderToString (which supports isScreenReaderEnabled) — a throw during +// render would propagate out of renderToString, so `not.toThrow` pins the fix. +test("screen-reader-hidden Box with modifier-name backgroundColor does NOT throw", ({ expect }) => { + const App = defineComponent(() => () => ( + + secret + + )); + let out = ""; + expect(() => { + out = renderToString(App, { columns: 100, isScreenReaderEnabled: true }); + }).not.toThrow(); + // Hidden from the screen reader → not rendered → bg never colorized → no throw. + expect(out).not.toContain("secret"); +}); + +// And the hidden variant: a screen-reader-hidden Text with a modifier-name +// bg also returns null before colorize, so it must NOT throw either. +test("screen-reader-hidden Text with modifier-name backgroundColor does NOT throw", ({ + expect, +}) => { + const App = defineComponent(() => () => ( + + + secret + + + )); + expect(() => renderToString(App, { columns: 100, isScreenReaderEnabled: true })).not.toThrow(); +}); diff --git a/packages/runtime/src/components/Box.ts b/packages/runtime/src/components/Box.ts index 433e5ee..a0201ce 100644 --- a/packages/runtime/src/components/Box.ts +++ b/packages/runtime/src/components/Box.ts @@ -1,6 +1,7 @@ import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue"; import cliBoxes from "cli-boxes"; import { AppContextKey } from "../context.ts"; +import { assertValidBackgroundColor } from "../paint/text-style.ts"; import type { WithChildren } from "./with-children.ts"; type Spacing = number; @@ -170,6 +171,84 @@ const BoxImpl = defineComponent({ return null; } + // --- backgroundColor validation (A12) --- + // + // Validate the bg-style props during RENDER so a chalk-modifier name (the + // exact case Ink's colorize.ts throws on) is caught by vue-tui's error + // boundary, not the post-flush paint pass where a throw wedges Vue's + // scheduler (cf. the borderStyle fix #124). See assertValidBackgroundColor / + // Ink colorize.ts (40b3a75). + // + // Placed AFTER the screen-reader-hidden early-return to mirror Ink WHERE it + // throws: Ink's render-node-to-output never reaches render-background / + // render-border for a node it didn't emit, and a screen-reader-hidden Box + // emits nothing — so colorize never runs for it. Below, we additionally + // gate each value the way Ink's renderers do, so vue throws exactly where + // Ink would colorize, and not elsewhere. + + // Box's OWN backgroundColor: Ink's render-background.ts only colorizes when + // the resolved content area is > 0 (after subtracting drawn borders); it + // bails early otherwise. We can't know the content area at render time + // (layout hasn't run), so we validate eagerly. ACCEPTED tiny over-throw: a + // degenerate-size Box (content area <= 0, e.g. width/height collapses to + // border-only or 0) with a chalk-modifier-name backgroundColor throws here + // where Ink would silently skip the fill. This is a niche case on + // clearly-invalid input (a modifier name is never a valid bg); matching + // Ink's layout-time gate at render time is not worth the complexity. + assertValidBackgroundColor(props.backgroundColor); + + // Border backgrounds: Ink's render-border.ts only colorizes a border bg + // when (a) borderStyle is truthy (line 28 gate) AND (b) the specific edge + // is DRAWN (`border !== false`), and the value it passes per edge is + // `borderBackgroundColor ?? borderBackgroundColor` (the per-edge + // value with the general value as fallback — lines 44-52, 80/100/112/126). + // So we validate exactly the resolved bg of each DRAWN edge — never the + // general value on its own, and never an edge whose border isn't drawn. + // Consequences (matching Ink, not over-throwing): + // - no borderStyle → no border colorize at all → nothing validated. + // - borderTop={false} → top edge not drawn → its resolved bg not checked. + // - a bad general borderBackgroundColor but every DRAWN edge overrides it + // with a valid per-edge value → general value never reaches colorize → + // not validated → no throw. + // ACCEPTED irreducible over-throw (same class as the content-area note + // above): top/bottom edge bg is validated eagerly, but Ink only colorizes a + // top/bottom border string when it's non-empty — a degenerate box (e.g. + // width=0 with no left/right border) produces an empty string Ink skips. + // Matching that needs layout, unavailable at render. Niche + invalid-input-only. + if (props.borderStyle) { + const generalBg = props.borderBackgroundColor; + if (props.borderTop !== false) { + assertValidBackgroundColor( + props.borderTopBackgroundColor ?? generalBg, + "borderTopBackgroundColor", + ); + } + if (props.borderBottom !== false) { + assertValidBackgroundColor( + props.borderBottomBackgroundColor ?? generalBg, + "borderBottomBackgroundColor", + ); + } + if (props.borderLeft !== false) { + assertValidBackgroundColor( + props.borderLeftBackgroundColor ?? generalBg, + "borderLeftBackgroundColor", + ); + } + if (props.borderRight !== false) { + assertValidBackgroundColor( + props.borderRightBackgroundColor ?? generalBg, + "borderRightBackgroundColor", + ); + } + } + + // NOTE: this component-level validation covers the public ``/`` + // API only. A raw host-op call (`h("box", { backgroundColor: "bold" })`) + // bypasses it; the paint layer keeps its silent degrade-to-bare-text there + // rather than throwing (a throw in the post-flush paint pass wedges Vue's + // scheduler). Same accepted limitation as the borderStyle fix (#124). + // Validate borderStyle during RENDER so an unknown name is caught by // vue-tui's error boundary (onErrorCaptured → ErrorOverview), exactly like // any other component render error. Ink crashes on an unknown borderStyle diff --git a/packages/runtime/src/components/Text.ts b/packages/runtime/src/components/Text.ts index eda4fb3..ecedd3a 100644 --- a/packages/runtime/src/components/Text.ts +++ b/packages/runtime/src/components/Text.ts @@ -1,12 +1,17 @@ import { + Comment, + Text as VueText, defineComponent, getCurrentInstance, h, inject, + isVNode, type ExtractPublicPropTypes, type PropType, + type VNode, } from "vue"; import { AppContextKey } from "../context.ts"; +import { assertValidBackgroundColor } from "../paint/text-style.ts"; import type { WithChildren } from "./with-children.ts"; type Color = string | [number, number, number]; @@ -53,6 +58,25 @@ const TextImpl = defineComponent({ return null; } + // Validate backgroundColor during RENDER so a chalk-modifier name (the + // exact case Ink's colorize.ts throws on) is caught by vue-tui's error + // boundary, not the post-flush paint pass where a throw wedges the + // scheduler. See assertValidBackgroundColor / Ink colorize.ts (40b3a75). + // + // Gated to mirror Ink WHERE it colorizes. Ink's attaches a + // colorizing `transform` to the ink-text node, but that transform only + // runs on NON-EMPTY text: squash-text-nodes.ts applies it per child only + // when `nodeText.length > 0`, and render-node-to-output.ts writes (and so + // colorizes) only when the squashed `text.length > 0`. So Ink does NOT + // throw for text that squashes to empty — even though it still renders the + // (empty) node. The `children === null/undefined` early-return above only + // catches a literal null/undefined child; a `{""}` empty-string child (or + // a group of only empty/inert children) gets past it, so we additionally + // skip validation when the content would render empty. (A12) + if (wouldRenderNonEmptyText(children)) { + assertValidBackgroundColor(props.backgroundColor); + } + const insideText = isInsideText(); if (insideText) { return h("virtual-text", props as never, children); @@ -69,6 +93,31 @@ export const Text = TextImpl as WithChildren; /** Props accepted by `` — the vue-tui analogue of Ink's `TextProps`. */ export type TextProps = ExtractPublicPropTypes; +/** + * Heuristic for Ink's "would this colorize?" condition: its transform + * only runs on non-empty squashed text (squash-text-nodes.ts gates each child on + * `nodeText.length > 0`; render-node-to-output.ts writes only when the whole + * `text.length > 0`). We can't squash at render time (nested content isn't + * known yet), so we conservatively answer true unless the content is provably + * empty. Provably-empty = an `""`/whitespace-collapsing string, or a children + * array whose every entry is an empty-string text vnode or an inert Comment + * (Vue's materialization of a `null`/`false`/`v-if` child). ANY element/component + * child or any non-empty text makes it true — matching Ink, which would then + * colorize the squashed result. The only residual over-throw is a contrived + * element child that itself squashes to empty (e.g. a nested empty ); that + * is the same irreducible class as Box's content-area gate. + */ +function wouldRenderNonEmptyText(children: string | VNode[]): boolean { + if (typeof children === "string") return children.length > 0; + return !children.every((child) => { + if (!isVNode(child)) return false; + if (child.type === Comment) return true; + // A Vue text vnode carries its string in `children`; empty ⇒ no text. + if (child.type === VueText) return typeof child.children !== "string" || child.children === ""; + return false; + }); +} + function isInsideText(): boolean { let parent = getCurrentInstance()?.parent; while (parent) { diff --git a/packages/runtime/src/paint/text-style.test.ts b/packages/runtime/src/paint/text-style.test.ts index 732d09a..a8a36c1 100644 --- a/packages/runtime/src/paint/text-style.test.ts +++ b/packages/runtime/src/paint/text-style.test.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import { expect, test } from "vite-plus/test"; -import { applyChalk } from "./text-style.ts"; +import { applyChalk, assertValidBackgroundColor, isInvalidBackgroundColor } from "./text-style.ts"; test("named color applies chalk method", () => { const prev = chalk.level; @@ -157,3 +157,68 @@ test("level 0 emits no ANSI codes regardless of styles", () => { chalk.level = prev; } }); + +// A12: a chalk-MODIFIER name as a BACKGROUND is what Ink colorize.ts throws on +// (`'bold' in chalk` true, but `chalk.bgBold` is not a function). vue-tui detects +// it at render so the error boundary catches it. Every other form is valid. +test("isInvalidBackgroundColor: chalk modifier names are invalid backgrounds", () => { + for (const m of [ + "bold", + "dim", + "italic", + "underline", + "inverse", + "hidden", + "strikethrough", + "reset", + "overline", + "visible", + ]) { + expect(isInvalidBackgroundColor(m)).toBe(true); + } +}); + +test("isInvalidBackgroundColor: real colors / hex / ansi256 / rgb / tuple / unknown / empty are valid", () => { + for (const ok of [ + "red", + "blue", + "blackBright", + "redBright", + "#ff0000", + "ansi256(9)", + "rgb(1,2,3)", + "not-a-real-color", + "", + undefined, + null, + [1, 2, 3], + ]) { + expect(isInvalidBackgroundColor(ok)).toBe(false); + } +}); + +test("assertValidBackgroundColor throws only for a modifier name, with the label in the message", () => { + expect(() => assertValidBackgroundColor("bold")).toThrow(/backgroundColor/i); + expect(() => assertValidBackgroundColor("dim", "borderTopBackgroundColor")).toThrow( + /borderTopBackgroundColor/, + ); + // No throw for valid forms. + expect(() => assertValidBackgroundColor("red")).not.toThrow(); + expect(() => assertValidBackgroundColor("#abcdef")).not.toThrow(); + expect(() => assertValidBackgroundColor("not-a-real-color")).not.toThrow(); + expect(() => assertValidBackgroundColor([1, 2, 3])).not.toThrow(); + expect(() => assertValidBackgroundColor(undefined)).not.toThrow(); +}); + +// Foreground is UNAFFECTED: `color="bold"` resolves `chalk.bold` (a real fn) and +// applies the modifier — Ink does NOT throw on a foreground modifier name, and +// neither does vue-tui. (Only the bg path has the missing-`bg*`-method problem.) +test("foreground modifier name still applies (no validation on color)", () => { + const prev = chalk.level; + chalk.level = 1; + try { + expect(applyChalk("X", { color: "bold" })).toBe(chalk.bold("X")); + } finally { + chalk.level = prev; + } +}); diff --git a/packages/runtime/src/paint/text-style.ts b/packages/runtime/src/paint/text-style.ts index 25f0149..7111c5b 100644 --- a/packages/runtime/src/paint/text-style.ts +++ b/packages/runtime/src/paint/text-style.ts @@ -45,6 +45,50 @@ function bgKey(name: string): string { return "bg" + name.charAt(0).toUpperCase() + name.slice(1); } +/** + * Detect a backgroundColor value that Ink's `colorize` would THROW on. + * + * Ink colorize.ts (commit 40b3a75): for a BACKGROUND it tests `isNamedColor` = + * `color in chalk`; if so it builds `bg${Capitalize(color)}` and calls + * `chalk[methodName]`. A chalk MODIFIER name (`bold`/`dim`/`italic`/`underline`/ + * `inverse`/`hidden`/`strikethrough`/`reset`/`overline`/`visible`) is `in chalk` + * but has NO `bg*` method, so the call is `chalk[undefined-method](str)` and throws + * "chalk.bgBold is not a function". A chalk COLOR name resolves to a real `bg*` + * method (works); a string NOT in chalk falls through to bare text (no throw). + * + * vue-tui mirrors that throw, but VALIDATES here at component-render time (not in + * paint): a raw throw in the post-flush paint pass unwinds through Vue's + * flushPostFlushCbs and wedges the scheduler, where onErrorCaptured can't catch it + * (cf. the borderStyle fix #124). Returning a flag lets the component throw during + * render so vue-tui's error boundary (onErrorCaptured → ErrorOverview) handles it. + * + * Only the in-chalk-but-no-bg-method case is rejected; valid colors, hex, + * ansi256, rgb, `[r,g,b]` tuples, and unknown non-chalk strings all return false. + */ +export function isInvalidBackgroundColor(color: unknown): boolean { + // Only a non-empty STRING can be a chalk name. Arrays ([r,g,b]), undefined, + // null, hex/ansi256/rgb strings (not `in chalk`) all fall through to `false`. + if (typeof color !== "string" || color.length === 0) return false; + const isInChalk = color in (chalk as unknown as Record); + if (!isInChalk) return false; + const bgMethod = (chalk as unknown as Record)[bgKey(color)]; + return typeof bgMethod !== "function"; +} + +/** + * Throw (during component render) if `color` is a chalk-modifier-name + * backgroundColor — the exact case Ink's colorize.ts throws on. No-op for every + * valid background form. `label` names the offending prop in the message. + */ +export function assertValidBackgroundColor(color: unknown, label = "backgroundColor"): void { + if (isInvalidBackgroundColor(color)) { + throw new Error( + `Invalid ${label}: ${JSON.stringify(color)} (chalk has no bg method for it — ` + + `it is a text modifier, not a background color)`, + ); + } +} + export function applyChalk(text: string, props: TextProps): string { // Mirror Ink's Text.tsx `transform` (commit 40b3a75): apply each enabled // style as its OWN nested chalk call, in the exact order