Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/hex-color-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---

@bombshell-cooper bombshell-cooper Bot Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset should start with an approved active present-tense verb. Changeset package scope does not match the affected packages confidently.

View the proposed replacement
---
'@bomb.sh/tty': minor
---

Adds CSS-style hex color strings as an alternative to `rgba()` packed numbers for every color field.

`open({ bg })`, `border.color`, `border.bg`, per-side `color`/`bg`, and `text({ color, bg })` now accept `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` strings (case-insensitive, `#` required). Strings are expanded to their packed ARGB value at the transfer-encoding boundary; the wire format and renderer are unchanged.

Invalid hex strings are rejected by `validate()` and throw a `TypeError` from the packer before any bytes are produced. String literals are checked against the hex grammar at compile time — an invalid hex literal is a TypeScript type error at the call site.

#### Migration

This release changes the `Color` type accepted by color fields from `number` to `number | string`. Existing numeric `rgba()` usage is unaffected.

```diff
- open("box", { bg: rgba(255, 136, 0) })
+ open("box", { bg: "#ff8800" })   // hex string — or keep rgba(), both work
```

"@bomb.sh/tty": minor
---

Directive color fields accept CSS-style hex color strings. `open({ bg })`,
`border.color`/`border.bg` and per-side `color`/`bg`, and
`text({ color, bg })` take `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA`
(case-insensitive, `#` required) in addition to packed ARGB numbers from
`rgba()`. Strings expand to the packed value at the transfer-encoding
boundary, so the wire format, snapshots, and the renderer are unchanged.
Invalid hex is a compile-time type error on literals, rejected by
`validate()`, and throws a `TypeError` from the packer before any bytes are
produced.
136 changes: 118 additions & 18 deletions ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,20 @@ function sideWidth(side: BorderSide | undefined): number {
return typeof side === "number" ? side : side?.width ?? 0;
}

