The static projection
Every live widget and every terminal capability in the family has a plain-text form for pipes, CI and screen readers — and one without it is refused when it is registered, not discovered when it prints.
What it is
Anything the family draws for a terminal — a spinner, a progress bar, a prompt, a hyperlink, an image — has two forms: the live one a terminal sees, and a static projection, plain text that says the same thing to a reader who cannot see the drawing. A pipe, a CI log, a screen reader and an agent get the static form. The output policy decides which form a run gets; this page is about what each form must be.
The static form is required. The live form is optional.
Why it exists
Stripping the escapes out of a drawing does not make it text. A progress bar with its colour
removed is still a row of blocks; a box is still a border; a hyperlink with its OSC 8 sequence
removed has lost its destination. The static form is written for a reader, which is why a
built-in says 12/30 files · 40% rather than ████░░░░. And a rule every contributor has to
remember is a rule some contribution breaks, so the family checks it at registration: a
component, a widget or a capability without a text form is refused with one code,
E_NO_STATIC_PROJECTION, before it can print anything.
How each package implements it
flagstaff: components
A component is { name, static(state), frame?(t, state), interval? }. hoist() asks
outputMode once and picks a projection:
| Mode | What is written | Where |
|---|---|---|
tty | frame(t, state) repainted every interval ms (80 by default), the cursor hidden; on lower(), the final static line | stdout |
pipe, ci, accessible | static(state), once per change of the text: no carriage return, no cursor escape, and a growing list appended rather than reprinted | stdout |
json | one NDJSON event per transition, {"event":<name>,"state":<state>} | stderr |
Under --json stdout stays free for the program's own result. Pinned by R1 · one component, five modes and R5 · off a terminal, no carriage return and no cursor escape in
loop.test.ts,
and the refusal by R2 · a contribution without a static projection is refused in
plugin.test.ts.
The same applies to a spinner: its static string is what a pipe prints instead of the frames.
paratext: capabilities
A terminal capability is { name, osc, when, encode, fallback }. emit(runtime, name, fields)
writes encode where when says the terminal supports it and fallback everywhere else: a
link is the report (https://…) in a pipe, never raw OSC and never the text with its
destination dropped. fallback is required; an empty string is a legal answer (the bell has
nothing to say in a pipe), absence is not (refuses a capability with no projection, at registration rather than at output in
paratext.test.ts).
paratext is a leaf, so it cannot ask roundel's outputMode. Its built-in capabilities declare
when: { tty: true }, which is what sends a pipe to the fallback; TERM=dumb always does.
caique: prompts
A prompt is flags first. When an answer is missing and nobody can type it — --json, no
terminal on stdin, CI, or an agent — caique does not wait:
it refuses with a USAGE verdict that names the flag to pass, which burgee turns into
exit 2. A plugin widget must carry static(spec), the line a pipe,
an agent or a screen reader gets instead of the prompt (a widget without `static` is E_NO_STATIC_PROJECTION, the same code flagstaff uses (R5) in
plugin.test.ts;
R2 · with nobody there, a missing value is an error and never a wait in
decide.test.ts).
controlroom
Reserved, with no API yet. Its spec plans the same rule for full-screen screens: each pane's
static projection in pane order, and NDJSON events on stderr under --json.
What is not the same everywhere
Only flagstaff emits NDJSON under --json. caique's answer to --json is to refuse the
prompt, which is an envelope rather than an event stream, and paratext has no --json form: it
writes the fallback wherever when does not match. And paratext decides support from when
alone, so a capability whose when matches a terminal is emitted in the accessible and ci
modes too when stdout is that terminal.
Run it
One component and one capability, in a pipe and under --json:
import process from 'node:process';
import { hoist } from 'flagstaff/loop';
import { emit } from 'paratext';
const rt = {
env: process.env,
isTTY: { stdout: process.stdout.isTTY === true },
stdout: process.stdout,
stderr: process.stderr,
clock: {
now: () => Date.now(),
schedule(fn, ms) {
const id = setTimeout(fn, ms);
return () => clearTimeout(id);
},
},
};
const json = process.argv.includes('--json');
const upload = {
name: 'upload',
static: (s) => `${s.sent} of ${s.total} files uploaded`,
frame: (t, s) => `${'|/-\\'[Math.floor(t / 80) % 4]} ${s.sent}/${s.total}`,
};
const flag = hoist(upload, rt, { sent: 0, total: 2 }, { json });
flag.update({ sent: 1, total: 2 });
flag.lower({ sent: 2, total: 2 });
if (!json) console.log(emit(rt, 'link', { text: 'the report', url: 'https://example.com/r/42' }));0 of 2 files uploaded
1 of 2 files uploaded
2 of 2 files uploaded
the report (https://example.com/r/42)0 of 2 files uploaded
1 of 2 files uploaded
2 of 2 files uploaded
the report (https://example.com/r/42){"event":"upload","state":{"sent":0,"total":2}}
{"event":"upload","state":{"sent":1,"total":2}}
{"event":"upload","state":{"sent":2,"total":2}}A contribution without a text form, in each of the three packages that draw or emit, and a prompt with nobody to answer it:
import process from 'node:process';
import { decide } from 'caique/decide';
import { register as registerWidget } from 'caique/plugin';
import { register as registerComponent } from 'flagstaff/plugin';
import { register as registerCapability } from 'paratext';
const attempts = {
flagstaff: () => registerComponent({ name: 'acme', spinners: { dots: { frames: ['.', '..'], interval: 100 } } }),
caique: () => registerWidget({ name: 'acme', widgets: { rating: {} } }),
paratext: () => registerCapability({ name: 'beep', osc: 'BEL', when: { tty: true }, encode: '\u0007' }),
};
for (const [pkg, attempt] of Object.entries(attempts)) {
try {
attempt();
} catch (error) {
console.log(`${pkg}: ${error.code}`);
}
}
const verdict = decide({
value: undefined,
spec: { kind: 'text', message: 'Project name?' },
option: 'name',
runtime: { env: process.env, isTTY: { stdin: process.stdin.isTTY === true } },
required: true,
});
console.log(verdict);flagstaff: E_NO_STATIC_PROJECTION
caique: E_NO_STATIC_PROJECTION
paratext: E_NO_STATIC_PROJECTION
{
action: 'error',
code: 'USAGE',
message: '--name is required when there is no terminal',
fix: 'pass --name; it would have been asked as "Project name?"'
}Where the rules live
- The rule: cli-output-stack U3 and R3.
- Guides: The static projection and Accessibility on flagstaff's site; The static projection on paratext's; Never hangs on caique's.
- API:
flagstaff/loop,paratext,caique/decide.
The output policy
One function decides where output is going and how much colour it may carry: roundel/policy's outputMode() and colorLevel(), and roundel/terminal's interactive(). The packages that draw ask them rather than reading the process themselves.
The plugin system
One plain object, one published schema, one set of refusal codes, and a check command in every package: how a plugin is written, validated and published across the family.