Skip to content
Draft
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
19 changes: 19 additions & 0 deletions .changeset/system-colors-ansi16.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---

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. Changeset does not name the affected public API confidently.

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

Adds support for CSS Color 4 system color keywords (`Canvas`, `CanvasText`, `LinkText`, `GrayText`, `Highlight`, `AccentColor`, and others) in all color fields, case-insensitively.

Each keyword maps to a terminal-themed color rather than a fixed sRGB value, so output follows the user's light or dark terminal theme. Background-default keywords (`Canvas`, `ButtonFace`, `Field`) and foreground-default keywords (`CanvasText`, `ButtonText`, `FieldText`) resolve to the terminal's own default colors when used in their matching field. Palette keywords (`LinkText` → blue, `GrayText` → bright black, etc.) emit ANSI 16-color SGR codes so the terminal's theme palette applies.

Used in the opposite field, a default keyword resolves to the terminal's probed color (from the OSC 10/11 theme report) as an opaque truecolor value — for example, `text({ bg: "CanvasText" })` paints the terminal's actual default foreground as a truecolor background once the theme report arrives, and falls back to the terminal default background until then.

Transitions between a terminal-themed color and any other color change discretely at 50% progress rather than interpolating.

#### Migration

`Canvas` and `CanvasText` were previously rejected as invalid color strings. Any code that relied on that rejection — for example, treating them as errors in validation — will now accept them as valid system colors.

```diff
-// Previously threw / failed validation:
-text("Hi", { color: "Canvas" });
-open("box", { bg: "CanvasText" });
+// Now valid — resolves to the terminal's default foreground/background:
+text("Hi", { color: "Canvas" });   // terminal default foreground
+open("box", { bg: "CanvasText" }); // terminal default bg, or probed fg as truecolor
```

`currentcolor` remains reserved and invalid.

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

Color fields accept the CSS system color keywords (`Canvas`, `CanvasText`,
`LinkText`, `Highlight`, `GrayText`, `Mark`, `AccentColor`, ...),
case-insensitively. Instead of a fixed RGB value, each one renders with the
terminal's own theme: the default foreground or background, or an entry of the
ANSI 16-color palette (`LinkText` is blue, `GrayText` is bright black, and so
on), so output follows the user's light or dark terminal theme. The
default-color keywords mean the default of the attribute they set, so
`text({ bg: "Canvas" })` paints the terminal default background over any
enclosing element background, while an omitted `bg` still keeps it. Named in
the other field instead, a default keyword now paints the terminal's actual
default color once it's known: `text({ bg: "CanvasText" })` renders the
terminal's real default foreground color as an opaque truecolor background,
learned from the terminal's OSC 10/11 theme report, falling back to the
default background until that report arrives. Packed `rgba()` colors and
other color strings render exactly as before.
87 changes: 84 additions & 3 deletions color.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,99 @@
import { convert, parse } from "hueful";

/**
* A §8.5 terminal-themed color: an ANSI palette index (0–15), or a default
* keyword naming the terminal default foreground ("fg-default") or
* background ("bg-default"). Which default it names is fixed by the
* keyword; whether it stays terminal-themed or resolves to a probed sRGB
* color depends on the field it's packed into (see resolveForField).
*/
export interface TerminalColor {
ansi: number | "fg-default" | "bg-default";
}

/** An 8-bit RGB color, structurally compatible with terminfo.ts's Rgb. */
export interface ThemeRgb {
r: number;
g: number;
b: number;
}

/** The theme colors `pack()` resolves cross-field default keywords against. */
export interface ThemeColors {
readonly foreground?: ThemeRgb;
readonly background?: ThemeRgb;
}

export const systemColors = {
canvas: "bg-default",
buttonface: "bg-default",
field: "bg-default",
canvastext: "fg-default",
buttontext: "fg-default",
fieldtext: "fg-default",
activetext: 1,
mark: 3,
linktext: 4,
highlight: 4,
selecteditem: 4,
visitedtext: 5,
accentcolor: 6,
graytext: 8,
buttonborder: 8,
highlighttext: 15,
selecteditemtext: 15,
marktext: 0,
accentcolortext: 0,
} as const satisfies Record<string, TerminalColor["ansi"]>;

function byte(unit: number): number {
return Math.round(Math.min(Math.max(unit, 0), 1) * 255);
}

