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
66 changes: 66 additions & 0 deletions examples/css-tty/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# `@examples/css-tty`

CSS + `@media` queries driven by terminal capabilities: a `@bomb.sh/dom`
document is snapshotted into `@bomb.sh/css`, whose environment is built from
`@bomb.sh/tty`'s capability maps.

```
detectTerminal() ──▶ createTerm({ terminfo })
│ scan() events ─▶ term.update(events)
▼ (capability events fold in)
term.capabilities ──▶ environmentFromCapabilities(caps, size) ──▶ env
│ │
computeTree(sheet, { nodes, parents, env }) ◀───────────────────────────────────┘
```

`@bomb.sh/dom` has no run loop by design — frame scheduling belongs to
consumers — so the demo owns a minimal one (probe window, repaint on
resize, Ctrl-C exit). Real applications get the loop from the platform:
`@clack/ui`'s `createUI().main()` mounts roots and drives frames for them.

## The wiring

| Terminal capability | CSS surface |
| ---------------------------- | ------------------------------------------------ |
| columns / rows | `env.viewportWidth` / `Height` (8×16px per cell) |
| probed background color | `env.colorScheme` (background luminance) |
| host motion switch | `env.reducedMotion` (terminals expose none) |
| cell aspect | `env.resolution: 1` |
| color depth, kitty protocols | root custom properties via `capabilityVariables` |

The capability map is `term.capabilities` — the merged view `@bomb.sh/tty`
maintains as `scan()` events flow through `term.update()` (the full events
array passes straight through). The demo folds nothing itself.

Two paths carry capabilities into values:

- **`@media`** reads the environment record: `@media (min-width: 80ch)`
flips the board to two columns (1ch = 8px, so `80ch` = 80 columns),
`@media (orientation: …)` picks the main axis, and
`@media (prefers-reduced-motion: reduce)` responds to the host switch.
- **`light-dark()`** resolves at computed-value time against the color
scheme derived from the probed background. Resolution covers `color` and
`*-color` properties, so the stylesheet uses `background-color` and
`border-color` rather than the shorthands.

## Run

```
pnpm --filter @examples/css-tty demo
```

With a TTY the demo probes the terminal (raw-mode stdin for ~250ms), folds
the responses into the capability map, and re-evaluates the `@media` queries
on every resize; Ctrl-C exits. Piped, it performs one evaluation with the
static capabilities and a dark fallback scheme.

## Test

```
pnpm --filter @examples/css-tty test
```

The suite covers the adapter in isolation and the stylesheet's behavior
through the real engine: the 80ch breakpoint flip, the orientation axis,
the motion switch, `light-dark()` under both schemes, and the computed-value
drop for malformed inline calls.
28 changes: 28 additions & 0 deletions examples/css-tty/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@examples/css-tty",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"demo": "node --experimental-transform-types src/demo.ts",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "vitest run",
"format": "bsh format",
"format:check": "bsh format --check",
"lint": "bsh lint"
},
"dependencies": {
"@bomb.sh/css": "workspace:*",
"@bomb.sh/dom": "workspace:*",
"@bomb.sh/tty": "catalog:"
},
"devDependencies": {
"@bomb.sh/tools": "^0.6.1",
"@types/node": "^22",
"@typescript/native-preview": "7.0.0-dev.20260623.1",
"vitest": "^4.1.2"
}
}
63 changes: 63 additions & 0 deletions examples/css-tty/src/css-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Type stub for `@bomb.sh/css`: the package ships untyped JS, so this file
// pins the surface the example uses. Wired up through `paths` in the
// example tsconfig (type-checking only; runtime always loads the real
// package). The WIT world `bombshell:css@0.4.0` is the source of truth.

/// Rendering environment consulted by `@media` evaluation.
export interface Environment {
viewportWidth?: number;
viewportHeight?: number;
colorScheme?: 'light' | 'dark';
reducedMotion?: boolean;
resolution?: number;
}

/// One element of the host tree: trees ship as a flat, document-ordered
/// list plus a parallel parent-index list (`NO_PARENT` marks a root).
export interface CssNode {
tag: string;
id?: string;
classes?: string[];
attributes?: [string, string][];
/// WIT `node-state` bits: focus = 1, hover = 2, active = 4.
state?: number;
width?: number;
height?: number;
/// Inline style declarations; a real cascade slot.
style?: string;
}

export interface Diagnostic {
/// 0 = error, 1 = warning.
severity: 0 | 1;
code: string;
message: string;
}

