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 integers on 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). The wire format, snapshot encoding, and renderer are unchanged — strings are resolved to their packed ARGB value at the transfer-encoding boundary.

Invalid hex is caught at three layers: a compile-time type error for string literals, a `false` return from `validate()`, and a `TypeError` thrown by `pack()` before any bytes are written.

#### Migration

Color fields previously typed `number` are now typed `number | string`. Existing `rgba()` calls and numeric literals are unaffected. If you have wrapper types or overloads that constrain color fields to `number`, widen them to accept `string`:

```diff
-function themed(bg: number): OpenElement {
+function themed(bg: number | string): OpenElement {
   return open("root", { bg });
 }
```

"@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 @@ -80,20 +80,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 @@ -189,7 +189,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 @@ -295,13 +295,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 @@ -341,6 +346,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 @@ -371,8 +408,8 @@ export type BorderSide =
| number
| {
width: number;
color?: number;
bg?: number;
color?: Color;
bg?: Color;
};

export interface OpenElement {
Expand All @@ -387,11 +424,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 @@ -479,8 +516,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 @@ -495,16 +532,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
48 changes: 43 additions & 5 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,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 @@ -707,6 +707,41 @@ 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 @@ -930,7 +965,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 @@ -992,7 +1028,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 @@ -1151,8 +1188,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