---
// The terminal demo: an asciinema cast of grr's real output, played by the
// self-hosted asciinema-player.
//
// The mount is lazy, armed by an IntersectionObserver below: the player
// bundle (~50 KB) and the .cast fetch only happen once this section nears
// the viewport, so the page's LCP is untouched. Astro's ClientRouter swaps
// in new DOM nodes that carry no mount marker, so `astro:page-load` re-arms
// the observer and the whole thing is idempotent.
// A `client:*` directive would NOT do this: DemoPlayer is an Astro
// component, and Astro components never render in the client. The directive
// is ignored with a build warning, which left the player mounting eagerly on
// load while this comment claimed otherwise.
//
// The player's CSS is NOT imported in the frontmatter. A frontmatter import
// -- and even a dynamic `import()` of the stylesheet -- is hoisted by Astro
// into a render-blocking <link> on every page load, for a terminal that only
// exists far below the fold: 3.8 KB of critical-path CSS for content nobody
// has scrolled to yet. Importing it with Vite's `?url` suffix yields the
// built asset's URL without registering it as a page stylesheet, so
// ensureStyles() can attach it at mount time instead.
//
// Ordering matters: the stylesheet must be applied before create() runs, or
// the cast paints once without `white-space: pre` and its newlines collapse
// into a staircase. So ensureStyles resolves on the link's load event rather
// than merely appending it.
interface Props {
src?: string;
duration?: string;
}
// Single source of truth: demo/demo.cast at the repo root — the file demo.yml
// regenerates on every CLI or version change. It used to be mirrored by hand
// into site/public/demo/, and the mirror rotted: the live site kept playing a
// 0.5.0-era recording while the real cast on main was three re-recordings
// newer. Importing it as an asset emits it content-hashed under _astro/, so a
// re-recorded cast is a new URL and the immutable-cache rule for built assets
// can never serve a stale one.
import demoCastUrl from '../../../demo/demo.cast?url';
// The same file as text, for the caption's pre-hydration value: the header
// line's `duration`, the exact field the player itself reads at mount. No
// hand-maintained number, so the server-rendered caption cannot drift either.
import demoCastRaw from '../../../demo/demo.cast?raw';
const castDuration = (JSON.parse(demoCastRaw.split('\n')[0]) as { duration?: number }).duration ?? 0;
const { src = demoCastUrl, duration = `${Math.round(castDuration)}s` } = Astro.props;
---
<div class="demo-shell" data-demo-player data-demo-src={src}>
<div class="demo-bar" aria-hidden="true">
<span class="demo-dots"><i></i><i></i><i></i></span>
<span class="demo-title">grr — a real terminal, a real agent</span>
<span class="demo-dims" data-demo-dims>{duration} · asciinema</span>
</div>
<div class="demo-stage" data-demo-stage></div>
</div>
<script>
type CreateFn = typeof import('asciinema-player').create;
let player: Promise<CreateFn> | null = null;
import playerCss from 'asciinema-player/dist/bundle/asciinema-player.css?url';
const STYLE_ATTR = 'data-demo-styles';
const ensureStyles = () =>
new Promise<void>((resolve) => {
const existing = document.querySelector<HTMLLinkElement>(`link[${STYLE_ATTR}]`);
if (existing?.sheet) {
resolve();
return;
}
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => resolve(), { once: true });
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = playerCss;
link.setAttribute(STYLE_ATTR, '');
link.addEventListener('load', () => resolve(), { once: true });
link.addEventListener('error', () => resolve(), { once: true });
document.head.appendChild(link);
});
const loadPlayer = () =>
(player ??= Promise.all([ensureStyles(), import('asciinema-player')]).then(([, mod]) => mod.create));
function syncCaption(instance: ReturnType<CreateFn>, shell: HTMLElement | null) {
if (!shell) return;
let applied = false;
const apply = () => {
if (applied) return;
const seconds = instance.getDuration();
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return;
applied = true;
const dims = shell.querySelector<HTMLElement>('[data-demo-dims]');
if (dims) dims.textContent = `${Math.round(seconds)}s · asciinema`;
};
instance.addEventListener('playing', apply);
apply();
let attempts = 0;
const poll = () => {
if (applied || attempts >= 20) return;
attempts += 1;
apply();
if (!applied) setTimeout(poll, 100);
};
setTimeout(poll, 100);
}
async function mountPending() {
const pending = Array.from(document.querySelectorAll<HTMLElement>('[data-demo-stage]')).filter(
(stage) => stage.dataset.demoMounted !== 'true',
);
if (pending.length === 0) return;
for (const stage of pending) stage.dataset.demoMounted = 'true';
let create: CreateFn;
try {
create = await loadPlayer();
} catch (error) {
for (const stage of pending) stage.textContent = 'demo unavailable';
return;
}
for (const stage of pending) {
const shell = stage.closest<HTMLElement>('[data-demo-player]');
const src = shell?.dataset.demoSrc;
if (!src) {
stage.textContent = 'demo unavailable';
continue;
}
try {
const instance = create(src, stage, {
loop: true,
autoPlay: true,
terminalFontFamily: "'IBM3270', 'IBM3270 Fallback', monospace",
fit: 'width',
theme: 'asciinema',
});
syncCaption(instance, shell);
} catch (error) {
stage.textContent = 'demo unavailable';
if (shell) shell.dataset.demoMounted = 'failed';
}
}
}
let observer: IntersectionObserver | null = null;
function arm() {
const shells = document.querySelectorAll<HTMLElement>('[data-demo-player]');
if (shells.length === 0) return;
if (typeof IntersectionObserver === 'undefined') {
mountPending();
return;
}
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
mountPending();
const pending = Array.from(document.querySelectorAll<HTMLElement>('[data-demo-stage]')).some(
(stage) => stage.dataset.demoMounted !== 'true',
);
if (!pending && observer) {
observer.disconnect();
observer = null;
}
},
{ rootMargin: '200px' },
);
shells.forEach((shell) => observer?.observe(shell));
}
arm();
document.addEventListener('astro:page-load', arm);
</script>