From 12efbe6900fa8adaf6b0080b3bb29b13f5e7f191 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 21:30:36 +0800 Subject: [PATCH] fix(runtime): border edge SGR nests dim outermost, matching Ink (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A border edge combining dim with a foreground and/or background color emitted a different SGR nesting order than Ink (vue: dim innermost via the shared Text applyChalk; Ink: dim OUTERMOST). Visually identical, but the byte stream diverged. Give colorizeEdge its own stylePiece ordering matching render-border.ts (fg, then bg, then chalk.dim last/outermost) instead of routing border edges through applyChalk — whose dim-innermost order is correct for and is left unchanged. colorizeEdge is the single shared path for all four edges. Exact-byte tests lock the top edge, a side rail, and the fg+bg (no-dim) subset; the previously-lax `.toContain('[31m')` tests (duplicated across borders and background-color) are upgraded. Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/background-color.test.tsx | 12 ++-- .../integration/components/borders.test.tsx | 66 +++++++++++++++++-- packages/runtime/src/paint/paint.ts | 20 ++++-- 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx index 81a1553..63d6be6 100644 --- a/packages/runtime-tests/integration/components/background-color.test.tsx +++ b/packages/runtime-tests/integration/components/background-color.test.tsx @@ -592,8 +592,12 @@ test("foreground, background and dim combine correctly", async ({ expect }) => { { columns: 100 }, ); const output = lastFrame()!; - // red FG (31), cyan BG (46), dim (2) - expect(output).toContain("[31m"); - expect(output).toContain("[46m"); - expect(output).toContain("[2m"); + // EXACT-byte parity with Ink's render-border.ts stylePiece (commit 40b3a75, + // lines 7-20): fg innermost, then bg, then dim outermost. Open codes nest + // dim(2) -> bg(46) -> fg(31); close fg(39) -> bg(49) -> dim(22). The lax + // `.toContain('[31m')` form this replaces would pass even with the wrong + // Text-style (dim-innermost) nesting. + const topLine = output.split("\n")[0]!; + expect(topLine).toContain("\x1b[2m\x1b[46m\x1b[31m"); + expect(topLine).toContain("\x1b[39m\x1b[49m\x1b[22m"); }); diff --git a/packages/runtime-tests/integration/components/borders.test.tsx b/packages/runtime-tests/integration/components/borders.test.tsx index 2a9b213..853c4a3 100644 --- a/packages/runtime-tests/integration/components/borders.test.tsx +++ b/packages/runtime-tests/integration/components/borders.test.tsx @@ -1199,10 +1199,68 @@ test("foreground, background and dim combine correctly", async ({ expect }) => { { columns: 100 }, ); const frame = lastFrame()!; - // red FG=31, cyan BG=46, dim=2 - expect(frame).toContain("[31m"); - expect(frame).toContain("[46m"); - expect(frame).toContain("[2m"); + // EXACT-byte parity with Ink's render-border.ts stylePiece (commit 40b3a75, + // lines 7-20): fg INNERMOST, then bg, then dim OUTERMOST — + // chalk.dim(chalk.bgCyan(chalk.red(glyphs))). With chalk level 3 the open + // codes nest dim(2) -> bg(46) -> fg(31) and close fg(39) -> bg(49) -> dim(22). + // A lax `.toContain('[31m')` etc. would pass even with the wrong (Text-style, + // dim-innermost) nesting, so assert the precise byte windows. + const topLine = frame.split("\n")[0]!; + expect(topLine).toContain("\x1b[2m\x1b[46m\x1b[31m"); + expect(topLine).toContain("\x1b[39m\x1b[49m\x1b[22m"); +}); + +test("border side rails: foreground, background and dim combine in Ink byte order", async ({ + expect, +}) => { + // Exercises a vertical edge (left rail) — colorizeEdge is the single shared + // path for all four edges, so this guards the same SGR ordering on the rails. + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + const frame = lastFrame()!; + // The left rail glyph │ lives on the content row (row index 1). Same Ink + // stylePiece order as the top edge: dim(2) -> bg(46) -> fg(31), close + // fg(39) -> bg(49) -> dim(22). + const railRow = frame.split("\n")[1]!; + expect(railRow).toContain("\x1b[2m\x1b[46m\x1b[31m"); + expect(railRow).toContain("\x1b[39m\x1b[49m\x1b[22m"); +}); + +test("border foreground + background (no dim) nests bg outer, fg inner like Ink", async ({ + expect, +}) => { + // The no-dim subset must also match Ink's stylePiece: colorize(colorize(glyph, + // fg,'foreground'), bg,'background') => bg(46) outer, fg(31) inner, no dim wrap. + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + const topLine = lastFrame()!.split("\n")[0]!; + expect(topLine).toContain("\x1b[46m\x1b[31m"); + expect(topLine).toContain("\x1b[39m\x1b[49m"); + // and NO dim code on this edge + expect(topLine).not.toContain("\x1b[2m"); }); // G05 — height-1 box with side-only borders renders rails (Ink parity) diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index df7dbc1..071948c 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -7,7 +7,8 @@ import { styledCharsToString, tokenize, } from "@alcalzone/ansi-tokenize"; -import { applyChalk } from "./text-style.ts"; +import chalk from "chalk"; +import { applyChalk, applyColor } from "./text-style.ts"; import { sanitizeAnsi } from "./sanitize-ansi.ts"; import Yoga from "yoga-layout"; import type { @@ -494,11 +495,18 @@ function drawBorder( const edgeBg = (props[`border${capEdge}BackgroundColor`] as string | undefined) ?? (props["borderBackgroundColor"] as string | undefined); - const p: TextProps = {}; - if (edgeColor) p.color = edgeColor; - if (edgeBg) p.backgroundColor = edgeBg; - if (edgeDim) p.dimColor = true; - return Object.keys(p).length > 0 ? applyChalk(s, p) : s; + // Border SGR nesting deliberately differs from 's. Mirror Ink's + // render-border.ts stylePiece (commit 40b3a75, lines 7-20) EXACTLY: + // foreground innermost, then background, then `chalk.dim` OUTERMOST — + // i.e. chalk.dim(bg(fg(glyphs))). This is NOT applyChalk's order, whose + // dim-innermost nesting is correct for (Ink Text.tsx) and must + // stay unchanged. Routing edges through applyChalk would emit the bytes + // in the wrong order (bg, fg, dim) versus Ink's (dim, bg, fg). + let styled = s; + if (edgeColor) styled = applyColor(chalk, edgeColor as never, false)(styled); + if (edgeBg) styled = applyColor(chalk, edgeBg as never, true)(styled); + if (edgeDim) styled = chalk.dim(styled); + return styled; } if (top) {