Files
PIG-Demo/src/components/demo/TracePlayer.tsx
T
karti-ai b601511e7f Wordle module, cross-language tests, and the deploy path
The browser engine is a port of the Python one and CI proves it: all 21.2M
(guess, answer) pairs hashed on both sides to the same SHA-256. Six TS tests,
including the duplicate-letter table and the twelve pinned seed vectors that
keep ?seed= permalinks pointing at the same word the recording used.

Word lists are split by how they are used. answers.json is inlined because the
board needs it before first paint to turn a seed into a word, and a fetch there
means a visibly empty board on a cold cache. guesses.json is fetched, because it
is three times larger and only needed the first time somebody presses Enter;
until it lands, validation falls back to the answer list, which accepts strictly
fewer words. The failure mode is 'your real word was briefly rejected', not 'a
non-word was accepted' — the right way round.

The solver runs in a worker constructed from a same-origin module URL, never
Vite's ?worker&inline: that yields a blob:, and production CSP has no
worker-src, so it falls back to default-src 'self' and the worker is blocked
with no console error. It would fail in production only.

deploy.sh smoke-tests the real public hostname from the deploying machine and
fails on a body under 1 kB, because the bind bug's signature is a valid
certificate over an empty 200 and a local --resolve check passes anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
2026-08-28 15:53:25 -07:00

199 lines
6.4 KiB
TypeScript

import { ChevronLeft, ChevronRight, Circle, Pause, Play, RotateCcw } from 'lucide-react';
import { PLAYBACK_SPEEDS } from '@/lib/demo-kit/player';
import type { PlaybackSpeed } from '@/lib/demo-kit/player';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { formatDate } from './format';
export interface TracePlayerProps {
playing: boolean;
onPlayingChange: (playing: boolean) => void;
speed: PlaybackSpeed;
onSpeedChange: (speed: PlaybackSpeed) => void;
onRestart: () => void;
step: number;
stepCount: number;
onStepChange: (next: number) => void;
/** 0..1 through the current step, from the player. Drives the hairline. */
progress?: number;
/**
* False when any step's dwell was invented because the trace carried no
* duration. Surfaced, not hidden: the player's whole claim is that the
* pacing is the model's, and where it is not, it says so.
*/
timingIsReal?: boolean;
/** Straight off the run. Never a marketing name for the model. */
model: string;
capturedAt: string;
/** Present only on an `intervened` run; the contract requires it there. */
intervention?: string;
className?: string;
}
/**
* Transport controls for a recorded rollout.
*
* There is no spinner in this component and there never should be. A spinner
* implies a request is in flight; nothing here is live, and an exec who
* believes they are watching a model think in real time has been misled by the
* UI rather than the copy. Hence the permanent badge — not a disclosure tucked
* into a footnote, but a fixture of the transport bar for the whole session.
*/
export function TracePlayer({
playing,
onPlayingChange,
speed,
onSpeedChange,
onRestart,
step,
stepCount,
onStepChange,
progress = 0,
timingIsReal = true,
model,
capturedAt,
intervention,
className,
}: TracePlayerProps) {
const canPlay = stepCount > 1;
return (
<div className={cn('card overflow-hidden', className)}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-touch"
onClick={() => onStepChange(Math.max(step - 1, 0))}
disabled={step <= 0}
aria-label="Previous step"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
size="touch"
onClick={() => onPlayingChange(!playing)}
disabled={!canPlay}
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
aria-keyshortcuts="Space"
>
{playing ? <Pause aria-hidden="true" /> : <Play aria-hidden="true" />}
</Button>
<Button
variant="ghost"
size="icon-touch"
onClick={() => onStepChange(Math.min(step + 1, Math.max(stepCount - 1, 0)))}
disabled={step >= stepCount - 1}
aria-label="Next step"
>
<ChevronRight aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon-touch"
onClick={onRestart}
aria-label="Restart from the first step"
>
<RotateCcw aria-hidden="true" />
</Button>
</div>
<p className="nums text-sm text-muted">
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
{stepCount}
</p>
<div
role="radiogroup"
aria-label="Playback speed"
className="flex items-center gap-0.5 rounded-lg bg-surface-2 p-0.5"
>
{PLAYBACK_SPEEDS.map((option) => {
const selected = option === speed;
return (
<button
key={String(option)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onSpeedChange(option)}
className={cn(
'min-h-9 rounded-md px-2.5 text-xs font-medium transition-colors duration-2 ease-enter',
selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg',
)}
>
{option === 'instant' ? 'Instant' : `${option}x`}
</button>
);
})}
</div>
<RecordedBadge
model={model}
capturedAt={capturedAt}
{...(intervention ? { intervention } : {})}
{...(timingIsReal ? {} : { timingNote: 'pacing approximate' })}
/>
</div>
{/*
A hairline, not a scrubber: the step chips below are the scrubber. It
exists so the pause between steps reads as time passing rather than as
the page having stopped. `transition-[width]` covers the ~15 Hz at which
the player pushes progress — without it the bar visibly ratchets.
*/}
<div className="h-0.5 w-full bg-surface-2" aria-hidden="true">
<div
className="h-full bg-brand transition-[width] duration-1 ease-enter"
style={{
width: `${
stepCount <= 1 ? 0 : ((step + Math.min(Math.max(progress, 0), 1)) / (stepCount - 1)) * 100
}%`,
}}
/>
</div>
</div>
);
}
export interface RecordedBadgeProps {
model: string;
capturedAt: string;
intervention?: string;
/** Rendered when the playback pacing is not the model's own. */
timingNote?: string;
className?: string;
}
export function RecordedBadge({
model,
capturedAt,
intervention,
timingNote,
className,
}: RecordedBadgeProps) {
return (
<p
className={cn(
'ml-auto flex flex-wrap items-center gap-x-1.5 gap-y-1 rounded-lg bg-surface-2 px-2.5 py-1.5 text-xs text-muted',
className,
)}
>
<Circle className="size-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
<span className="font-medium text-fg">Recorded run</span>
<span aria-hidden="true">·</span>
<span className="nums font-mono">{model}</span>
<span aria-hidden="true">·</span>
<span className="nums">{formatDate(capturedAt)}</span>
{intervention ? (
<span className="rounded-md bg-accent-subtle px-1.5 py-0.5 font-medium text-accent-fg">
{intervention}
</span>
) : null}
{timingNote ? (
<span className="rounded-md border border-border px-1.5 py-0.5">{timingNote}</span>
) : null}
</p>
);
}