/// A `[name, value]` computed declaration.
export type ComputedProperty = [string, string];

export interface Sheet {
readonly ir: Uint8Array;
readonly diagnostics: Diagnostic[];
compute(
nodes: CssNode[],
options: { parents: number[]; target: number; env?: Environment },
): ComputedProperty[];
computeTree(
nodes: CssNode[],
options: { parents: number[]; env?: Environment },
): ComputedProperty[][];
release(): void;
[Symbol.dispose](): void;
}

export declare function compile(css: string, origin?: number): Sheet;

export declare function computeTree(
sheet: Sheet,
options: { nodes: CssNode[]; parents: number[]; env?: Environment },
): ComputedProperty[][];

/// Parent index marking a root with no parent (u32::MAX).
export declare const NO_PARENT: number;
233 changes: 233 additions & 0 deletions examples/css-tty/src/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// CSS + @media queries driven by terminal capabilities, rendered live.
//
// Detects the terminal (terminfo, plus a bounded probe round-trip when
// stdin is a TTY), keeps the capability maps in a `@bomb.sh/tty` term —
// `scan()` events flow through `term.update()`, which folds them into
// `term.capabilities` — snapshots a `@bomb.sh/dom` document, computes
// styles with `@bomb.sh/css`, and renders the computed styles as real
// frames: resize the terminal and the `@media` queries re-evaluate, the
// board relayouts, the accent color changes, and the status bar prints
// the numbers.
//
// `@bomb.sh/dom` has no run loop by design (frame scheduling belongs to
// consumers), so the demo owns a minimal one: probe window, then
// repaint-on-resize (the render itself diffs inside `Term.render`, so
// only changed cells hit the wire). Real applications get the loop from
// the platform (`@clack/ui`'s `createUI().main()`).
//
// Usage: pnpm --filter @examples/css-tty demo
import { compile, type ComputedProperty, type CssNode } from '@bomb.sh/css';
import {
alternateBuffer,
createInput,
createTerm,
cursor,
detectTerminal,
settings,
type RuntimeCapabilities,
type Setting,
type TerminalInfo,
} from '@bomb.sh/tty';
import { capabilityVariables, environmentFromCapabilities, type TerminalSize } from './index.ts';
import { createDemoDocument, snapshotDocument, type DocumentSnapshot } from './tree.ts';
import { renderTree } from './render.ts';
import { DEMO_CSS } from './styles.ts';

/// How long to wait for probe responses before proceeding with whatever
/// arrived. Terminals that never answer keep the static capabilities.
const PROBE_TIMEOUT_MS = 250;

function currentSize(): TerminalSize {
// `||` rather than `??`: a degenerate pty reports 0×0, and a zero-sized
// term lays out nothing.
return {
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
};
}

/// Write the terminfo probe and feed responses through the input parser
/// into `term.update()` until the theme answers (or the timeout lapses),
/// inside a short raw-mode window so responses reach the parser
/// byte-for-byte.
async function probeCapabilities(
terminfo: TerminalInfo,
term: Awaited<ReturnType<typeof createTerm>>,
): Promise<void> {
const input = await createInput({ terminfo });
const stream = process.stdin;
const wasRaw = stream.isRaw ?? false;

stream.setRawMode(true);
try {
await new Promise<void>((resolve) => {
let done = false;
const finish = () => {
if (done) {
return;
}
done = true;
clearTimeout(timer);
stream.off('data', onData);
// A response ending mid-burst can leave a buffered lone ESC;
// one argument-less scan flushes it.
term.update(input.scan().events);
resolve();
};
const onData = (chunk: Buffer | string): void => {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
const { events } = input.scan(new Uint8Array(bytes));
// The full events array passes straight through: capability
// events fold into term.capabilities, everything else is a
// no-op. Bytes come back only when something must be written
// now.
const out = term.update(events);
if (out.length > 0) {
process.stdout.write(out);
}
if (term.capabilities.theme.background !== undefined) {
finish();
}
};
const timer = setTimeout(finish, PROBE_TIMEOUT_MS);
stream.resume();
stream.on('data', onData);
process.stdout.write(terminfo.probe);
});
} finally {
// Restore the terminal even if the probe window fails midway.
stream.setRawMode(wasRaw);
stream.pause();
}
}

interface Frame extends DocumentSnapshot {
env: ReturnType<typeof environmentFromCapabilities>;
computed: ComputedProperty[][];
numbers: string;
}

