diff --git a/packages/@wterm/dom/src/__tests__/upper-block-alignment.test.ts b/packages/@wterm/dom/src/__tests__/upper-block-alignment.test.ts new file mode 100644 index 0000000..3fd3f97 --- /dev/null +++ b/packages/@wterm/dom/src/__tests__/upper-block-alignment.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Renderer } from "../renderer.js"; +import type { CellData, CursorState } from "@wterm/core"; + +/** + * Regression coverage for the U+2594 (UPPER ONE EIGHTH BLOCK) horizontal-rule + * alignment bug observed when wterm renders Claude Code's TUI. + * + * Claude Code draws horizontal rules as a run of U+2594 cells. The DOM + * renderer paints these via a CSS linear-gradient on a `.term-block` span. + * The original implementation used `linear-gradient( 12.5%, 12.5%)`, + * which at the canonical row-height of 17px resolves to a 2.125px stop — + * a sub-pixel boundary the browser must round inconsistently from cell to + * cell, producing the visible jog at every cell boundary and 1px gaps / + * overlaps with the row below. + * + * The fix snaps the gradient stop to a whole-pixel boundary using CSS + * `round()` against the `--term-row-height` custom property, so every cell + * paints the eighth-block at the same physical pixel and the row directly + * below sits flush against it. + * + * These tests assert on the emitted CSS string because jsdom / happy-dom + * do no real layout — pixel-level assertions are not possible without a + * real browser. Pixel rendering is exercised via the e2e suite. + */ + +function createMockBridge(cols: number, rows: number, grid: CellData[][] = []) { + const dirtyRows = new Set(); + for (let r = 0; r < rows; r++) dirtyRows.add(r); + + return { + getCols: () => cols, + getRows: () => rows, + getCell: (row: number, col: number): CellData => + grid[row]?.[col] ?? { char: 0, fg: 256, bg: 256, flags: 0 }, + isDirtyRow: (row: number) => dirtyRows.has(row), + clearDirty: () => dirtyRows.clear(), + getCursor: (): CursorState => ({ row: 1, col: 0, visible: false }), + getScrollbackCount: () => 0, + getScrollbackCell: (_offset: number, _col: number): CellData => ({ + char: 0, + fg: 256, + bg: 256, + flags: 0, + }), + getScrollbackLineLen: () => 0, + }; +} + +function makeCell(char: string, fg = 256, bg = 256, flags = 0): CellData { + return { char: char.codePointAt(0)!, fg, bg, flags }; +} + +describe("U+2594 horizontal-rule alignment (Claude Code TUI)", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + it("renders U+2594 cells with a pixel-snapped gradient stop, not a sub-pixel %", () => { + // Five U+2594 cells across row 0; five U+2502 (vertical bar) on row 1 + // at the same columns. This is the exact pattern Claude Code emits for + // a horizontal rule followed by box-drawing. + const upper = makeCell("▔"); + const vbar = makeCell("│"); + const grid: CellData[][] = [ + [upper, upper, upper, upper, upper], + [vbar, vbar, vbar, vbar, vbar], + ]; + const bridge = createMockBridge(5, 2, grid); + const renderer = new Renderer(container); + renderer.render(bridge as any); + + const blocks = container.querySelectorAll(".term-block"); + expect(blocks).toHaveLength(5); + + // Every block span must carry a background gradient. + for (const block of Array.from(blocks)) { + const style = block.getAttribute("style") ?? ""; + expect(style).toMatch(/linear-gradient\(/); + } + + // The sub-pixel `12.5%` stop is the bug — after the fix, no block + // span should emit a percentage-based vertical gradient stop for + // U+2594. The stop must be expressed in a length that snaps to a + // whole pixel (e.g. via `round(... , 1px)` or a px-typed calc). + const firstStyle = blocks[0].getAttribute("style") ?? ""; + expect( + firstStyle, + "U+2594 gradient stop must be pixel-snapped, not the sub-pixel 12.5% that caused the alignment jog", + ).not.toMatch(/\b12\.5%/); + + // Positive assertion: a pixel-typed stop appears (either `round(...)` + // or an explicit `px` length). Either keeps the gradient stop on a + // device pixel. + expect(firstStyle).toMatch(/round\(|\bpx\b/); + + // All five U+2594 cells must share an identical background string so + // adjacent cells paint at the same pixel — any divergence reintroduces + // the per-cell jog symptom. + const styles = Array.from(blocks).map((b) => b.getAttribute("style")); + for (let i = 1; i < styles.length; i++) { + expect(styles[i]).toBe(styles[0]); + } + }); + + it("U+2594 spans inherit the .term-row > span vertical-align rule (no baseline jog)", () => { + // The .term-row > span selector in terminal.css sets vertical-align: top + // on every child span. Confirm the U+2594 span is a plain that + // the selector matches, not a different element type. (jsdom doesn't + // resolve computed styles from stylesheets, so we verify the structural + // contract the CSS depends on.) + const grid: CellData[][] = [[makeCell("▔"), makeCell("▔")]]; + const bridge = createMockBridge(2, 1, grid); + const renderer = new Renderer(container); + renderer.render(bridge as any); + + const row = container.querySelector(".term-row"); + expect(row).not.toBeNull(); + const directSpanChildren = row?.querySelectorAll(":scope > span"); + expect(directSpanChildren?.length).toBeGreaterThan(0); + for (const child of Array.from(directSpanChildren ?? [])) { + expect(child.tagName).toBe("SPAN"); + expect(child.classList.contains("term-block")).toBe(true); + } + }); +}); diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index a5ed237..6583441 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -120,24 +120,38 @@ function resolveColors( }; } +// Pixel-snapped vertical gradient stops keyed off `--term-row-height` so that +// every cell paints the eighth-block boundary on the same physical pixel — +// using raw percentages (e.g. `12.5%`) at the canonical 17px row-height +// resolves to 2.125px and the browser rounds it differently across cells, +// producing the per-cell jog Claude Code's horizontal-rule (`▔▔▔▔▔`) makes +// visible against the row immediately below. +const SNAP_1_8 = "round(calc(var(--term-row-height) * 0.125), 1px)"; +const SNAP_2_8 = "round(calc(var(--term-row-height) * 0.25), 1px)"; +const SNAP_3_8 = "round(calc(var(--term-row-height) * 0.375), 1px)"; +const SNAP_4_8 = "round(calc(var(--term-row-height) * 0.5), 1px)"; +const SNAP_5_8 = "round(calc(var(--term-row-height) * 0.625), 1px)"; +const SNAP_6_8 = "round(calc(var(--term-row-height) * 0.75), 1px)"; +const SNAP_7_8 = "round(calc(var(--term-row-height) * 0.875), 1px)"; + function getBlockBackground(cp: number, fg: string, bg: string): string { switch (cp) { case 0x2580: - return `linear-gradient(${fg} 50%,${bg} 50%)`; + return `linear-gradient(${fg} ${SNAP_4_8},${bg} ${SNAP_4_8})`; case 0x2581: - return `linear-gradient(${bg} 87.5%,${fg} 87.5%)`; + return `linear-gradient(${bg} ${SNAP_7_8},${fg} ${SNAP_7_8})`; case 0x2582: - return `linear-gradient(${bg} 75%,${fg} 75%)`; + return `linear-gradient(${bg} ${SNAP_6_8},${fg} ${SNAP_6_8})`; case 0x2583: - return `linear-gradient(${bg} 62.5%,${fg} 62.5%)`; + return `linear-gradient(${bg} ${SNAP_5_8},${fg} ${SNAP_5_8})`; case 0x2584: - return `linear-gradient(${bg} 50%,${fg} 50%)`; + return `linear-gradient(${bg} ${SNAP_4_8},${fg} ${SNAP_4_8})`; case 0x2585: - return `linear-gradient(${bg} 37.5%,${fg} 37.5%)`; + return `linear-gradient(${bg} ${SNAP_3_8},${fg} ${SNAP_3_8})`; case 0x2586: - return `linear-gradient(${bg} 25%,${fg} 25%)`; + return `linear-gradient(${bg} ${SNAP_2_8},${fg} ${SNAP_2_8})`; case 0x2587: - return `linear-gradient(${bg} 12.5%,${fg} 12.5%)`; + return `linear-gradient(${bg} ${SNAP_1_8},${fg} ${SNAP_1_8})`; case 0x2588: return fg; case 0x2589: @@ -163,7 +177,7 @@ function getBlockBackground(cp: number, fg: string, bg: string): string { case 0x2593: return `color-mix(in srgb,${fg} 75%,${bg})`; case 0x2594: - return `linear-gradient(${fg} 12.5%,${bg} 12.5%)`; + return `linear-gradient(${fg} ${SNAP_1_8},${bg} ${SNAP_1_8})`; case 0x2595: return `linear-gradient(to right,${bg} 87.5%,${fg} 87.5%)`; default: {