function sideFg(side: BorderSide | undefined, shared: number): number {
function sideFg(side: BorderSide | undefined, shared: Color): number {
let color = typeof side === "object" && side.color !== undefined
? side.color
: shared;
return color & 0x00FFFFFF;
return colorValue(color) & 0x00FFFFFF;
}

function sideBg(
side: BorderSide | undefined,
shared: number | undefined,
shared: Color | undefined,
): number {
let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared;
// ATTR_DEFAULT sentinel (bit 31 set) means "keep the existing cell bg"
return bg === undefined ? 0x80000000 : bg & 0x00FFFFFF;
return bg === undefined ? 0x80000000 : colorValue(bg) & 0x00FFFFFF;
}

function packString(
Expand Down Expand Up @@ -196,7 +196,7 @@ export function pack(
}

if (op.bg !== undefined) {
view.setUint32(o, op.bg, true);
view.setUint32(o, colorValue(op.bg), true);
o += 4;
}

Expand Down Expand Up @@ -307,13 +307,18 @@ export function pack(
// 0 and setting ATTR_DEFAULT (0x80 in the attrs byte). The C path ORs
// it into fg and emit_attr skips the foreground SGR (mirrors unset bg).
let textDefault = op.color === undefined;
view.setUint32(o, op.color ?? 0, true);
view.setUint32(
o,
op.color === undefined ? 0 : colorValue(op.color),
true,
);
o += 4;

// No explicit bg: leave the terminal default bg by writing
// 0 and setting ATTR_DEFAULT (0x80 in the attrs byte). The C path ORs
// it into bg and emit_attr skips the background SGR
let bg = op.bg === undefined ? 0x80000000 : op.bg & 0x00FFFFFF;
let bg = op.bg === undefined ? 0x80000000 : colorValue(op.bg) &
0x00FFFFFF;
view.setUint32(o, bg, true);
o += 4;

Expand Down Expand Up @@ -353,6 +358,38 @@ export function rgba(r: number, g: number, b: number, a = 255): number {
(b & 0xFF);
}

/**
* A color value: a packed ARGB `number` as produced by `rgba()`, or a hex
* color `string` (§8.5). Strings are expanded to the packed value at the
* transfer-encoding boundary; only numbers reach the wire.
*/
export type Color = number | string;

/* §8.5 hex grammar: "#" followed by exactly 3, 4, 6, or 8 hex digits. */
const HEX_COLOR =
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;

/**
* Resolves a color value to the packed ARGB number the transfer encoding
* carries (§8.5). Numbers pass through; hex strings expand — 3/4-digit
* forms double each digit, 6-digit forms gain alpha 255 — from the
* string's RRGGBBAA digit order into the word's AARRGGBB layout. Invalid
* hex throws rather than decoding to a garbage color.
*/
function colorValue(color: Color): number {
if (typeof color === "number") return color;
if (!HEX_COLOR.test(color)) {
throw new TypeError(`invalid hex color: "${color}"`);
}
let body = color.slice(1);
if (body.length === 3 || body.length === 4) {
body = body.replace(/./g, "$&$&");
}
if (body.length === 6) body += "ff";
let value = parseInt(body, 16);
return (value >>> 8) | ((value & 0xFF) << 24);
}

export type SizingAxis =
| { type: "fit"; min?: number; max?: number }
| { type: "grow"; min?: number; max?: number }
Expand Down Expand Up @@ -383,8 +420,8 @@ export type BorderSide =
| number
| {
width: number;
color?: number;
bg?: number;
color?: Color;
bg?: Color;
};

export interface OpenElement {
Expand All @@ -399,11 +436,11 @@ export interface OpenElement {
alignX?: "left" | "center" | "right";
alignY?: "top" | "center" | "bottom";
};
bg?: number;
bg?: Color;
cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number };
border?: {
color: number;
bg?: number;
color: Color;
bg?: Color;
left?: BorderSide;
right?: BorderSide;
top?: BorderSide;
Expand Down Expand Up @@ -492,8 +529,8 @@ function encodeClipTo(value: ClipTo | undefined): number {
export interface Text {
directive: typeof OP_TEXT;
content: string;
color?: number;
bg?: number;
color?: Color;
bg?: Color;
fontSize?: number;
fontId?: number;
wrap?: number;
Expand All @@ -508,16 +545,79 @@ interface Snapshot {

export type Op = OpenElement | Text | CloseElement | Snapshot;

export function open(
/*
* Compile-time color checking (§8.5). A string literal assigned to a color
* field must match the hex grammar — the type-level equivalent of
* `#${string{3,4,6,8}}`. Strings that are not literals (computed colors) and
* values already narrowed to `#${string}` pass through; the runtime grammar
* check in validate()/pack() is normative for them. Plain unions like
* `HexRun<string>` would need ~16^6 union members, which TypeScript cannot
* represent (TS2590), so checking runs per-character via recursion instead.
*/

type HexDigit =
| "0"
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "a"
| "b"
| "c"
| "d"
| "e"
| "f"
| "A"
| "B"
| "C"
| "D"
| "E"
| "F";

type HexRun<S extends string, C extends readonly 0[] = []> = S extends ""
? (C["length"] extends 3 | 4 | 6 | 8 ? true : false)
: S extends `${HexDigit}${infer Rest}` ? HexRun<Rest, [...C, 0]>
: false;

type CheckedColor<T> = T extends number ? T
: T extends string ? string extends T ? T
: `#${string}` extends T ? T
: T extends `#${infer Body}` ? (HexRun<Body> extends true ? T : never)
: never
: T;

/* Applies CheckedColor to the color fields only; other fields pass through. */
type CheckedColors<T> = {
[K in keyof T]: K extends "bg" | "color" ? CheckedColor<T[K]>
: T[K] extends object ? CheckedColors<T[K]>
: T[K];
};

export function open<
P extends Omit<OpenElement, "directive" | "id"> = Omit<
OpenElement,
"directive" | "id"
>,
>(
id: string,
props: Omit<OpenElement, "directive" | "id"> = {},
props: P & NoInfer<CheckedColors<P>> = {} as P & NoInfer<CheckedColors<P>>,
): OpenElement {
return { directive: OP_OPEN_ELEMENT, id, ...props };
}

export function text(
export function text<
P extends Omit<Text, "directive" | "content"> = Omit<
Text,
"directive" | "content"
>,
>(
content: string,
props: Omit<Text, "directive" | "content"> = {},
props: P & NoInfer<CheckedColors<P>> = {} as P & NoInfer<CheckedColors<P>>,
): Text {
return { directive: OP_TEXT, content, ...props };
}
Expand Down
85 changes: 80 additions & 5 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,44 @@ To restore before exit, the caller renders a frame without `pointer`, or passes
`{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and
writes the result.

### 7.9 Synchronized output (DEC mode 2026)

When the `syncOutput` runtime capability is true, each full-screen frame
(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized Update
(`CSI ? 2026 h`) before the frame's first emitted byte and End Synchronized
Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between
the pair and presents it as one atomic repaint, so a frame never tears
mid-update.

**Capability evidence.** `syncOutput` originates exclusively from the
`sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser
reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2,
and 3 (set, reset, permanently set) report support; 0 and 4 report none. The
static `Capabilities` never seed this field: it is false until a probe reply is
folded in through `update()`.

**Invalidation and timing.** The renderer reads the capability snapshot at each
render transaction, so a capability flip takes effect on the next rendered
frame. No invalidation signal exists, and none is required: a flip between
frames changes only whether the next frame is wrapped.

**Normative rules:**

- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`.
- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in
the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be
interleaved with foreign content, and an unterminated synchronized block would
suppress it.
- A full-screen render whose diff produces no cell changes and no caret
transition MUST emit zero bytes, even when `syncOutput` is true. The renderer
decides after the diff, so no-op frames carry neither the wrap prefix nor the
suffix.
- Terminals that receive the sequences without supporting the mode ignore it by
design; the probe still refines behavior for terminals that report mode 0
or 4.

This section discharges the synchronized-output deferral recorded in §7.8.

---

## 8. Public Rendering API
Expand Down Expand Up @@ -749,7 +787,7 @@ fit(min?: number, max?: number): SizingAxis
The element sizes to fit its content, optionally constrained by minimum and
maximum bounds.

### 8.5 Color helper
### 8.5 Color values

```
rgba(r: number, g: number, b: number, a?: number): number
Expand All @@ -759,6 +797,40 @@ Packs color channel values (each 0–255) into a single 32-bit integer in ARGB
format. Alpha defaults to 255 (fully opaque). The returned value is used
wherever the directive model expects a color.

**Color values.** A color value is either a packed ARGB `number`, as produced by
`rgba()`, or a hexadecimal color `string`. Every directive field described as a
color — `open({ bg })`, `border.color`, `border.bg`, the `color` and `bg` fields
of structured border sides, and `text({ color, bg })` — accepts both forms. The
forms are exactly equivalent: each hex string is converted to its packed ARGB
value at the transfer-encoding boundary (§9.2), so hex strings MUST NOT appear
in the transfer encoding. The wire format, snapshot encoding, and cell
representation are unchanged by this acceptance, and the numeric and string
forms of the same color produce bit-identical packed values.

**Hex grammar.** A hex color string is `#` followed by exactly 3, 4, 6, or 8
hexadecimal digits, case-insensitive. The `#` is required. The 3- and 4-digit
forms expand by doubling each digit: `#f80` is `#ff8800`, and `#f80a` is
`#ff8800aa`. The 6- and 8-digit forms are taken literally. Alpha defaults to 255
in the 3- and 6-digit forms; the 4- and 8-digit forms supply alpha as their
final digit pair (or digit, doubled).

**Invalid hex.** A string that does not match the hex grammar is invalid
directive input (§9.1). `validate()` reports it as an invalid field value. The
packer MUST reject a directive array containing an invalid hex color string by
throwing a `TypeError` before producing any bytes, rather than emitting a
partially decoded or garbage color value.

**Static checking.** In TypeScript, color fields are typed `number | string`.
The directive constructors check string literals against the hex grammar at
compile time — the type-level equivalent of `#${string{3,4,6,8}}` — so an
invalid hex literal is a type error at the call site. Strings whose value is
only known at runtime (colors read from a theme or configuration, for example)
pass the type check as `string` and are checked by `validate()` and the packer
at render time. The string form is deliberately restricted to the hex grammar
for now: a future version may accept the broader CSS Color 4 syntax, which is
why the static type remains the plain `number | string` shape rather than a
narrower prefix-pattern type.

### 8.6 Term update

```
Expand Down Expand Up @@ -983,7 +1055,8 @@ The `open()` constructor currently accepts the following property groups in its
`bottom`, `left`) accepts either a scalar width or a structured object
`{ width, color?, bg? }`. The shared `color` field is required and is the
fallback foreground for every side; the optional shared `bg` field is the
fallback border-cell background for every side
fallback border-cell background for every side. Color fields accept the color
values of §8.5 (packed ARGB number or hex string)
- **`cornerRadius`** — per-corner radius values, producing rounded box-drawing
characters
- **`clip`** — Declares the element as a clip region (see §7.5). Currently
Expand Down Expand Up @@ -1046,7 +1119,8 @@ input or lets it pass through, `clipTo` controls inherited clipping, and

The `text()` constructor accepts: `color`, `bg`, `fontSize`, `letterSpacing`,
`lineHeight`, and attribute flags (`bold`, `italic`, `underline`,
`strikethrough`).
`strikethrough`). The `color` and `bg` fields accept the color values of §8.5
(packed ARGB number or hex string).

These property groups represent the current implementation surface. New groups
and fields have been added incrementally and more may follow.
Expand Down Expand Up @@ -1205,8 +1279,9 @@ acknowledged gaps, and the approach may evolve.
### 12.5 Validation and packing

**`validate(ops)`** — A public API function that checks a directive array for
structural errors (unbalanced open/close pairs, invalid field types). Exported
and used in tests.
structural errors (unbalanced open/close pairs, invalid field types — including
color fields, which must be a packed ARGB number or a §8.5 hex color string).
Exported and used in tests.

**`pack(ops, mem, offset)`** — An internal function that serializes a directive
array into the transfer encoding described in Section 12.1. Currently exported
Expand Down
28 changes: 28 additions & 0 deletions test/border.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,3 +647,31 @@ describe("box model", () => {
expect(lines[2]).toBe("└────────────┘");
});
});

describe("hex colors", () => {
it("renders side colors from hex strings with shared fallback", async () => {
let parsed = await renderBox({
border: {
color: "#ff0000",
bg: "#0000ff",
top: { width: 1, bg: "#00ff00" },
right: 1,
bottom: { width: 1, color: "#ffffff" },
left: 1,
},
});

// top: own bg, shared fg
expect(at(parsed, 3, 0).fg).toBe(FG.red);
expect(at(parsed, 3, 0).bg).toBe(BG.green);
// right: shared fg, shared bg
expect(at(parsed, 7, 1).fg).toBe(FG.red);
expect(at(parsed, 7, 1).bg).toBe(BG.blue);
// bottom: own fg, shared bg
expect(at(parsed, 3, 3).fg).toBe(FG.white);
expect(at(parsed, 3, 3).bg).toBe(BG.blue);
// left: shared fg, shared bg
expect(at(parsed, 0, 1).fg).toBe(FG.red);
expect(at(parsed, 0, 1).bg).toBe(BG.blue);
});
});
Loading
Loading