diff --git a/package-lock.json b/package-lock.json
index f6ce6bb..332eeb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,11 @@
"": {
"name": "kik-website",
"dependencies": {
+ "@noble/ciphers": "^2.4.0",
+ "@noble/curves": "^2.4.0",
+ "@noble/hashes": "^2.4.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
},
@@ -1057,6 +1062,45 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
+ "node_modules/@noble/ciphers": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
+ "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
+ "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.4.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -1482,6 +1526,21 @@
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC"
},
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
+ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
+ "license": "MIT"
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
+ "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
+ },
"node_modules/am-i-vibing": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz",
diff --git a/package.json b/package.json
index d087ca7..83e6eb8 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,11 @@
"preview": "astro preview"
},
"dependencies": {
+ "@noble/ciphers": "^2.4.0",
+ "@noble/curves": "^2.4.0",
+ "@noble/hashes": "^2.4.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
}
diff --git a/src/components/Prompt.astro b/src/components/Prompt.astro
index c7c74bc..6081309 100644
--- a/src/components/Prompt.astro
+++ b/src/components/Prompt.astro
@@ -1 +1 @@
-
root@khaos-im-kiez:~#
+root@khaos-im-kiez:~#
diff --git a/src/components/Terminal.astro b/src/components/Terminal.astro
index cb712ac..eed8acb 100644
--- a/src/components/Terminal.astro
+++ b/src/components/Terminal.astro
@@ -1,110 +1,36 @@
---
import Prompt from './Prompt.astro';
---
-
+
diff --git a/src/scripts/ssh.ts b/src/scripts/ssh.ts
new file mode 100644
index 0000000..e20e5c1
--- /dev/null
+++ b/src/scripts/ssh.ts
@@ -0,0 +1,276 @@
+// Minimal SSH client for one interactive shell over a websockify bridge (raw SSH bytes in binary frames).
+// Supports exactly: curve25519-sha256 with strict kex, ssh-ed25519 host and user keys,
+// chacha20-poly1305@openssh.com, one session channel with a pty, and server-initiated re-keying.
+import { ed25519, x25519 } from '@noble/curves/ed25519.js';
+import { sha256 } from '@noble/hashes/sha2.js';
+import { chacha20orig } from '@noble/ciphers/chacha.js';
+import { poly1305 } from '@noble/ciphers/_poly1305.js';
+import { concatBytes as concat, equalBytes, randomBytes } from '@noble/ciphers/utils.js';
+
+export interface Options {
+ socket: WebSocket;
+ user: string;
+ privateKey: string; // unencrypted ed25519 key in openssh-key-v1 format
+ hostKey: string; // expected host key fingerprint, "SHA256:..."
+ term: string;
+ cols: number;
+ rows: number;
+ onData: (data: Uint8Array) => void;
+ onClose: (error?: Error) => void;
+}
+
+export interface Session {
+ write(data: string | Uint8Array): void;
+ resize(cols: number, rows: number): void;
+}
+
+const CIPHER = 'chacha20-poly1305@openssh.com';
+const WINDOW = 1 << 16;
+const MAX_PACKET = 1 << 15;
+
+const enc = new TextEncoder();
+const dec = new TextDecoder();
+
+const u32 = (n: number) => Uint8Array.of(n >>> 24, n >>> 16, n >>> 8, n);
+
+/** SSH wire encoding: number → uint32, boolean → byte, string or bytes → length-prefixed string. */
+function pack(...values: (number | boolean | string | Uint8Array)[]): Uint8Array {
+ return concat(...values.map((v) => {
+ if (typeof v === 'number') return u32(v);
+ if (typeof v === 'boolean') return Uint8Array.of(+v);
+ const bytes = typeof v === 'string' ? enc.encode(v) : v;
+ return concat(u32(bytes.length), bytes);
+ }));
+}
+
+const msg = (type: number, ...values: Parameters) => concat(Uint8Array.of(type), pack(...values));
+
+function reader(bytes: Uint8Array, offset = 0) {
+ const r = {
+ u8: () => bytes[offset++],
+ u32: () => new DataView(bytes.buffer, bytes.byteOffset).getUint32((offset += 4) - 4),
+ bytes: () => { const n = r.u32(); return bytes.subarray(offset, (offset += n)); },
+ str: () => dec.decode(r.bytes()),
+ };
+ return r;
+}
+
+/** Unsigned big-endian integer → SSH mpint. */
+function mpint(bytes: Uint8Array) {
+ const n = bytes.subarray(bytes.findIndex((b) => b !== 0));
+ return pack(n[0] & 0x80 ? concat(Uint8Array.of(0), n) : n);
+}
+
+function parsePrivateKey(text: string) {
+ const r = reader(Uint8Array.from(atob(text.replace(/-----[^-]+-----|\s/g, '')), (c) => c.charCodeAt(0)), 15); // skip "openssh-key-v1\0"
+ if (r.str() !== 'none') throw new Error('encrypted private keys are not supported');
+ r.str(); r.bytes(); r.u32(); // kdf name, kdf options, number of keys
+ const publicKey = r.bytes();
+ const secret = reader(r.bytes(), 8); // skip check ints
+ secret.str(); secret.bytes(); // key type, public key
+ return { publicKey, seed: secret.bytes().subarray(0, 32) };
+}
+
+/** chacha20-poly1305@openssh.com. Sequence numbers restart at 0 after every NEWKEYS because of strict kex. */
+function chachaPoly(key: Uint8Array) {
+ const main = key.subarray(0, 32);
+ const header = key.subarray(32);
+ let seq = 0;
+ const nonce = () => concat(u32(0), u32(seq));
+ const mac = (n: Uint8Array, data: Uint8Array) => poly1305(data, chacha20orig(main, n, new Uint8Array(32)));
+ return {
+ length: (head: Uint8Array) => reader(chacha20orig(header, nonce(), head)).u32(),
+ seal(packet: Uint8Array) {
+ const n = nonce();
+ seq++;
+ const sealed = concat(chacha20orig(header, n, packet.subarray(0, 4)), chacha20orig(main, n, packet.subarray(4), undefined, 1));
+ return concat(sealed, mac(n, sealed));
+ },
+ open(packet: Uint8Array) {
+ const n = nonce();
+ seq++;
+ const sealed = packet.subarray(0, -16);
+ if (!equalBytes(mac(n, sealed), packet.subarray(-16))) throw new Error('bad packet MAC');
+ return chacha20orig(main, n, sealed.subarray(4), undefined, 1);
+ },
+ };
+}
+
+export function connect(o: Options): Session {
+ const { socket } = o;
+ let buffer = new Uint8Array(0);
+ let wake = () => {};
+ let closed = false;
+ socket.binaryType = 'arraybuffer';
+ socket.onmessage = (e) => { buffer = concat(buffer, new Uint8Array(e.data)); wake(); };
+ socket.onclose = () => { closed = true; wake(); };
+
+ async function read(n: number) {
+ while (buffer.length < n) {
+ if (closed) throw new Error('connection closed');
+ await new Promise((resolve) => (wake = resolve));
+ }
+ const bytes = buffer.subarray(0, n);
+ buffer = buffer.subarray(n);
+ return bytes;
+ }
+
+ let tx: ReturnType | undefined;
+ let rx: ReturnType | undefined;
+ let held: Uint8Array[] | undefined; // connection-layer messages held back during a key exchange
+
+ function send(payload: Uint8Array) {
+ if (socket.readyState !== WebSocket.OPEN) return; // the session has ended
+ if (held && payload[0] >= 50) return void held.push(payload);
+ let padding = 8 - ((payload.length + (tx ? 1 : 5)) % 8); // chacha20-poly1305 excludes the length field
+ if (padding < 4) padding += 8;
+ const packet = concat(u32(payload.length + padding + 1), Uint8Array.of(padding), payload, new Uint8Array(padding));
+ socket.send(tx ? tx.seal(packet) : packet);
+ }
+
+ async function readPacket() {
+ const head = await read(4);
+ const packet = concat(head, await read(rx ? rx.length(head) + 16 : reader(head).u32()));
+ const plain = rx ? rx.open(packet) : packet.subarray(4);
+ return plain.subarray(1, plain.length - plain[0]);
+ }
+
+ /** Next packet, after handling the messages that may arrive at any time once the initial key exchange is done. */
+ async function receive(): Promise {
+ for (;;) {
+ const p = await readPacket();
+ if (!rx) return p; // strict kex: nothing else is allowed during the initial key exchange
+ const r = reader(p, 1);
+ switch (p[0]) {
+ case 1: r.u32(); throw new Error(`disconnected: ${r.str()}`);
+ case 80: r.str(); if (r.u8()) send(Uint8Array.of(82)); break; // global request: reply failure
+ case 2: case 3: case 4: case 53: break; // ignore, unimplemented, debug, auth banner
+ default: return p;
+ }
+ }
+ }
+
+ async function expect(type: number) {
+ const p = await receive();
+ if (p[0] !== type) throw new Error(`expected message ${type}, got ${p[0]}`);
+ return p;
+ }
+
+ let channel: number | undefined;
+ let remoteWindow = 0;
+ let remoteMaxPacket = 0;
+ let pending = new Uint8Array(0);
+
+ function flush() {
+ while (pending.length && remoteWindow) {
+ const n = Math.min(pending.length, remoteWindow, remoteMaxPacket);
+ send(msg(94, channel!, pending.subarray(0, n)));
+ pending = pending.subarray(n);
+ remoteWindow -= n;
+ }
+ }
+
+ async function run() {
+ let line;
+ do {
+ line = '';
+ while (!line.endsWith('\n')) line += String.fromCharCode((await read(1))[0]);
+ } while (!line.startsWith('SSH-'));
+ const serverVersion = line.trimEnd();
+ const clientVersion = 'SSH-2.0-web';
+ socket.send(enc.encode(`${clientVersion}\r\n`));
+
+ let sessionId: Uint8Array | undefined;
+ /** The initial key exchange, or a re-key started by the server's KEXINIT. */
+ async function kex(serverKexinit?: Uint8Array) {
+ held = [];
+ const clientKexinit = concat(Uint8Array.of(20), randomBytes(16), pack(
+ 'curve25519-sha256,kex-strict-c-v00@openssh.com', 'ssh-ed25519',
+ CIPHER, CIPHER, '', '', 'none', 'none', '', '', false, 0,
+ ));
+ send(clientKexinit);
+ serverKexinit ??= await expect(20);
+ if (!sessionId && !reader(serverKexinit, 17).str().split(',').includes('kex-strict-s-v00@openssh.com')) throw new Error('server lacks strict kex');
+
+ const ephemeral = x25519.keygen();
+ send(msg(30, ephemeral.publicKey));
+ const reply = reader(await expect(31), 1);
+ const hostKey = reply.bytes();
+ const serverPublic = reply.bytes();
+ const signature = reply.bytes();
+ const fingerprint = `SHA256:${btoa(String.fromCharCode(...sha256(hostKey))).replace(/=+$/, '')}`;
+ if (fingerprint !== o.hostKey) throw new Error(`unexpected host key ${fingerprint}`);
+ const K = mpint(x25519.getSharedSecret(ephemeral.secretKey, serverPublic));
+ const H = sha256(concat(pack(clientVersion, serverVersion, clientKexinit, serverKexinit, hostKey, ephemeral.publicKey, serverPublic), K));
+ if (!ed25519.verify(signature.subarray(-64), H, hostKey.subarray(-32))) throw new Error('bad host key signature');
+
+ const id = (sessionId ??= H);
+ const key = (letter: string) => {
+ const k1 = sha256(concat(K, H, enc.encode(letter), id));
+ return concat(k1, sha256(concat(K, H, k1)));
+ };
+ send(Uint8Array.of(21));
+ tx = chachaPoly(key('C'));
+ await expect(21);
+ rx = chachaPoly(key('D'));
+ const messages = held;
+ held = undefined;
+ messages.forEach(send);
+ }
+ await kex();
+
+ send(msg(5, 'ssh-userauth'));
+ await expect(6);
+ const { publicKey, seed } = parsePrivateKey(o.privateKey);
+ const auth = msg(50, o.user, 'ssh-connection', 'publickey', true, 'ssh-ed25519', publicKey);
+ send(concat(auth, pack(pack('ssh-ed25519', ed25519.sign(concat(pack(sessionId!), auth), seed)))));
+ if ((await receive())[0] !== 52) throw new Error('authentication failed');
+
+ send(msg(90, 'session', 0, WINDOW, MAX_PACKET));
+ const confirmation = reader(await expect(91), 5);
+ channel = confirmation.u32();
+ remoteWindow = confirmation.u32();
+ remoteMaxPacket = confirmation.u32();
+ send(msg(98, channel, 'pty-req', true, o.term, o.cols, o.rows, 0, 0, '\0')); // no terminal modes, just TTY_OP_END
+ send(msg(98, channel, 'shell', true));
+ flush();
+
+ let window = WINDOW;
+ for (;;) {
+ const p = await receive();
+ const r = reader(p, 5); // everything from here on is for our only channel
+ switch (p[0]) {
+ case 20: await kex(p); break;
+ case 93: remoteWindow += r.u32(); flush(); break;
+ case 95: r.u32(); // extended data (stderr): skip the type code
+ // falls through
+ case 94: {
+ const data = r.bytes();
+ o.onData(data);
+ if ((window -= data.length) <= WINDOW / 2) {
+ send(msg(93, channel, WINDOW - window));
+ window = WINDOW;
+ }
+ break;
+ }
+ case 98: r.str(); if (r.u8()) send(msg(100, channel)); break; // exit-status, keepalive, ...
+ case 100: throw new Error('pty or shell request failed');
+ case 97: send(msg(97, channel)); return;
+ }
+ }
+ }
+
+ run().catch((e) => e).then((error) => { socket.close(); o.onClose(error); });
+
+ return {
+ write(data) {
+ pending = concat(pending, typeof data === 'string' ? enc.encode(data) : data);
+ flush();
+ },
+ resize(cols, rows) {
+ o.cols = cols;
+ o.rows = rows;
+ if (channel !== undefined) send(msg(98, channel, 'window-change', false, cols, rows, 0, 0));
+ },
+ };
+}
diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts
new file mode 100644
index 0000000..980f95b
--- /dev/null
+++ b/src/scripts/terminal.ts
@@ -0,0 +1,129 @@
+// The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent.
+import { Terminal, type IMarker } from '@xterm/xterm';
+import { FitAddon } from '@xterm/addon-fit';
+import css from '@xterm/xterm/css/xterm.css?inline';
+import { connect } from './ssh';
+
+const SERVER = 'wss://web.nowhere.wirs.ing';
+const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A';
+const SCROLLBACK = 10000;
+
+/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
+export async function start(prompt: HTMLElement, command: string) {
+ const privateKey = await (await fetch('root_key')).text();
+ document.head.append(Object.assign(document.createElement('style'), { textContent: css }));
+ // The page is the scrollback: lines that scroll off the terminal's screen continue as page text above it,
+ // and the screen only grows to the rows in use, up to the window height.
+ const history = document.createElement('div');
+ history.className = 'history';
+ const screen = document.createElement('div');
+ screen.className = 'screen';
+ const element = screen.appendChild(document.createElement('div'));
+ prompt.after(history, screen);
+
+ const { color, fontFamily, fontSize } = getComputedStyle(screen);
+ const term = new Terminal({
+ fontFamily,
+ fontSize: parseFloat(fontSize),
+ scrollback: SCROLLBACK,
+ cursorBlink: !matchMedia('(prefers-reduced-motion: reduce)').matches,
+ cursorInactiveStyle: 'block', // typing anywhere on the page reaches the terminal, focused or not
+ theme: { background: '#00000000', foreground: color, cursor: color, green: color }, // xterm.js rejects 'transparent'
+ });
+ const fit = new FitAddon();
+ term.loadAddon(fit);
+ term.open(element);
+ fit.fit();
+ // The wheel scrolls the page, except in full-screen apps, where xterm.js turns it into arrow keys.
+ screen.addEventListener('wheel', (e) => term.buffer.active.type === 'normal' && e.stopPropagation(), { capture: true });
+
+ const grid = element.querySelector('.xterm-screen')!;
+ let used = 1; // screen rows in use, like a terminal window filling up
+ let moved: IMarker | undefined; // the last scrollback line already moved into the page
+ function layout() {
+ const buffer = term.buffer.active;
+ const rowHeight = grid.clientHeight / term.rows;
+ if (buffer.type === 'normal') {
+ used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1);
+ const last = moved && !moved.isDisposed ? moved.line : -1;
+ for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen
+ for (let y = last + 1; y < buffer.baseY; y++) history.append(`${buffer.getLine(y)!.translateToString(true)}\n`);
+ while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove();
+ if (last !== buffer.baseY - 1) {
+ moved?.dispose();
+ moved = buffer.baseY ? term.registerMarker(-1 - buffer.cursorY) : undefined;
+ }
+ }
+ history.style.lineHeight = `${rowHeight}px`;
+ screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`;
+ }
+ // "Erase scrollback" (sent by `clear`) erases it from the page too.
+ term.parser.registerCsiHandler({ final: 'J' }, (params) => (params[0] === 3 && history.replaceChildren(), false));
+
+ // Like a terminal window, the page scrolls along as output reaches the bottom, unless the visitor scrolled away.
+ const cursorRow = () => {
+ const { top, height } = grid.getBoundingClientRect();
+ const rowHeight = height / term.rows;
+ return { top: top + term.buffer.active.cursorY * rowHeight, bottom: top + (term.buffer.active.cursorY + 1) * rowHeight };
+ };
+ const reveal = () =>
+ term.buffer.active.type === 'alternate'
+ ? screen.scrollIntoView({ block: 'nearest' }) // full-screen apps are shown whole
+ : scrollBy(0, Math.max(0, Math.ceil(cursorRow().bottom - innerHeight))); // whole pixels, or it stays a fraction below
+ let follow = true;
+ addEventListener('scroll', () => (follow = cursorRow().top < innerHeight), { passive: true });
+ term.onWriteParsed(() => {
+ layout();
+ if (follow) reveal();
+ });
+ term.onResize(layout);
+ new ResizeObserver(() => {
+ fit.fit();
+ if (follow) reveal();
+ }).observe(element);
+ layout();
+ reveal();
+ term.focus();
+
+ // The page's prompt stands in for the shell's first prompt, so that prompt and the shell's echo of the command are dropped.
+ let phase: 'starting' | 'echo' | 'live' = 'starting';
+ let typeahead = '';
+ let idle: ReturnType;
+ const session = connect({
+ socket: new WebSocket(SERVER),
+ user: 'root',
+ privateKey,
+ hostKey: HOST_KEY,
+ term: 'xterm-256color',
+ cols: term.cols,
+ rows: term.rows,
+ onData(data) {
+ if (phase === 'live') return term.write(data);
+ if (phase === 'echo') {
+ const newline = data.indexOf(10);
+ if (newline >= 0) {
+ phase = 'live';
+ term.write(data.subarray(newline + 1));
+ }
+ return;
+ }
+ // The shell is ready once its first output has settled; a command sent earlier would be echoed twice.
+ clearTimeout(idle);
+ idle = setTimeout(() => {
+ phase = 'echo';
+ session.write(`${command}\r${typeahead}`);
+ }, 300);
+ },
+ onClose(error) {
+ term.write(`${term.buffer.active.cursorX ? '\r\n' : ''}[${error?.message ?? 'connection closed'}]\r\n`);
+ },
+ });
+ term.onData((data) => {
+ reveal();
+ if (phase === 'starting') typeahead += data;
+ else session.write(data);
+ });
+ term.onBinary((data) => session.write(Uint8Array.from(data, (c) => c.charCodeAt(0)))); // mouse reports in the default encoding
+ term.onResize(({ cols, rows }) => session.resize(cols, rows));
+ return term;
+}
diff --git a/src/styles/style.css b/src/styles/style.css
index 24f2f33..4e1ecc3 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -54,7 +54,7 @@ a:hover, a:focus-visible {
}
/* Prompt */
-.prompt { color: var(--dim); }
+.prompt { color: var(--dim); white-space: pre-wrap; }
.prompt b { color: var(--fg); font-weight: normal; }
.cursor {
display: inline-block;
@@ -64,6 +64,20 @@ a:hover, a:focus-visible {
animation: blink 1.1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
+#term-input { all: unset; width: 0; padding-right: 1px; caret-color: var(--fg); } /* as wide as its text */
+.prompt:focus-within .cursor { display: none; } /* the input's own caret takes over while typing */
+
+/* Terminal: continues right below the first prompt. Lines scrolled off its screen become page text,
+ the screen shows the rows in use and xterm.js fits rows and columns to the window. */
+.prompt:has(+ .history) { margin-bottom: 0; }
+.history { white-space: pre-wrap; }
+.screen { overflow: clip; }
+.screen > div { height: 100dvh; }
+.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */
+@media (prefers-reduced-motion: no-preference) {
+ /* typing still reaches the terminal when it is not focused, so its cursor keeps blinking like the page's */
+ .screen .xterm-rows:not(.xterm-focus) .xterm-cursor-block { animation: blink 1.1s steps(1) infinite; }
+}
/* Header */
header {