Skip to content

Commit 73e56b1

Browse files
committed
Add a CSS + @media demo over tty capability maps (examples/css-tty)
A `@bomb.sh/dom` document is snapshotted into `@bomb.sh/css` and styled with an environment built from `@bomb.sh/tty`'s capability maps, so the terminal's own facts drive the stylesheet: - `environmentFromCapabilities` builds the `env` record for `computeTree({ env })`: terminal size becomes the viewport at 8×16px per cell (matching the engine's `ch` unit, so `@media (min-width: 80ch)` selects an 80-column terminal), the probed background color becomes `prefers-color-scheme` via relative luminance, the host wires its own reduced-motion switch, and resolution is 1. - `foldCapabilities` folds probe responses onto the static terminfo capabilities, mirroring `term.update()`'s merge. - `capabilityVariables` renders facts without an environment slot (color depth, kitty protocols) as root custom properties through the root's inline style. The demo detects the terminal (terminfo plus a bounded raw-mode probe round-trip when stdin is a TTY), prints the capability → env mapping and per-node computed styles, and re-evaluates the `@media` queries live on resize; the stylesheet is compiled once and reused per evaluation. Piped, it performs one evaluation with a dark fallback scheme. The stylesheet demonstrates both mechanisms: `@media` blocks flip the board's column count and main axis on width/orientation and honor the motion switch, while colors go through `light-dark()` resolved against the probed scheme — on `color`/`*-color` properties only, so panels use `background-color` and borders use `border-color`. Bump the `@bomb.sh/tty` catalog to ^0.10.0 (terminfo capability maps). Tests cover the adapter in isolation and the stylesheet's behavior through the real engine; typecheck pins the css ABI with a local type stub.
1 parent 25aa0d8 commit 73e56b1

11 files changed

Lines changed: 997 additions & 6 deletions

File tree

‎examples/css-tty/README.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# `@examples/css-tty`
2+
3+
CSS + `@media` queries driven by terminal capabilities: a `@bomb.sh/dom`
4+
document is snapshotted into `@bomb.sh/css`, whose environment is built from
5+
`@bomb.sh/tty`'s capability maps.
6+
7+
```
8+
detectTerminal() ──▶ Capabilities ──▶ foldCapabilities(probe responses)
9+
│
10+
▼
11+
environmentFromCapabilities(caps, size) ──▶ env
12+
│
13+
computeTree(sheet, { nodes, parents, env }) ◀┘
14+
```
15+
16+
## The wiring
17+
18+
| Terminal capability | CSS surface |
19+
| ---------------------------- | ------------------------------------------------ |
20+
| columns / rows | `env.viewportWidth` / `Height` (8×16px per cell) |
21+
| probed background color | `env.colorScheme` (background luminance) |
22+
| host motion switch | `env.reducedMotion` (terminals expose none) |
23+
| cell aspect | `env.resolution: 1` |
24+
| color depth, kitty protocols | root custom properties via `capabilityVariables` |
25+
26+
Two paths carry capabilities into values:
27+
28+
- **`@media`** reads the environment record: `@media (min-width: 80ch)`
29+
flips the board to two columns (1ch = 8px, so `80ch` = 80 columns),
30+
`@media (orientation: …)` picks the main axis, and
31+
`@media (prefers-reduced-motion: reduce)` responds to the host switch.
32+
- **`light-dark()`** resolves at computed-value time against the color
33+
scheme derived from the probed background. Resolution covers `color` and
34+
`*-color` properties, so the stylesheet uses `background-color` and
35+
`border-color` rather than the shorthands.
36+
37+
## Run
38+
39+
```
40+
pnpm --filter @examples/css-tty demo
41+
```
42+
43+
With a TTY the demo probes the terminal (raw-mode stdin for ~250ms), folds
44+
the responses into the capability map, and re-evaluates the `@media` queries
45+
on every resize; Ctrl-C exits. Piped, it performs one evaluation with the
46+
static capabilities and a dark fallback scheme.
47+
48+
## Test
49+
50+
```
51+
pnpm --filter @examples/css-tty test
52+
```
53+
54+
The suite covers the adapter in isolation and the stylesheet's behavior
55+
through the real engine: the 80ch breakpoint flip, the orientation axis,
56+
the motion switch, `light-dark()` under both schemes, and the computed-value
57+
drop for malformed inline calls.

‎examples/css-tty/package.json‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@examples/css-tty",
3+
"private": true,
4+
"license": "MIT",
5+
"type": "module",
6+
"exports": {
7+
".": "./src/index.ts"
8+
},
9+
"scripts": {
10+
"demo": "node --experimental-transform-types src/demo.ts",
11+
"typecheck": "tsgo --noEmit -p tsconfig.json",
12+
"test": "vitest run",
13+
"format": "bsh format",
14+
"format:check": "bsh format --check",
15+
"lint": "bsh lint"
16+
},
17+
"dependencies": {
18+
"@bomb.sh/css": "workspace:*",
19+
"@bomb.sh/dom": "workspace:*",
20+
"@bomb.sh/tty": "catalog:"
21+
},
22+
"devDependencies": {
23+
"@bomb.sh/tools": "^0.6.1",
24+
"@types/node": "^22",
25+
"@typescript/native-preview": "7.0.0-dev.20260623.1",
26+
"vitest": "^4.1.2"
27+
}
28+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Type stub for `@bomb.sh/css`: the package ships untyped JS, so this file
2+
// pins the surface the example uses. Wired up through `paths` in the
3+
// example tsconfig (type-checking only; runtime always loads the real
4+
// package). The WIT world `bombshell:css@0.4.0` is the source of truth.
5+
6+
/// Rendering environment consulted by `@media` evaluation.
7+
export interface Environment {
8+
viewportWidth?: number;
9+
viewportHeight?: number;
10+
colorScheme?: 'light' | 'dark';
11+
reducedMotion?: boolean;
12+
resolution?: number;
13+
}
14+
15+
/// One element of the host tree: trees ship as a flat, document-ordered
16+
/// list plus a parallel parent-index list (`NO_PARENT` marks a root).
17+
export interface CssNode {
18+
tag: string;
19+
id?: string;
20+
classes?: string[];
21+
attributes?: [string, string][];
22+
/// WIT `node-state` bits: focus = 1, hover = 2, active = 4.
23+
state?: number;
24+
width?: number;
25+
height?: number;
26+
/// Inline style declarations; a real cascade slot.
27+
style?: string;
28+
}
29+
30+
export interface Diagnostic {
31+
/// 0 = error, 1 = warning.
32+
severity: 0 | 1;
33+
code: string;
34+
message: string;
35+
}
36+
37+
/// A `[name, value]` computed declaration.
38+
export type ComputedProperty = [string, string];
39+
40+
export interface Sheet {
41+
readonly ir: Uint8Array;
42+
readonly diagnostics: Diagnostic[];
43+
compute(
44+
nodes: CssNode[],
45+
options: { parents: number[]; target: number; env?: Environment },
46+
): ComputedProperty[];
47+
computeTree(
48+
nodes: CssNode[],
49+
options: { parents: number[]; env?: Environment },
50+
): ComputedProperty[][];
51+
release(): void;
52+
[Symbol.dispose](): void;
53+
}
54+
55+
export declare function compile(css: string, origin?: number): Sheet;
56+
57+
export declare function computeTree(
58+
sheet: Sheet,
59+
options: { nodes: CssNode[]; parents: number[]; env?: Environment },
60+
): ComputedProperty[][];
61+
62+
/// Parent index marking a root with no parent (u32::MAX).
63+
export declare const NO_PARENT: number;

‎examples/css-tty/src/demo.ts‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// CSS + @media queries driven by terminal capabilities.
2+
//
3+
// Detects the terminal (terminfo, plus a bounded probe round-trip when
4+
// stdin is a TTY), folds the capability maps into the CSS environment,
5+
// snapshots a `@bomb.sh/dom` document, and computes styles with
6+
// `@bomb.sh/css`. While the demo stays open, resizing the terminal
7+
// re-evaluates the `@media` queries live; the parsed stylesheet is reused
8+
// across evaluations, exactly like the engine's per-frame story.
9+
//
10+
// Usage: pnpm --filter @examples/css-tty demo
11+
import { compile, type ComputedProperty, type CssNode } from '@bomb.sh/css';
12+
import { createInput, detectTerminal, type CapabilityEvent, type TerminalInfo } from '@bomb.sh/tty';
13+
import {
14+
capabilityVariables,
15+
environmentFromCapabilities,
16+
foldCapabilities,
17+
type MergedCapabilities,
18+
type TerminalSize,
19+
} from './index.ts';
20+
import { createDemoDocument, snapshotDocument } from './tree.ts';
21+
import { DEMO_CSS } from './styles.ts';
22+
23+
/// How long to wait for probe responses before proceeding with whatever
24+
/// arrived. Terminals that never answer keep the static capabilities.
25+
const PROBE_TIMEOUT_MS = 250;
26+
27+
function currentSize(): TerminalSize {
28+
return {
29+
columns: process.stdout.columns ?? 80,
30+
rows: process.stdout.rows ?? 24,
31+
};
32+
}
33+
34+
const isCapability = (event: { type: string }): event is CapabilityEvent =>
35+
event.type === 'capability';
36+
37+
/// Write the terminfo probe, collect responses through the input parser
38+
/// until the theme answers (or the timeout lapses), and fold the events
39+
/// onto the static capabilities. Runs in a short raw-mode window so the
40+
/// responses reach the parser byte-for-byte.
41+
async function probeCapabilities(terminfo: TerminalInfo): Promise<MergedCapabilities> {
42+
const input = await createInput({ terminfo });
43+
const events: CapabilityEvent[] = [];
44+
const stream = process.stdin;
45+
const wasRaw = stream.isRaw ?? false;
46+
47+
await new Promise<void>((resolve) => {
48+
let done = false;
49+
const finish = () => {
50+
if (done) {
51+
return;
52+
}
53+
done = true;
54+
clearTimeout(timer);
55+
stream.off('data', onData);
56+
// A response ending mid-burst can leave a buffered lone ESC;
57+
// one argument-less scan flushes it.
58+
for (const event of input.scan().events) {
59+
if (isCapability(event)) {
60+
events.push(event);
61+
}
62+
}
63+
resolve();
64+
};
65+
const onData = (chunk: Buffer | string): void => {
66+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
67+
for (const event of input.scan(new Uint8Array(bytes)).events) {
68+
if (isCapability(event)) {
69+
events.push(event);
70+
}
71+
}
72+
if (events.some((event) => event.key === 'background-color')) {
73+
finish();
74+
}
75+
};
76+
const timer = setTimeout(finish, PROBE_TIMEOUT_MS);
77+
stream.setRawMode(true);
78+
stream.resume();
79+
stream.on('data', onData);
80+
process.stdout.write(terminfo.probe);
81+
});
82+
83+
stream.setRawMode(wasRaw);
84+
stream.pause();
85+
return foldCapabilities(terminfo.capabilities, events);
86+
}
87+
88+
/// Snapshot the demo document and compute every node's style against the
89+
/// environment built from the capability maps. The stylesheet is compiled
90+
/// once and reused; each evaluation is one `computeTree` call.
91+
function evaluate(sheet: ReturnType<typeof compile>, capabilities: MergedCapabilities) {
92+
const size = currentSize();
93+
const document = createDemoDocument();
94+
const { nodes, parents } = snapshotDocument(document, {
95+
rootStyle: capabilityVariables(capabilities),
96+
viewport: size,
97+
});
98+
const env = environmentFromCapabilities(capabilities, { size });
99+
return { env, nodes, computed: sheet.computeTree(nodes, { parents, env }) };
100+
}
101+
102+
const formatDeclarations = (declarations: ComputedProperty[]): string =>
103+
declarations.map(([name, value]) => `${name}: ${value}`).join('; ');
104+
105+
const formatNodeLabel = (node: CssNode): string =>
106+
[node.tag, node.id ? `#${node.id}` : '', (node.classes ?? []).map((c) => `.${c}`).join('')].join(
107+
'',
108+
);
109+
110+
function report(capabilities: MergedCapabilities, evaluated: ReturnType<typeof evaluate>): string {
111+
const { env, nodes, computed } = evaluated;
112+
const theme = capabilities.theme;
113+
const lines: string[] = [
114+
`capabilities colors=${capabilities.colors} truecolor=${capabilities.trueColor ? 'yes' : 'no'}`,
115+
` theme=${theme.background ? `bg ${rgb(theme.background)}` : 'unprobed'}`,
116+
` kitty-keyboard=${capabilities.kittyKeyboard} kitty-graphics=${capabilities.kittyGraphics}`,
117+
`env viewport=${env.viewportWidth}×${env.viewportHeight}px color-scheme=${env.colorScheme}`,
118+
` reduced-motion=${env.reducedMotion ? 'reduce' : 'no-preference'} resolution=${env.resolution}`,
119+
'',
120+
];
121+
for (let index = 0; index < nodes.length; index++) {
122+
lines.push(
123+
`${String(index).padStart(2, ' ')} ${formatNodeLabel(nodes[index]!)}`,
124+
` ${formatDeclarations(computed[index]!)}`,
125+
);
126+
}
127+
return lines.join('\n');
128+
}
129+
130+
function rgb({ r, g, b }: { r: number; g: number; b: number }): string {
131+
const hex = (channel: number) => channel.toString(16).padStart(2, '0');
132+
return `#${hex(r)}${hex(g)}${hex(b)}`;
133+
}
134+
135+
const sheet = compile(DEMO_CSS, 2);
136+
for (const diagnostic of sheet.diagnostics) {
137+
console.warn(`css ${diagnostic.severity === 0 ? 'error' : 'warning'}: ${diagnostic.message}`);
138+
}
139+
140+
const terminfo = await detectTerminal();
141+
const capabilities = process.stdin.isTTY
142+
? await probeCapabilities(terminfo)
143+
: foldCapabilities(terminfo.capabilities, []);
144+
145+
let first = true;
146+
const print = () => {
147+
const text = report(capabilities, evaluate(sheet, capabilities));
148+
// Repaints clear the screen so the @media flip is visible in place.
149+
process.stdout.write(first ? `\n${text}\n` : `\x1b[H\x1b[2J${text}\n`);
150+
first = false;
151+
};
152+
153+
print();
154+
155+
if (process.stdin.isTTY) {
156+
console.info('\nresize the terminal to re-evaluate @media · Ctrl-C exits');
157+
process.stdout.on('resize', print);
158+
process.on('SIGINT', () => {
159+
sheet.release();
160+
process.exit(0);
161+
});
162+
} else {
163+
// Piped or non-interactive: one evaluation, then done.
164+
sheet.release();
165+
}

0 commit comments

Comments
 (0)