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
This commit is contained in:
+118
-190
@@ -1,100 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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, usePrefersReducedMotion } from './format';
|
||||
|
||||
/** `instant` is not "very fast": it is "do not animate, show me the end". */
|
||||
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';
|
||||
|
||||
export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];
|
||||
|
||||
/** Wall-clock dwell on a step at 1x. Not the model's real latency — see below. */
|
||||
const BASE_STEP_MS = 1800;
|
||||
|
||||
export interface UseTracePlaybackOptions {
|
||||
stepCount: number;
|
||||
step: number;
|
||||
onStepChange: (next: number) => void;
|
||||
/**
|
||||
* Dwell time for one step at 1x, in ms. Defaults to a fixed cadence rather
|
||||
* than the recorded `durationMs`, and that is deliberate: real calls run from
|
||||
* 300 ms to half a minute, so replaying at true latency produces a player
|
||||
* that appears frozen. The recorded latency is still shown, verbatim, in the
|
||||
* model-call panel — it is reported, just not used as a timeline.
|
||||
*/
|
||||
stepDurationMs?: (index: number) => number;
|
||||
initialSpeed?: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export interface TracePlayback {
|
||||
playing: boolean;
|
||||
speed: PlaybackSpeed;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setSpeed: (speed: PlaybackSpeed) => void;
|
||||
toggle: () => void;
|
||||
restart: () => void;
|
||||
atEnd: boolean;
|
||||
}
|
||||
|
||||
export function useTracePlayback({
|
||||
stepCount,
|
||||
step,
|
||||
onStepChange,
|
||||
stepDurationMs,
|
||||
initialSpeed = 1,
|
||||
}: UseTracePlaybackOptions): TracePlayback {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeedState] = useState<PlaybackSpeed>(initialSpeed);
|
||||
const atEnd = step >= stepCount - 1;
|
||||
|
||||
// The callback identity changes on every render of the shell; holding it in a
|
||||
// ref keeps it out of the timer effect's deps, or the timer restarts on every
|
||||
// render and the step never lands.
|
||||
const onStepChangeRef = useRef(onStepChange);
|
||||
onStepChangeRef.current = onStepChange;
|
||||
|
||||
const setSpeed = useCallback(
|
||||
(next: PlaybackSpeed) => {
|
||||
setSpeedState(next);
|
||||
if (next === 'instant') {
|
||||
setPlaying(false);
|
||||
onStepChangeRef.current(Math.max(stepCount - 1, 0));
|
||||
}
|
||||
},
|
||||
[stepCount],
|
||||
);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
onStepChangeRef.current(0);
|
||||
setPlaying(stepCount > 1);
|
||||
}, [stepCount]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (stepCount <= 1) return;
|
||||
setPlaying((was) => {
|
||||
if (was) return false;
|
||||
// Pressing play at the end replays from the top rather than doing
|
||||
// nothing, which is what every visitor expects and nobody says out loud.
|
||||
if (step >= stepCount - 1) onStepChangeRef.current(0);
|
||||
return true;
|
||||
});
|
||||
}, [step, stepCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || speed === 'instant' || stepCount <= 1) return;
|
||||
if (step >= stepCount - 1) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
const base = stepDurationMs?.(step) ?? BASE_STEP_MS;
|
||||
const timer = window.setTimeout(() => {
|
||||
onStepChangeRef.current(step + 1);
|
||||
}, Math.max(base / speed, 120));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [playing, speed, step, stepCount, stepDurationMs]);
|
||||
|
||||
return { playing, speed, setPlaying, setSpeed, toggle, restart, atEnd };
|
||||
}
|
||||
import { formatDate } from './format';
|
||||
|
||||
export interface TracePlayerProps {
|
||||
playing: boolean;
|
||||
@@ -105,7 +14,15 @@ export interface TracePlayerProps {
|
||||
step: number;
|
||||
stepCount: number;
|
||||
onStepChange: (next: number) => void;
|
||||
/** Straight off the run: never a marketing name for the model. */
|
||||
/** 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. */
|
||||
@@ -116,11 +33,11 @@ export interface TracePlayerProps {
|
||||
/**
|
||||
* Transport controls for a recorded rollout.
|
||||
*
|
||||
* There is no spinner anywhere in this component and there never should be. A
|
||||
* spinner implies a request is in flight; nothing here is live, and an exec who
|
||||
* 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 — it is not a disclosure
|
||||
* we tuck into a footnote, it sits in the transport bar for the whole session.
|
||||
* 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,
|
||||
@@ -131,105 +48,110 @@ export function TracePlayer({
|
||||
step,
|
||||
stepCount,
|
||||
onStepChange,
|
||||
progress = 0,
|
||||
timingIsReal = true,
|
||||
model,
|
||||
capturedAt,
|
||||
intervention,
|
||||
className,
|
||||
}: TracePlayerProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const canPlay = stepCount > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
onClick={() => onStepChange(Math.max(step - 1, 0))}
|
||||
disabled={step <= 0}
|
||||
aria-label="Previous step"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg bg-primary px-4 text-primary-foreground transition-colors duration-2 ease-enter hover:bg-primary/90 disabled:opacity-40"
|
||||
onClick={() => onPlayingChange(!playing)}
|
||||
disabled={!canPlay}
|
||||
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
|
||||
aria-keyshortcuts="Space"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
onClick={() => onStepChange(Math.min(step + 1, Math.max(stepCount - 1, 0)))}
|
||||
disabled={step >= stepCount - 1}
|
||||
aria-label="Next step"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
onClick={onRestart}
|
||||
aria-label="Restart from the first step"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<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} />
|
||||
|
||||
{reducedMotion ? (
|
||||
// Not an apology — a statement that the page is behaving as asked. The
|
||||
// steps still advance; only the tile flips and slides are gone.
|
||||
<p className="sr-only">
|
||||
Reduced motion is on. Steps still advance and every change is announced.
|
||||
<p className="nums text-sm text-muted">
|
||||
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
|
||||
{stepCount}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +160,8 @@ export interface RecordedBadgeProps {
|
||||
model: string;
|
||||
capturedAt: string;
|
||||
intervention?: string;
|
||||
/** Rendered when the playback pacing is not the model's own. */
|
||||
timingNote?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -245,6 +169,7 @@ export function RecordedBadge({
|
||||
model,
|
||||
capturedAt,
|
||||
intervention,
|
||||
timingNote,
|
||||
className,
|
||||
}: RecordedBadgeProps) {
|
||||
return (
|
||||
@@ -254,7 +179,7 @@ export function RecordedBadge({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Circle className="h-2 w-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
|
||||
<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>
|
||||
@@ -265,6 +190,9 @@ export function RecordedBadge({
|
||||
{intervention}
|
||||
</span>
|
||||
) : null}
|
||||
{timingNote ? (
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5">{timingNote}</span>
|
||||
) : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user