/**
* Resolves a §8.5 CSS color string to the packed ARGB number the transfer
* encoding carries, or `undefined` when the string is not a supported color.
* Channels clamp to the sRGB range, so out-of-gamut colors clip per channel.
* encoding carries, a terminal-themed color for a system color keyword, or
* `undefined` when the string is not a supported color. Channels clamp to
* the sRGB range, so out-of-gamut colors clip per channel.
*/
export function resolveColor(input: string): number | undefined {
export function resolveColor(
input: string,
): number | TerminalColor | undefined {
let lower = input.toLowerCase();
if (Object.hasOwn(systemColors, lower)) {
return { ansi: systemColors[lower as keyof typeof systemColors] };
}
let parsed = parse(input);
if (parsed === null || parsed.space === "cmyk") return undefined;
let [r, g, b, alpha = 1] = convert(parsed.space, "rgb", parsed.value);
return (byte(alpha) << 24) | (byte(r / 255) << 16) | (byte(g / 255) << 8) |
byte(b / 255);
}

/**
* Resolves a terminal-themed color for the field it was packed into (§8.5).
* A same-field default keyword and a palette entry stay terminal-themed. A
* cross-field default keyword (a fg-default keyword in a bg field, or a
* bg-default keyword in a fg field) resolves to the probed theme color for
* the attribute it names, packed as an ordinary opaque ARGB number; absent
* that probe, it falls back to the field's own terminal default.
*/
export function resolveForField(
color: TerminalColor,
field: "fg" | "bg",
theme: ThemeColors | undefined,
): number | TerminalColor {
let { ansi } = color;
if (ansi !== "fg-default" && ansi !== "bg-default") return color;
if (
(ansi === "fg-default" && field === "fg") ||
(ansi === "bg-default" && field === "bg")
) {
return color;
}
let rgb = ansi === "fg-default" ? theme?.foreground : theme?.background;
if (rgb === undefined) {
return { ansi: field === "fg" ? "fg-default" : "bg-default" };
}
return (255 << 24) | (rgb.r << 16) | (rgb.g << 8) | rgb.b;
}
118 changes: 86 additions & 32 deletions ops.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { names } from "hueful";
import { resolveColor } from "./color.ts";
import {
resolveColor,
resolveForField,
type systemColors,
type TerminalColor,
type ThemeColors,
} from "./color.ts";
import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts";

export type { PointerShape };
Expand Down Expand Up @@ -41,6 +47,13 @@ const PROP_CLIP = 0x10;
const PROP_FLOATING = 0x20;
const PROP_TRANSITION = 0x40;
const PROP_CURSOR = 0x80;
const PROP_BG_TERMINAL = 0x100;

/* Color words (§12.1): bit 31 marks a terminal-themed color, either the
* default (no low bits) or a palette entry (PALETTE | index). */
const TERMINAL = 0x80000000;
const PALETTE = 0x100;
const BG_KEEP = 0x40000000;

const encoder = new TextEncoder();

Expand Down Expand Up @@ -87,20 +100,24 @@ function sideWidth(side: BorderSide | undefined): number {
return typeof side === "number" ? side : side?.width ?? 0;
}

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

function sideBg(
side: BorderSide | undefined,
shared: Color | undefined,
theme: ThemeColors | 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 : colorValue(bg) & 0x00FFFFFF;
return bg === undefined ? BG_KEEP : colorWord(bg, "bg", theme);
}

function packString(
Expand Down Expand Up @@ -132,6 +149,7 @@ export function pack(
mem: ArrayBufferLike,
offset: number,
limit?: number,
theme?: ThemeColors,
): number {
let view = new DataView(mem);
let end = limit ?? mem.byteLength;
Expand All @@ -151,9 +169,13 @@ export function pack(
let bytes = encoder.encode(op.id);
o = packString(view, bytes, o, end, "element id");

let bg = op.bg === undefined
? undefined
: colorValue(op.bg, "bg", theme);
let mask = 0;
if (op.layout) mask |= PROP_LAYOUT;
if (op.bg !== undefined) mask |= PROP_BG_COLOR;
if (typeof bg === "number") mask |= PROP_BG_COLOR;
if (typeof bg === "object") mask |= PROP_BG_COLOR | PROP_BG_TERMINAL;
if (op.cornerRadius) mask |= PROP_CORNER_RADIUS;
if (op.border) mask |= PROP_BORDER;
if (op.clip) mask |= PROP_CLIP;
Expand Down Expand Up @@ -197,8 +219,12 @@ export function pack(
o += 4;
}

if (op.bg !== undefined) {
view.setUint32(o, colorValue(op.bg), true);
if (bg !== undefined) {
view.setUint32(
o,
typeof bg === "number" ? bg : terminalWord(bg),
true,
);
o += 4;
}

Expand Down Expand Up @@ -229,9 +255,9 @@ export function pack(
// C treats the presence and order of these words as a wire-format
// invariant and only consumes the explicit values.
for (let side of [b.top, b.right, b.bottom, b.left]) {
view.setUint32(o, sideFg(side, b.color), true);
view.setUint32(o, sideFg(side, b.color, theme), true);
o += 4;
view.setUint32(o, sideBg(side, b.bg), true);
view.setUint32(o, sideBg(side, b.bg, theme), true);
o += 4;
}
}
Expand Down Expand Up @@ -305,31 +331,33 @@ export function pack(
case OP_TEXT: {
view.setUint32(o, OP_TEXT, true);
o += 4;
// No explicit color: leave the terminal default foreground by writing
// 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;
// The fg word's bit 31 travels in the attrs byte (0x80); the C path
// ORs the attrs byte back over the high byte. No color is the
// terminal default.
let color = op.color === undefined
? { ansi: "fg-default" as const }
: colorValue(op.color, "fg", theme);
let terminal = typeof color === "object";
view.setUint32(
o,
op.color === undefined ? 0 : colorValue(op.color),
typeof color === "number" ? color : terminalWord(color) & 0x00FFFFFF,
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 : colorValue(op.bg) &
0x00FFFFFF;
view.setUint32(o, bg, true);
view.setUint32(
o,
op.bg === undefined ? BG_KEEP : colorWord(op.bg, "bg", theme),
true,
);
o += 4;

view.setUint32(
o,
(op.fontSize ?? 1) |
((op.fontId ?? 0) << 8) |
((op.wrap ?? 0) << 16) |
(((op.attrs ?? 0) | (textDefault ? 0x80 : 0)) << 24),
(((op.attrs ?? 0) | (terminal ? 0x80 : 0)) << 24),
true,
);
o += 4;
Expand Down Expand Up @@ -368,16 +396,39 @@ export function rgba(r: number, g: number, b: number, a = 255): number {
export type Color = number | string;

/**
* Resolves a color value to the packed ARGB number the transfer encoding
* carries (§8.5). Numbers pass through; strings resolve through the CSS
* color grammar. Invalid strings throw rather than decoding to a garbage
* color.
* Resolves a color value to a packed ARGB number or a terminal-themed color
* (§8.5), for the field it's packed into. Numbers pass through; strings
* resolve through the CSS color grammar. A cross-field default keyword
* resolves against `theme` to a packed ARGB number, falling back to the
* field's own terminal default when `theme` lacks that color. Invalid
* strings throw rather than decoding to a garbage color.
*/
function colorValue(color: Color): number {
function colorValue(
color: Color,
field: "fg" | "bg",
theme: ThemeColors | undefined,
): number | TerminalColor {
if (typeof color === "number") return color;
let value = resolveColor(color);
if (value === undefined) throw new TypeError(`invalid color: "${color}"`);
return value;
if (typeof value === "number") return value;
return resolveForField(value, field, theme);
}

function terminalWord(color: TerminalColor): number {
return typeof color.ansi === "number"
? TERMINAL | PALETTE | color.ansi
: TERMINAL;
}

/* The color word text bg and border attributes carry; alpha is dropped. */
function colorWord(
color: Color,
field: "fg" | "bg",
theme: ThemeColors | undefined,
): number {
let value = colorValue(color, field, theme);
return typeof value === "number" ? value & 0x00FFFFFF : terminalWord(value);
}

export type SizingAxis =
Expand Down Expand Up @@ -539,9 +590,9 @@ export type Op = OpenElement | Text | CloseElement | Snapshot;
* Compile-time color checking (§8.5). A string literal assigned to a color
* field must be a hex color (the type-level equivalent of
* `#${string{3,4,6,8}}`), start with a supported color function, or be a
* named color. 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
* named or system color. 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 hex checking runs per-character via recursion.
*/
Expand Down Expand Up @@ -586,7 +637,10 @@ type ColorFunction =
| "oklab"
| "oklch";

type NamedColor = keyof typeof names | "transparent";
type NamedColor =
| keyof typeof names
| keyof typeof systemColors
| "transparent";

type CheckedColor<T> = T extends number ? T
: T extends string ? string extends T ? T
Expand Down
Loading
Loading