registry:ui · Terminal
ghostty-term — the live wasm terminal
The brand's live terminal surface: a canvas grid rendered by the real libghostty-vt wasm, not a DOM reimplementation. The component owns painting and geometry — DPR-aware cells derived from the density tokens, rAF-batched dirty-row repaints — while the consumer owns the pty: onData carries terminal input out (keys, gated pastes, wheel) and the bind:this write() feeds pty output back in. Load failures degrade to a data-state machine with a terminal-styled fallback, or the consumer's own face through the children slot.
$npx jixoai-ui add ghostty-term
https://ui.jixoai.com/r/ghostty-term.json
the wasm supply step: see Install prerequisites below
install
Install prerequisites
The one jixoai component with a supply-chain step: the wasm never enters git or your bundle source — the vite plugin pins its sha256, resolves it (env → cache → verified download), and hands the URL over through the virtual:jixoai-ghostty module. Two steps on a tailwind v4 + vite project:
# 1 — the jixoai base on a tailwind v4 + vite project
npx jixoai-ui init --hue 330{
"registries": {
"@jixoai": "https://ui.jixoai.com/r/{name}.json"
}
}# ghostty-term pulls its registry deps (ghostty-vt, color-utils) with it
npx jixoai-ui add ghostty-term# 2 — the wasm supply chain (the one component that needs it)
npm i -D @jixoai/ui-vite-pluginimport { sveltekit } from '@sveltejs/kit/vite';
import { jixoai } from '@jixoai/ui-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
export default {
plugins: [sveltekit(), tailwindcss(), ...jixoai()],
};/// <reference types="@jixoai/ui-vite-plugin/client" />overview
Overview
The live terminal surface: a DPR-aware canvas grid painted by the real libghostty-vt wasm — the component owns paint and geometry, the consumer owns the pty, and the color boundary is deliberately one-sided.
Nothing re-implements VT here: parsing, grid state and scrollback live inside
libghostty-vt; the component adds the browser-shaped pieces — DPR-aware cell metrics
derived from the density kernels, rAF-batched dirty-row repaints, and the input bridge.
The pty is yours: onData carries terminal input
out (encoded keys, gated pastes, wheel), and the bind:this write() feeds pty output back in — the page's
demo is a loopback answering through exactly that seam, no hidden shell.
Degradation is a typed state machine: data-state goes
loading → ready | error, a failed wasm load renders a polite role="status" face carrying the GhosttyVTError
message, and the children slot hands the degraded face to the consumer. The supply
chain is the install section's contract — the wasm never enters git or the bundle
source; the vite plugin pins its sha256 and serves it through the virtual:jixoai-ghostty module.
The axes story is this family's signature: SEVEN of the eight axes take lanes —
density consumed as the cell-metric kernels (the one live ladder), size the §11 echo,
the rest stamp-and-supply — and the eighth is the §13 shadow case: the theme prop is the shell-color OBJECT (absent-slot
escape hatch), never the light/dark/system enum. The surface is typed-frozen under
scoped .dark by design and the content is ANSI verbatim — a terminal keeps its colors.
Kinship: terminal-card (the chrome paint law this
page's window borrows) and code-card (the readonly
render-side sibling).
ghostty-term
The live loopback: click the terminal and type — the fake shell on this page answers through onData/write with zero network. help · color · clear · ↑ history.
<script lang="ts">
import GhosttyTerm from '@ui/ghostty-term';
import type { GhosttyTermHandle } from '@ui/ghostty-term';
let term = $state<GhosttyTermHandle>();
const enc = new TextEncoder();
</script>
<!-- the container derives the grid (ResizeObserver → cols/rows) -->
<div class="h-80">
<GhosttyTerm
bind:this={term}
onData={(bytes) => ptyInput.write(bytes)}
/>
</div>
<!-- write() is the pty OUTPUT side: rendered bytes feed back in -->
<button onclick={() => term?.write(enc.encode('\u001b[1mhello\u001b[0m\r\n'))}>
write
</button>demo
Failure & degradation
Loading is a state machine — data-state goes loading → ready | error — and a failed wasm load never crashes the page. Without a slot, the default fallback face is a terminal-styled status line; with the children slot, the consumer owns the degraded face outright.
ghostty-term · degradation
<script lang="ts">
import GhosttyTerm from '@ui/ghostty-term';
</script>
<!-- a failed wasm load never crashes the page: without a slot the
default fallback face is a terminal-styled status line -->
<div class="h-40">
<GhosttyTerm wasmUrl="https://invalid.jixoai.test/ghostty-vt.wasm" />
</div>
<!-- with the children slot, the consumer owns the degraded face outright -->
<div class="h-40">
<GhosttyTerm wasmUrl="https://invalid.jixoai.test/ghostty-vt.wasm">
<div
class="absolute inset-0 flex items-center justify-center p-4 font-mono text-[13px] leading-5 text-terminal-foreground"
role="status"
>
<span class="text-primary mr-2" aria-hidden="true">$</span>
consumer fallback — this face is the children slot
</div>
</GhosttyTerm>
</div>demo
Density & theming
Density rides the standard token kernels: --jx-text sets the cell font, --jx-line sets the row pitch — the same explicit density prop as every jixoai component. Theming is deliberately one-sided: the theme prop (and the --terminal tokens) restyle only the SHELL paper/ink; ANSI 8/256/truecolor content colors pass through the wasm verbatim.
ghostty-term · density
<script lang="ts">
import GhosttyTerm from '@ui/ghostty-term';
</script>
<!-- the density prop: --jx-text sets the cell font, --jx-line the pitch -->
<div class="h-32"><GhosttyTerm density="sm" /></div>
<div class="h-32"><GhosttyTerm /></div>
<div class="h-32"><GhosttyTerm density="lg" /></div>
<!-- theme restyles only the SHELL paper/ink; ANSI passes verbatim -->
<div class="h-[9.5rem]"><GhosttyTerm /></div>
<div class="h-[9.5rem]">
<GhosttyTerm theme={{ background: '#141019', foreground: '#f0e6ff' }} />
</div>law
The wasm is the terminal
Nothing re-implements VT here. The platform gives parsing, grid state and scrollback inside libghostty-vt; this component adds exactly the browser-shaped pieces around it — DPR-aware painting, density-derived metrics, the input bridge, the degradation machine.
- painting is data, not decoration: rAF batches writes, only dirty rows repaint, a row cache serves full repaints (theme/font changes) — no loops, no blink
- the color boundary is one-sided by design: shell paper/ink resolve from jixoai tokens; content colors leave the wasm verbatim — theming never rewrites user output
- the pty is yours: onData out, write in — the component never guesses what a shell is (this page’s demo is a loopback, not hidden behavior)
- V1 bounds, stated plainly: no cursor/selection paint (the frozen vt face exposes no cursor read), no hyperlink activation, viewport-only scroll, and a clamped wheel shift where the upstream render state under-reports dirty rows
usage
Usage
Auto sizing fills the container; explicit cols/rows fixes the grid. The consumer owns the pty loop: onData out, write in.
<script lang="ts">
import GhosttyTerm from '@ui/ghostty-term';
import type { GhosttyTermHandle } from '@ui/ghostty-term';
let term = $state<GhosttyTermHandle>();
const enc = new TextEncoder();
</script>
<!-- the container derives the grid (ResizeObserver → cols/rows) -->
<div class="h-80">
<GhosttyTerm
bind:this={term}
onData={(bytes) => ptyInput.write(bytes)}
/>
</div>
<!-- write() is the pty OUTPUT side: rendered bytes feed back in -->
<button onclick={() => term?.write(enc.encode('\u001b[1mhello\u001b[0m\r\n'))}>
write
</button>pty output ─▶ handle.write(bytes) ─▶ wasm vtWrite ─▶ dirty rows ─▶ canvas paint
keys/paste ─▶ keyEncode + paste gate ─▶ onData(bytes) ─▶ your ptyapi
API
Props from the GhosttyTerm Props interface; the bind:this surface mirrors a pty handle. Rest props spread onto the root. The generated meta carries 27 props (26 named + the synthesized rest) — the hand table serves the 11 consumer rows, with density and theme riding the EXTRA lane (reference identity): density because the family own 'default' is contract, theme because the generic light/dark/system enum is SHADOWED by the shell-object escape hatch — the carriers-bijection ruling, task 27.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| cols | number | — | Fixed grid columns; any explicit cols/rows (or auto={false}) switches out of auto sizing. |
| rows | number | — | Fixed grid rows. |
| auto | boolean | true | Derive the grid from the container box (ResizeObserver). |
| fontSize | number | --jx-text | Cell font size in px — must be finite positive; anything else warns once and falls back to the density token. |
| wasmUrl | string | virtual:jixoai-ghostty | wasm asset URL; the default resolves the vite plugin’s virtual module (the install prerequisite). |
| theme | { background?, foreground?, … } | terminal tokens | Shell overrides ONLY — the ABSENT-SLOT escape hatch (no theme = the jixoai token sheet owns every shell color, and the surface is typed-frozen: a scoped .dark moves nothing, measured). ANSI/256/truecolor CONTENT colors are never themed through this prop. The generic light/dark/system THEME AXIS is shadowed by this object — see the axes table. |
| onData | (bytes: Uint8Array) => void | — | Terminal INPUT out: encoded keys, gated pastes (bind:this write feeds OUTPUT in). |
| onResize | (detail: { cols, rows }) => void | — | Fires when the auto-mode grid derivation changes. |
| density | Density | 'default' · ambient scope | Explicit density for the cell metric kernels (--jx-text cell font, --jx-line row pitch — measured 12/13/15px and 18/20/24px across sm/default/lg). Omitted → the ambient density scope, else the family own 'default' (the always-concrete cell math). |
| class | string | '' | Merged onto the root through cn(). |
| children | Snippet | — | Overlay slot; when provided it also replaces the default error fallback face. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
Handle (bind:this)
| Property | Type | Default | Description |
|---|---|---|---|
| write | (bytes: Uint8Array) => void | — | Feed pty OUTPUT into the terminal — rAF-batched with in-flight writes. |
| reset | () => void | — | Full reset (RIS) back to a pristine grid. |
| resizeTo | (cols: number, rows: number) => void | — | Imperative grid resize; an auto-mode container change overrides it on the next derivation. |
| snapshot | () => string | — | Base64 terminal snapshot for diagnostics/tests (V1: encode only). |
axes
The eight axes on ghostty-term
SEVEN of the eight axes take lanes — size · shape · radius · density · color · elevation · motion — and the eighth is the §13 shadow case that names the ruling: the family's own `theme` prop is the shell-color OBJECT (the absent-slot escape hatch), so the generic light/dark/system enum has no seat. Density is the one consumed ladder (the cell-metric kernels, measured 12/13/15px + 18/20/24px); size is the §11 echo with the fixed error voice as its nothing-follows proof; the shell is typed-frozen under scoped .dark by design — a terminal is deliberately theme-independent; everything else stamps-and-supplies. fontSize keeps its component-specific name (no collision, §13).
| Property | Type | Default | Description |
|---|---|---|---|
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'default' · ambient scope | CONSUMED — the cell-metric kernels ARE the density contract: the cell font rides --jx-text and the row pitch --jx-line, measured across the demo rungs (kernel ladder 12 / 13 / 15px text, 18 / 20 / 24px line at sm/default/lg; the rung stamps data-density on the family root). The family own 'default' is the always-concrete cell math — a terminal never renders unspecified. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | THE §11 ECHO — the stamp lands verbatim (measured root inline: --jx-size-effective: 13px; font-size: var(--jx-size-effective, 1rem); computed 13px / 18px at the demo lanes) and the cell math follows NOTHING of it: the canvas font is fontSize → --jx-text (density-governed) and the error face is the FIXED --jx-text-base voice (measured 13px at both size lanes — the nothing-follows proof). A div carries no size-like attribute (the native-wrapper §1 rule). Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — stamps --jx-shape-effective + --jx-radius-factor-effective; no family css reads them (grep receipt: zero carrier reads in ui/ghostty-term/ + vt-deps.ts). The surface is square by the terminal law. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-radius-effective; zero readers (grep receipt: no border-radius anywhere in the family css — the region root is the §3 concentric ANCHOR for composed chrome, the supply is the point). Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — stamps --jx-color-effective; zero readers (grep receipt). The shell ink/paper are the typed terminal tokens; the error prompt is --jx-primary; content colors are the wasm’s ANSI/256/truecolor, verbatim. Number unit: hue degrees. |
| theme | SHADOWED — the shell-theme OBJECT, not the axis enum | terminal tokens (absent = the sheet owns the shell) | THE SHADOWED SLOT (the §13 case this campaign named the ruling after): the family's theme prop is the ABSENT-SLOT escape hatch — a structured { background, foreground, selectionBackground, … } object; ABSENCE is the meaningful state (the token sheet owns every shell color). 'light'/'dark'/'system' are not lanes here. THE MEASURED SPLIT: the surface atom is a typed :root token; the canvas shell probes the RAW shell tokens live but unobserved — a scope flip moves neither (measured: under a scoped .dark the ground holds oklch(0.9551 0 0) and ink oklch(0 0 0) while the scope's own --terminal reads the dark pole; the CSS surface holds by alias-freeze, the canvas by no-observer — there is no MutationObserver on class/data-theme, so the cached raw value paints until a theme-prop change re-probes the then-current scope), and the CONTENT is the wasm's ANSI — absolute under any theme. A terminal is deliberately theme-independent: the surface typed-frozen by construction, the shell pole-holding by no-observation, ANSI-verbatim by contract. No number lane, no enum lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-elevation-effective; zero readers in the family css (grep receipt: zero shadow declarations — the demo window’s float shadow belongs to the PAGE chrome, --shadow-md on the demo shell, not the component). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SUPPLY-ONLY — stamps --jx-motion-effective; zero readers (grep receipt). The family declares no transitions; the cursor blink is the wasm’s own state machine and painting is rAF-batched data flow, not choreography. Number unit: coefficient. |
Receipts: the density kernel ladder, the size echo (with the fixed --jx-text-base error voice), and the typed-frozen split were measured on this page's served DOM (probe, task 27); the supply-only rows carry grep receipts over ui/ghostty-term/ and vt-deps.ts. The adoption is the census batch A row (explicit-props W3-A); the theme shadow is the §13 no-rename precedent (the absent-slot contract, ghostty-term-defaults.svelte.ts).
<script lang="ts">
import GhosttyTerm from '@ui/ghostty-term';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (13px) applies; at 48rem+ the md case (18px)
wins — the §11 echo the root stamps (the cell math follows the
density kernels, not this stamp) -->
<GhosttyTerm rows={3} cols={40} size={query({ md: 18 }, 13)} wasmUrl="https://invalid.jixoai.test/ghostty-vt.wasm" />ghostty-term · query()
Media keys are min-width: below 48rem the base applies — the root stamp reads 13px; at 48rem and wider the md case wins — 18px. The number lane goes bare (results infer); string lanes take both generics. Resize across 48rem.
<script lang="ts">
import GhosttyTerm from '@ui/ghostty-term';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (13px) applies; at 48rem+ the md case (18px)
wins — the §11 echo the root stamps (the cell math follows the
density kernels, not this stamp) -->
<GhosttyTerm rows={3} cols={40} size={query({ md: 18 }, 13)} wasmUrl="https://invalid.jixoai.test/ghostty-vt.wasm" />ghostty-term · universal props
<!-- ghostty-term: 7 of the 8 axes — 'theme' stays the family's own
shell-theme OBJECT (background/foreground/…), never the axis enum;
fontSize keeps its component-specific name (no collision) -->
<GhosttyTerm rows={3} cols={40} size={13} density="small" />a11y
Accessibility
The canvas is a raster — semantics live one level up on the focused root; failures announce politely through a live status face.
ARIA
| Attribute | Value | Description |
|---|---|---|
| tabindex="0" | root | The root div is the keyboard surface (keydown → keyEncode); the canvas itself is aria-hidden. |
| aria-label | terminal | Names the surface for assistive tech — pass your own through the rest props spread. |
| role="status" | error face | The default degradation fallback renders as a polite status line carrying the typed error message. |
| data-state | loading | ready | error | The load state machine exposed as an attribute — style or assert it without touching internals. |