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
14 changes: 14 additions & 0 deletions .changeset/css-color-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---

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 contains unsupported consumer-facing claims.

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

Adds CSS color string support to all directive color fields.

`open({ bg })`, `border.color`, `border.bg`, per-side `color`/`bg`, and `text({ color, bg })` now accept CSS color strings alongside packed ARGB numbers from `rgba()`. Accepted forms: hex (`#f80`, `#ff8800aa`), named colors (`rebeccapurple`, `transparent`), and `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `lab()`, `lch()`, `oklab()`, and `oklch()`. Strings resolve to sRGB at the transfer-encoding boundary; the wire format, snapshots, and renderer are unchanged. Out-of-gamut colors clip per channel.

Invalid strings are caught at every layer: string literals are rejected at compile time by `open()` and `text()`, `validate()` rejects them as invalid field values, and `pack()` throws a `TypeError` before producing any bytes.

#### Migration

The `Color` type is new — color fields that previously accepted only `number` now accept `number | string`. Passing a plain `number` continues to work without any change.

```diff
- open("box", { bg: rgba(255, 136, 0) })
+ open("box", { bg: "#ff8800" })   // string form, or keep the rgba() call
  open("box", { bg: rgba(255, 136, 0) }) // unchanged — numbers still work
```

If you pass color values typed as `string` at runtime, `validate()` and `pack()` will reject strings that do not match the CSS color grammar. Ensure any dynamically constructed color strings are valid before passing them to a directive.

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

Directive color fields accept CSS color strings. `open({ bg })`,
`border.color`/`border.bg` and per-side `color`/`bg`, and
`text({ color, bg })` take hex (`#f80`, `#ff8800aa`), named colors
(`rebeccapurple`, `transparent`), and `rgb()`, `hsl()`, `hwb()`, `lab()`,
`lch()`, `oklab()`, and `oklch()` in addition to packed ARGB numbers from
`rgba()`. Strings resolve to sRGB at the transfer-encoding boundary, so the
wire format, snapshots, and the renderer are unchanged; out-of-gamut colors
clip per channel. Invalid hex, unknown names, and unsupported functions are
compile-time type errors on literals, are rejected by `validate()`, and throw
a `TypeError` from the packer before any bytes are produced.
18 changes: 18 additions & 0 deletions color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { convert, parse } from "hueful";

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.
*/
export function resolveColor(input: string): number | undefined {
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);
}
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"effection": "npm:effection@^4.0.2",
"@std/encoding": "jsr:@std/encoding@1",
"@codspeed/tinybench-plugin": "npm:@codspeed/tinybench-plugin@^5.4.0",
"tinybench": "npm:tinybench@^5.0.0"
"tinybench": "npm:tinybench@^5.0.0",
"hueful": "npm:hueful@^0.8.1"
},
"exports": {
".": "./mod.ts",
Expand Down
5 changes: 5 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

143 changes: 125 additions & 18 deletions ops.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { names } from "hueful";
import { resolveColor } from "./color.ts";
import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts";

export type { PointerShape };
Expand Down Expand Up @@ -85,20 +87,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 +198,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 +309,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 +360,26 @@ 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 CSS
* color `string` (§8.5). Strings are resolved to the packed value at the
* transfer-encoding boundary; only numbers reach the wire.
*/
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.
*/
function colorValue(color: Color): number {
if (typeof color === "number") return color;
let value = resolveColor(color);
if (value === undefined) throw new TypeError(`invalid color: "${color}"`);
return value;
}

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

export interface OpenElement {
Expand All @@ -399,11 +426,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 +519,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 +535,96 @@ 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 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
* `HexRun<string>` would need ~16^6 union members, which TypeScript cannot
* represent (TS2590), so hex checking runs per-character via recursion.
*/

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 ColorFunction =
| "rgb"
| "rgba"
| "hsl"
| "hsla"
| "hwb"
| "lab"
| "lch"
| "oklab"
| "oklch";

type NamedColor = keyof typeof names | "transparent";

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)
: T extends `${string}(${string}`
? (Lowercase<T> extends `${ColorFunction}(${string})` ? T : never)
: Lowercase<T> extends NamedColor ? T
: 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
Loading
Loading