/// Snapshot the demo document and compute every node's style against the
/// environment built from the capability map. The status bar prints the
/// numbers, and the numbers themselves are computed values — so the frame
/// is computed twice: once to read what the CSS decided (axis, gap,
/// breakpoint), once with those numbers written into the tree.
function evaluate(sheet: ReturnType<typeof compile>, capabilities: RuntimeCapabilities): Frame {
const size = currentSize();
const env = environmentFromCapabilities(capabilities, { size });

const draft = snapshotDocument(createDemoDocument(), {
rootStyle: capabilityVariables(capabilities),
viewport: size,
});
const draftComputed = sheet.computeTree(draft.nodes, { parents: draft.parents, env });
const root = Object.fromEntries(draftComputed[0] ?? []);
const numbers =
`${size.columns}×${size.rows} cells → ${env.viewportWidth}×${env.viewportHeight}px` +
` · ${env.colorScheme} · ${capabilities.colors} colors${capabilities.trueColor ? ' · truecolor' : ''}` +
` · ${root['--axis']} board · gap ${root['--gap']} · ${root['--columns']}-column` +
` · motion ${root['--motion']}`;

const frame = snapshotDocument(createDemoDocument(numbers), {
rootStyle: capabilityVariables(capabilities),
viewport: size,
});
return {
...frame,
env,
computed: sheet.computeTree(frame.nodes, { parents: frame.parents, env }),
numbers,
};
}

const formatDeclarations = (declarations: ComputedProperty[]): string =>
declarations.map(([name, value]) => `${name}: ${value}`).join('; ');

const formatNodeLabel = (node: CssNode): string =>
[node.tag, node.id ? `#${node.id}` : '', (node.classes ?? []).map((c) => `.${c}`).join('')].join(
'',
);

function report(frame: Frame): string {
const { env, nodes, computed } = frame;
const lines = [
`env viewport=${env.viewportWidth}×${env.viewportHeight}px color-scheme=${env.colorScheme}`,
` reduced-motion=${env.reducedMotion ? 'reduce' : 'no-preference'} resolution=${env.resolution}`,
` ${frame.numbers}`,
'',
];
for (let index = 0; index < nodes.length; index++) {
lines.push(
`${String(index).padStart(2, ' ')} ${formatNodeLabel(nodes[index]!)}`,
` ${formatDeclarations(computed[index]!)}`,
);
}
return lines.join('\n');
}

const sheet = compile(DEMO_CSS, 2);
for (const diagnostic of sheet.diagnostics) {
console.warn(`css ${diagnostic.severity === 0 ? 'error' : 'warning'}: ${diagnostic.message}`);
}

const terminfo = await detectTerminal();
const size = currentSize();
const term = await createTerm({ width: size.columns, height: size.rows, terminfo });
if (process.stdin.isTTY) {
await probeCapabilities(terminfo, term);
}

const visual = Boolean(process.stdin.isTTY);
let setup: Setting | undefined;
if (visual) {
// Full-screen app mode: alternate buffer, hidden cursor. Reverted on exit.
setup = settings(alternateBuffer({ clear: true }), cursor(false));
process.stdout.write(setup.apply);
}

const repaint = () => {
const frame = evaluate(sheet, term.capabilities);
if (!visual) {
return frame;
}
const ops = renderTree(frame, frame.computed);
const { output } = term.render(ops);
process.stdout.write(output);
return frame;
};

const firstFrame = repaint();

if (visual) {
// Live loop: fold the new size into the term (no WASM reinit), then
// re-evaluate the stylesheet and re-render. Diffing happens inside
// Term.render, so only changed cells hit the wire.
process.stdout.on('resize', () => {
const next = currentSize();
term.update([{ type: 'resize', width: next.columns, height: next.rows }]);
repaint();
});
// Liveness, belt and suspenders: stdin referenced AND a timer anchor,
// so the event loop cannot drain no matter how the underlying streams
// behave. Cooked mode is restored after the probe, so Ctrl-C still
// delivers SIGINT.
process.stdin.ref();
const keepAlive = setInterval(() => {}, 2_147_483_647);
const exit = () => {
clearInterval(keepAlive);
if (setup) {
process.stdout.write(setup.revert);
}
sheet.release();
process.exit(0);
};
process.on('SIGINT', exit);
process.on('SIGTERM', exit);
} else {
// Piped or non-interactive: one evaluation of the numbers + computed
// declarations, then done.
console.info(`\n${firstFrame.numbers}`);
console.info(report(firstFrame));
sheet.release();
console.info(`\nno TTY on stdin (isTTY=${process.stdin.isTTY}) — one-shot evaluation`);
}
Loading
Loading