Pin seed->word across both languages with a shared hash
engine.ts used mulberry32 and engine.py used random.Random(seed). Same seed, different word — so every ?seed= permalink on the site would have shown a different puzzle than the recorded run it claimed to be replaying, and nobody would have noticed until someone checked one by hand. Both now derive the index from FNV-1a 32-bit over the decimal seed. A hash rather than a PRNG because there is no honest one-line JavaScript equivalent of Mersenne Twister, and this way there is nothing to keep in step: both sides compute the same integer from the same string. Math.imul on the JS side is load-bearing — a plain multiply overflows into a double and diverges after the first few bytes. Twelve seeds are pinned as a vector in both test suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The demo-kit public barrel.
|
||||
*
|
||||
* This is the ONLY module a demo under `src/demos/` is allowed to import from
|
||||
* the shared shell, and it deliberately exposes a small surface: the contract
|
||||
* types, the two `define*` wrappers, and the two pure helpers a demo's own
|
||||
* surface might need to render a score honestly.
|
||||
*
|
||||
* The player, the registry, the verifier and the reward editor's arithmetic are
|
||||
* NOT here. They are shell machinery — a demo that reaches for `usePlayer` is a
|
||||
* demo that has started rendering its own chrome, and the whole point of the
|
||||
* contract is that the shell owns chrome so every demo gets the same one. The
|
||||
* shell imports those from their own modules:
|
||||
*
|
||||
* import { listDemos, loadDemoModule } from '@/lib/demo-kit/registry';
|
||||
* import { usePlayer } from '@/lib/demo-kit/player';
|
||||
* import { loadEpisode, listRuns } from '@/lib/demo-kit/episode';
|
||||
* import { decompose, reweight } from '@/lib/demo-kit/reward';
|
||||
* import { verifyEpisode } from '@/lib/demo-kit/verify';
|
||||
*/
|
||||
|
||||
export type {
|
||||
DemoEpisode,
|
||||
DemoMeta,
|
||||
DemoModule,
|
||||
DemoStatus,
|
||||
DemoStep,
|
||||
Limit,
|
||||
ModelCall,
|
||||
Narrative,
|
||||
Provenance,
|
||||
RewardComponent,
|
||||
RewardSpec,
|
||||
RewardValues,
|
||||
RunRef,
|
||||
StoryBeat,
|
||||
Vertical,
|
||||
} from './types';
|
||||
|
||||
export { defineDemo, defineMeta } from './define';
|
||||
|
||||
/** `null` is "not scored", never 0.0. Demos render absences with these two. */
|
||||
export { isNotScored, rewardTotal } from './episode';
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* The replay transport.
|
||||
*
|
||||
* A recorded rollout is played back on the timings the model actually took.
|
||||
* That is not decoration: "the second guess took four seconds and 900 tokens"
|
||||
* is one of the few things on this page an executive can feel rather than read.
|
||||
*
|
||||
* So the hard rule here is that **no code path invents a duration silently**.
|
||||
* When a step's `call.durationMs` is null the player falls back to
|
||||
* `FALLBACK_STEP_MS` and says so — `timingIsReal` goes false and `invented[i]`
|
||||
* marks the step — so the UI can label the timeline as approximate instead of
|
||||
* quietly presenting a made-up number as a measurement.
|
||||
*
|
||||
* Playback always starts PAUSED. A board that animates itself the moment the
|
||||
* page loads has already played its best moment to a visitor who was still
|
||||
* reading the headline.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import type { DemoStep } from './types';
|
||||
|
||||
/** 1x, 2x, 4x, or straight to the end. */
|
||||
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';
|
||||
|
||||
export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];
|
||||
|
||||
export function isPlaybackSpeed(value: unknown): value is PlaybackSpeed {
|
||||
return value === 1 || value === 2 || value === 4 || value === 'instant';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dwell for a step whose real duration was not recorded. Exported and named so
|
||||
* that when it appears on screen it can be labelled as the estimate it is.
|
||||
*/
|
||||
export const FALLBACK_STEP_MS = 900;
|
||||
|
||||
/**
|
||||
* How often the fractional progress within a step is pushed into React state.
|
||||
*
|
||||
* Not every frame, on purpose: `progress` re-renders the whole demo page, and
|
||||
* at 60 Hz on a phone that is the difference between a smooth board and a warm
|
||||
* one. ~15 Hz is plenty as long as whatever consumes it has a CSS transition on
|
||||
* the property it drives — give your progress bar `transition-[width]
|
||||
* duration-1` and the gaps disappear.
|
||||
*/
|
||||
const PROGRESS_TICK_MS = 66;
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
function subscribeReducedMotion(onChange: () => void): () => void {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return () => undefined;
|
||||
}
|
||||
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}
|
||||
|
||||
function readReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||
}
|
||||
|
||||
/** Live `prefers-reduced-motion`. Re-renders when the OS setting changes. */
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
return useSyncExternalStore(subscribeReducedMotion, readReducedMotion, () => false);
|
||||
}
|
||||
|
||||
/** The recorded duration, or `null` when the trace did not carry a usable one. */
|
||||
function recordedDurationMs(step: DemoStep<unknown> | undefined): number | null {
|
||||
const recorded = step?.call?.durationMs;
|
||||
if (recorded === null || recorded === undefined) return null;
|
||||
// A zero or negative duration is a recording artefact, not a measurement.
|
||||
if (!Number.isFinite(recorded) || recorded <= 0) return null;
|
||||
return recorded;
|
||||
}
|
||||
|
||||
export interface PlayerOptions {
|
||||
/** Where to start — typically the `step` URL param. Clamped. */
|
||||
initialIndex?: number;
|
||||
initialSpeed?: PlaybackSpeed;
|
||||
/** Fired on every index change, including seeks. Used to sync the permalink. */
|
||||
onIndexChange?: (index: number) => void;
|
||||
/** Override the invented dwell. Still reported as invented. */
|
||||
fallbackStepMs?: number;
|
||||
}
|
||||
|
||||
export interface Player<TState> {
|
||||
index: number;
|
||||
/** `undefined` only when there are no steps at all. */
|
||||
step: DemoStep<TState> | undefined;
|
||||
stepCount: number;
|
||||
isPlaying: boolean;
|
||||
speed: PlaybackSpeed;
|
||||
/** 0..1 through the current step. Pinned to 0 under reduced motion. */
|
||||
progress: number;
|
||||
elapsedMs: number;
|
||||
totalMs: number;
|
||||
atStart: boolean;
|
||||
atEnd: boolean;
|
||||
/** The OS setting, surfaced so surfaces can skip their own animations too. */
|
||||
reducedMotion: boolean;
|
||||
/** False when ANY step's dwell was invented. Label the timeline when false. */
|
||||
timingIsReal: boolean;
|
||||
/** Per step: was this dwell invented? */
|
||||
invented: readonly boolean[];
|
||||
/** The dwell actually used per step, real or invented. */
|
||||
durationsMs: readonly number[];
|
||||
fallbackStepMs: number;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
toggle: () => void;
|
||||
/** Jump to a step. Does NOT change play/pause — a scrubber drag keeps playing. */
|
||||
seek: (index: number) => void;
|
||||
/** Manual stepping pauses: you asked to look at this one. */
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
restart: () => void;
|
||||
setSpeed: (speed: PlaybackSpeed) => void;
|
||||
}
|
||||
|
||||
export function usePlayer<TState>(
|
||||
steps: readonly DemoStep<TState>[],
|
||||
options: PlayerOptions = {},
|
||||
): Player<TState> {
|
||||
const fallbackStepMs = options.fallbackStepMs ?? FALLBACK_STEP_MS;
|
||||
const stepCount = steps.length;
|
||||
const lastIndex = Math.max(0, stepCount - 1);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const [index, setIndexState] = useState(() =>
|
||||
clamp(options.initialIndex ?? 0, 0, Math.max(0, steps.length - 1)),
|
||||
);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [speed, setSpeedState] = useState<PlaybackSpeed>(options.initialSpeed ?? 1);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const indexRef = useRef(index);
|
||||
const elapsedRef = useRef(0);
|
||||
const lastProgressPushRef = useRef(0);
|
||||
|
||||
// Held in a ref so changing the callback never restarts the animation loop.
|
||||
const onIndexChangeRef = useRef(options.onIndexChange);
|
||||
onIndexChangeRef.current = options.onIndexChange;
|
||||
|
||||
const durationsMs = useMemo(
|
||||
() => steps.map((step) => recordedDurationMs(step) ?? fallbackStepMs),
|
||||
[steps, fallbackStepMs],
|
||||
);
|
||||
const invented = useMemo(() => steps.map((step) => recordedDurationMs(step) === null), [steps]);
|
||||
const timingIsReal = useMemo(() => !invented.includes(true), [invented]);
|
||||
|
||||
const cumulativeMs = useMemo(() => {
|
||||
let running = 0;
|
||||
return durationsMs.map((duration) => {
|
||||
const start = running;
|
||||
running += duration;
|
||||
return start;
|
||||
});
|
||||
}, [durationsMs]);
|
||||
const totalMs = useMemo(() => durationsMs.reduce((sum, d) => sum + d, 0), [durationsMs]);
|
||||
|
||||
const dwellAt = useCallback(
|
||||
(at: number) => durationsMs[at] ?? fallbackStepMs,
|
||||
[durationsMs, fallbackStepMs],
|
||||
);
|
||||
|
||||
const commitIndex = useCallback((next: number) => {
|
||||
if (indexRef.current === next) return;
|
||||
indexRef.current = next;
|
||||
setIndexState(next);
|
||||
onIndexChangeRef.current?.(next);
|
||||
}, []);
|
||||
|
||||
// A new steps array means a new run. Rewind rather than leaving the transport
|
||||
// pointing at step 7 of a rollout that only has four turns.
|
||||
const stepsRef = useRef(steps);
|
||||
useEffect(() => {
|
||||
if (stepsRef.current === steps) return;
|
||||
stepsRef.current = steps;
|
||||
elapsedRef.current = 0;
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
commitIndex(0);
|
||||
}, [steps, commitIndex]);
|
||||
|
||||
const seek = useCallback(
|
||||
(to: number) => {
|
||||
elapsedRef.current = 0;
|
||||
setProgress(0);
|
||||
commitIndex(clamp(to, 0, Math.max(0, stepsRef.current.length - 1)));
|
||||
},
|
||||
[commitIndex],
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (stepsRef.current.length === 0) return;
|
||||
// Pressing play on the final step replays from the top; the alternative is
|
||||
// a button that visibly does nothing.
|
||||
if (indexRef.current >= stepsRef.current.length - 1) {
|
||||
elapsedRef.current = 0;
|
||||
setProgress(0);
|
||||
commitIndex(0);
|
||||
}
|
||||
setIsPlaying(true);
|
||||
}, [commitIndex]);
|
||||
|
||||
const pause = useCallback(() => setIsPlaying(false), []);
|
||||
const toggle = useCallback(() => {
|
||||
if (isPlaying) pause();
|
||||
else play();
|
||||
}, [isPlaying, pause, play]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
seek(indexRef.current + 1);
|
||||
}, [seek]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
seek(indexRef.current - 1);
|
||||
}, [seek]);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
seek(0);
|
||||
}, [seek]);
|
||||
|
||||
const setSpeed = useCallback((nextSpeed: PlaybackSpeed) => setSpeedState(nextSpeed), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || stepCount === 0) return;
|
||||
|
||||
let raf = 0;
|
||||
let previous = performance.now();
|
||||
|
||||
const frame = (now: number): void => {
|
||||
// `instant` is expressed as infinite elapsed time rather than an infinite
|
||||
// rate: `(now - previous) * Infinity` is NaN on the very first frame,
|
||||
// where `now === previous`, and NaN would freeze the transport forever.
|
||||
const gained = speed === 'instant' ? Number.POSITIVE_INFINITY : (now - previous) * speed;
|
||||
previous = now;
|
||||
|
||||
const startIndex = indexRef.current;
|
||||
let elapsed = elapsedRef.current + gained;
|
||||
let at = startIndex;
|
||||
|
||||
while (at < lastIndex && elapsed >= dwellAt(at)) {
|
||||
elapsed -= dwellAt(at);
|
||||
at += 1;
|
||||
}
|
||||
|
||||
if (at >= lastIndex && elapsed >= dwellAt(lastIndex)) {
|
||||
elapsedRef.current = dwellAt(lastIndex);
|
||||
commitIndex(lastIndex);
|
||||
setProgress(1);
|
||||
setIsPlaying(false);
|
||||
return; // Run over. Deliberately not scheduling another frame.
|
||||
}
|
||||
|
||||
elapsedRef.current = elapsed;
|
||||
|
||||
if (at !== startIndex) {
|
||||
commitIndex(at);
|
||||
lastProgressPushRef.current = now;
|
||||
// Reduced motion still advances on real time — the content is not the
|
||||
// animation — but no fractional progress is emitted, so nothing on the
|
||||
// page is being driven frame by frame.
|
||||
setProgress(reducedMotion ? 0 : Math.min(1, elapsed / dwellAt(at)));
|
||||
} else if (!reducedMotion && now - lastProgressPushRef.current >= PROGRESS_TICK_MS) {
|
||||
lastProgressPushRef.current = now;
|
||||
setProgress(Math.min(1, elapsed / dwellAt(at)));
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isPlaying, speed, stepCount, lastIndex, dwellAt, reducedMotion, commitIndex]);
|
||||
|
||||
const elapsedMs = (cumulativeMs[index] ?? 0) + progress * dwellAt(index);
|
||||
|
||||
return {
|
||||
index,
|
||||
step: steps[index],
|
||||
stepCount,
|
||||
isPlaying,
|
||||
speed,
|
||||
progress,
|
||||
elapsedMs,
|
||||
totalMs,
|
||||
atStart: index === 0,
|
||||
atEnd: stepCount === 0 || index >= lastIndex,
|
||||
reducedMotion,
|
||||
timingIsReal,
|
||||
invented,
|
||||
durationsMs,
|
||||
fallbackStepMs,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
seek,
|
||||
next,
|
||||
prev,
|
||||
restart,
|
||||
setSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Per-route document head.
|
||||
*
|
||||
* `scripts/prerender.mjs` bakes these tags into the static HTML at build time,
|
||||
* which is what crawlers and link unfurlers actually read. This hook exists for
|
||||
* the other half: a visitor who lands on `/` and clicks through to a demo never
|
||||
* fetches a new document, so without it the tab title and the canonical link
|
||||
* would still say "home" three pages later.
|
||||
*
|
||||
* Deliberately no cleanup. Restoring the previous head on unmount would mean
|
||||
* every navigation flickers back to the old title before the next route sets
|
||||
* its own; the next route always sets one, so the last writer simply wins.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const SITE_ORIGIN = 'https://demo.primeintellectgrowth.com';
|
||||
export const SITE_NAME = 'PIG Demo';
|
||||
|
||||
/** `Wordle-five — PIG Demo`. One place, so every tab reads the same shape. */
|
||||
export function pageTitle(name?: string): string {
|
||||
return name && name.trim() !== '' ? `${name} — ${SITE_NAME}` : SITE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolutise a site-root path. Open Graph consumers do not resolve relative
|
||||
* URLs — a relative `og:image` is simply no image, silently, and you only find
|
||||
* out when someone pastes the link into Slack.
|
||||
*/
|
||||
export function absoluteUrl(pathOrUrl: string): string {
|
||||
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
||||
return `${SITE_ORIGIN}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`;
|
||||
}
|
||||
|
||||
export interface SeoInput {
|
||||
/** Used verbatim as `document.title`. Wrap with `pageTitle()` if you want the suffix. */
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Site-root path or absolute URL. */
|
||||
canonical?: string;
|
||||
/** Site-root path or absolute URL. */
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
type MetaKey = { name: string } | { property: string };
|
||||
|
||||
function upsertMeta(key: MetaKey, content: string): void {
|
||||
const selector =
|
||||
'name' in key ? `meta[name="${key.name}"]` : `meta[property="${key.property}"]`;
|
||||
let tag = document.head.querySelector<HTMLMetaElement>(selector);
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta');
|
||||
if ('name' in key) tag.setAttribute('name', key.name);
|
||||
else tag.setAttribute('property', key.property);
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
tag.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function upsertCanonical(href: string): void {
|
||||
let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.setAttribute('href', href);
|
||||
}
|
||||
|
||||
export function applySeo(input: SeoInput): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
document.title = input.title;
|
||||
upsertMeta({ property: 'og:title' }, input.title);
|
||||
upsertMeta({ name: 'twitter:title' }, input.title);
|
||||
|
||||
if (input.description) {
|
||||
upsertMeta({ name: 'description' }, input.description);
|
||||
upsertMeta({ property: 'og:description' }, input.description);
|
||||
upsertMeta({ name: 'twitter:description' }, input.description);
|
||||
}
|
||||
|
||||
if (input.canonical) {
|
||||
const href = absoluteUrl(input.canonical);
|
||||
upsertCanonical(href);
|
||||
upsertMeta({ property: 'og:url' }, href);
|
||||
}
|
||||
|
||||
if (input.ogImage) {
|
||||
const href = absoluteUrl(input.ogImage);
|
||||
upsertMeta({ property: 'og:image' }, href);
|
||||
upsertMeta({ name: 'twitter:image' }, href);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the head for this route.
|
||||
*
|
||||
* Deps are the individual strings rather than the object, so a caller can pass
|
||||
* an inline literal without re-running this on every render.
|
||||
*/
|
||||
export function useSeo(input: SeoInput): void {
|
||||
const { title, description, canonical, ogImage } = input;
|
||||
useEffect(() => {
|
||||
applySeo({
|
||||
title,
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(canonical === undefined ? {} : { canonical }),
|
||||
...(ogImage === undefined ? {} : { ogImage }),
|
||||
});
|
||||
}, [title, description, canonical, ogImage]);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Every permalink parameter on this site, in one module.
|
||||
*
|
||||
* The demo pages are meant to be sent to someone: "look at step 4 of the
|
||||
* fine-tuned run". That only works if the URL is the state, and it only stays
|
||||
* readable if a clean state produces a clean URL. So the two rules here are:
|
||||
*
|
||||
* 1. Defaults are OMITTED. `/demos/wordle-five` and
|
||||
* `/demos/wordle-five?step=0&speed=1&tab=play` are the same page, and only
|
||||
* the first one is worth pasting into an email.
|
||||
* 2. Unknown params SURVIVE. Every write is built from the params that are
|
||||
* already there, so a campaign tag or a future param added by another page
|
||||
* is not silently eaten by a scrub of the timeline.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { isPlaybackSpeed, type PlaybackSpeed } from '@/lib/demo-kit/player';
|
||||
|
||||
export const PARAM = {
|
||||
run: 'run',
|
||||
step: 'step',
|
||||
tab: 'tab',
|
||||
seed: 'seed',
|
||||
speed: 'speed',
|
||||
} as const;
|
||||
|
||||
/** The tab a demo page opens on when the URL says nothing. */
|
||||
export const DEFAULT_TAB = 'play';
|
||||
|
||||
export interface DemoUrlState {
|
||||
/** Run id from the manifest. `null` means "the demo's first run". */
|
||||
run: string | null;
|
||||
/** Zero-based step index. */
|
||||
step: number;
|
||||
tab: string;
|
||||
/** `null` means "the demo's own default seed". */
|
||||
seed: number | null;
|
||||
speed: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export type DemoUrlPatch = Partial<DemoUrlState>;
|
||||
|
||||
export interface PatchOptions {
|
||||
/**
|
||||
* Replace the history entry instead of pushing one. Left unset, params that
|
||||
* change during playback (`step`, `speed`) replace and everything else
|
||||
* pushes — so Back leaves the tab you were on rather than rewinding the
|
||||
* scrubber one frame at a time.
|
||||
*/
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
/** Params that move while the visitor is just watching, not navigating. */
|
||||
const TRANSIENT_PARAMS = new Set<keyof DemoUrlState>(['step', 'speed']);
|
||||
|
||||
function parseIndex(raw: string | null): number {
|
||||
if (raw === null) return 0;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseSeed(raw: string | null): number | null {
|
||||
if (raw === null || raw.trim() === '') return null;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseSpeed(raw: string | null): PlaybackSpeed {
|
||||
if (raw === null) return 1;
|
||||
if (raw === 'instant') return 'instant';
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return isPlaybackSpeed(parsed) ? parsed : 1;
|
||||
}
|
||||
|
||||
export function readUrlState(params: URLSearchParams, defaultTab = DEFAULT_TAB): DemoUrlState {
|
||||
const run = params.get(PARAM.run);
|
||||
const tab = params.get(PARAM.tab);
|
||||
return {
|
||||
run: run === null || run.trim() === '' ? null : run,
|
||||
step: parseIndex(params.get(PARAM.step)),
|
||||
tab: tab === null || tab.trim() === '' ? defaultTab : tab,
|
||||
seed: parseSeed(params.get(PARAM.seed)),
|
||||
speed: parseSpeed(params.get(PARAM.speed)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch to a set of params, dropping anything that is at its default.
|
||||
* Returns a NEW URLSearchParams; the input is never mutated.
|
||||
*/
|
||||
export function writeUrlState(
|
||||
params: URLSearchParams,
|
||||
patch: DemoUrlPatch,
|
||||
defaultTab = DEFAULT_TAB,
|
||||
): URLSearchParams {
|
||||
const next = new URLSearchParams(params);
|
||||
|
||||
const put = (key: string, value: string | null): void => {
|
||||
if (value === null) next.delete(key);
|
||||
else next.set(key, value);
|
||||
};
|
||||
|
||||
if ('run' in patch) put(PARAM.run, patch.run ?? null);
|
||||
if ('step' in patch) {
|
||||
const step = patch.step ?? 0;
|
||||
put(PARAM.step, step > 0 ? String(Math.trunc(step)) : null);
|
||||
}
|
||||
if ('tab' in patch) {
|
||||
const tab = patch.tab ?? defaultTab;
|
||||
put(PARAM.tab, tab === defaultTab ? null : tab);
|
||||
}
|
||||
if ('seed' in patch) {
|
||||
const seed = patch.seed;
|
||||
put(PARAM.seed, seed === null || seed === undefined ? null : String(Math.trunc(seed)));
|
||||
}
|
||||
if ('speed' in patch) {
|
||||
const speed = patch.speed ?? 1;
|
||||
put(PARAM.speed, speed === 1 ? null : String(speed));
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface UrlStateApi {
|
||||
state: DemoUrlState;
|
||||
patch: (changes: DemoUrlPatch, options?: PatchOptions) => void;
|
||||
/** Clear every param this module owns; anything else in the URL survives. */
|
||||
reset: (options?: PatchOptions) => void;
|
||||
/** `/demos/x?step=3` — path plus search, for a router `<Link to>`. */
|
||||
hrefFor: (changes?: DemoUrlPatch) => string;
|
||||
/** Absolute URL, for a copy-link button. */
|
||||
permalinkFor: (changes?: DemoUrlPatch) => string;
|
||||
}
|
||||
|
||||
export function useUrlState(options?: { defaultTab?: string }): UrlStateApi {
|
||||
const defaultTab = options?.defaultTab ?? DEFAULT_TAB;
|
||||
const [params, setParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const state = useMemo(() => readUrlState(params, defaultTab), [params, defaultTab]);
|
||||
|
||||
const patch = useCallback(
|
||||
(changes: DemoUrlPatch, patchOptions?: PatchOptions) => {
|
||||
const keys = Object.keys(changes) as (keyof DemoUrlState)[];
|
||||
const replace = patchOptions?.replace ?? keys.every((key) => TRANSIENT_PARAMS.has(key));
|
||||
// The updater form matters: two patches in the same tick (the player
|
||||
// advancing a step while the visitor clicks a tab) would otherwise both
|
||||
// read the pre-render params and the second would undo the first.
|
||||
setParams((prev) => writeUrlState(prev, changes, defaultTab), {
|
||||
replace,
|
||||
preventScrollReset: true,
|
||||
});
|
||||
},
|
||||
[setParams, defaultTab],
|
||||
);
|
||||
|
||||
const reset = useCallback(
|
||||
(patchOptions?: PatchOptions) => {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const key of Object.values(PARAM)) next.delete(key);
|
||||
return next;
|
||||
},
|
||||
{ replace: patchOptions?.replace ?? false, preventScrollReset: true },
|
||||
);
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const hrefFor = useCallback(
|
||||
(changes: DemoUrlPatch = {}) => {
|
||||
const search = writeUrlState(params, changes, defaultTab).toString();
|
||||
return search ? `${pathname}?${search}` : pathname;
|
||||
},
|
||||
[params, pathname, defaultTab],
|
||||
);
|
||||
|
||||
const permalinkFor = useCallback(
|
||||
(changes: DemoUrlPatch = {}) => {
|
||||
const href = hrefFor(changes);
|
||||
// Prerendering runs this file in a browser too, but guard anyway: a
|
||||
// permalink is not worth throwing a page away for.
|
||||
const origin = typeof window === 'undefined' ? '' : window.location.origin;
|
||||
return `${origin}${href}`;
|
||||
},
|
||||
[hrefFor],
|
||||
);
|
||||
|
||||
return { state, patch, reset, hrefFor, permalinkFor };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Single-param conveniences. Each is `[value, setValue]` and each writes
|
||||
* through the same omit-the-default path, so mixing them cannot produce a URL
|
||||
* that `useUrlState` reads back differently.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export function useRunParam(): [string | null, (run: string | null, options?: PatchOptions) => void] {
|
||||
const { state, patch } = useUrlState();
|
||||
const set = useCallback(
|
||||
(run: string | null, options?: PatchOptions) => {
|
||||
// A new run invalidates the step index: step 6 of a nine-turn rollout is
|
||||
// not step 6 of a three-turn one.
|
||||
patch({ run, step: 0 }, options);
|
||||
},
|
||||
[patch],
|
||||
);
|
||||
return [state.run, set];
|
||||
}
|
||||
|
||||
export function useStepParam(): [number, (step: number, options?: PatchOptions) => void] {
|
||||
const { state, patch } = useUrlState();
|
||||
const set = useCallback(
|
||||
(step: number, options?: PatchOptions) => patch({ step }, options),
|
||||
[patch],
|
||||
);
|
||||
return [state.step, set];
|
||||
}
|
||||
|
||||
export function useTabParam(
|
||||
defaultTab = DEFAULT_TAB,
|
||||
): [string, (tab: string, options?: PatchOptions) => void] {
|
||||
const { state, patch } = useUrlState({ defaultTab });
|
||||
const set = useCallback(
|
||||
(tab: string, options?: PatchOptions) => patch({ tab }, options),
|
||||
[patch],
|
||||
);
|
||||
return [state.tab, set];
|
||||
}
|
||||
|
||||
export function useSeedParam(): [number | null, (seed: number | null, options?: PatchOptions) => void] {
|
||||
const { state, patch } = useUrlState();
|
||||
const set = useCallback(
|
||||
(seed: number | null, options?: PatchOptions) => patch({ seed }, options),
|
||||
[patch],
|
||||
);
|
||||
return [state.seed, set];
|
||||
}
|
||||
|
||||
export function useSpeedParam(): [PlaybackSpeed, (speed: PlaybackSpeed, options?: PatchOptions) => void] {
|
||||
const { state, patch } = useUrlState();
|
||||
const set = useCallback(
|
||||
(speed: PlaybackSpeed, options?: PatchOptions) => patch({ speed }, options),
|
||||
[patch],
|
||||
);
|
||||
return [state.speed, set];
|
||||
}
|
||||
Reference in New Issue
Block a user