Demo controls
Global latency and failure settings for demos, and a way to replay loading.
Demo controls
Upcoming payment runs
import { Button } from "@oration/canon/components/button";import { DataState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonRows } from "@oration/canon/components/skeletons";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { type DemoLatency, replayLoading, setDemoSettings, useDemoSettings,} from "@oration/canon/lib/demo-data";import { RotateCcwIcon } from "lucide-react";export function Hero() { const settings = useDemoSettings(); const runs = [ { name: "Friday payment run", detail: "212 invoices, $1,284,310.42" }, { name: "Wire batch for Halcyon", detail: "3 invoices, $48,900.00" }, { name: "Month-end accruals", detail: "61 invoices, $392,118.07" }, ]; const query = useMockQuery("docs.demo.payment-runs", () => runs, { delay: 700, }); return ( <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-[15rem_1fr]"> <div className="flex flex-col gap-3 rounded-xl bg-card p-3 shadow-border"> <p className="text-13 font-medium text-foreground"> Demo controls </p> <div className="flex flex-col gap-1.5"> <span className="text-xs text-muted-foreground"> Latency </span> <SegmentedControl<DemoLatency> label="Latency" value={settings.latency} onValueChange={(latency) => setDemoSettings({ latency }) } options={[ { value: "instant", label: "Instant" }, { value: "realistic", label: "Realistic" }, { value: "slow", label: "Slow" }, ]} /> </div> <Switch label="Simulate errors" checked={settings.failures} onCheckedChange={(failures) => setDemoSettings({ failures }) } className="px-0" /> <Button type="button" variant="outline" size="sm" onClick={() => { replayLoading(); toast.add({ title: "Loading states replayed" }); }} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Replay loading states </Button> </div> <div className="min-w-0 rounded-xl bg-card shadow-border"> <h3 className="px-4 pt-3 pb-1 text-sm font-medium text-foreground"> Upcoming payment runs </h3> <DataState query={query} size="sm" errorTitle="Couldn't load payment runs" skeleton={ <SkeletonRows rows={3} avatar={false} className="px-4" /> } > {(list) => ( <ul className="flex flex-col px-4 pb-3"> {list.map((run) => ( <li key={run.name} className="flex flex-col py-1.5" > <span className="text-13 font-medium text-foreground"> {run.name} </span> <span className="text-xs text-muted-foreground tabular-nums"> {run.detail} </span> </li> ))} </ul> )} </DataState> </div> </div> );}Usage#
Demo controls are the prototype's switches for the mock data layer: a latency setting that scales every useMockQuery wait, a failure switch that fails each first attempt, and replayLoading(), which forgets the warm cache so loading shows again. People reach them from the user menu under Demo controls; code reads them with useDemoSettings(). Settings persist per browser in localStorage. They exist for reviewing states, so product logic should never branch on them.
When to use
- Reviewing a page's loading states: set latency to Slow, then Replay loading states.
- Reviewing error and recovery: turn on Simulate errors and replay; every first attempt fails and Retry succeeds.
- Recording a walkthrough or demo where nothing should flash: set latency to Instant.
- Building a control for these settings somewhere else, such as a docs panel, with
setDemoSettingsandreplayLoading.
When not to use
- Showing one component's error state while building it. Pass
fail: trueto that query instead of failing the whole prototype. Use Mock query - Slowing down one heavy view. Use the query's
delay. Use Mock query - Product behavior. Don't hide a skeleton, skip a confirmation or change copy because latency is Instant.
- Simulating permissions or roles. The same submenu has View as role for that, backed by the access store. Use Permissions and availability
Anatomy#
- Demo controls submenu. In the user menu, with a flask icon. It also holds View as role, Auth scenarios and End my session, which are separate tools.
- Latency. A radio group: Instant (0x), Realistic (1x, the default) and Slow (3x).
- Simulate errors. A checkbox item. Fails the first attempt of every query.
- Replay loading states. Calls
replayLoading()and confirms with a Loading states replayed toast.
Examples#
Reading the settings
useDemoSettings() re-renders on every change. LATENCY_MULTIPLIER is what useMockQuery applies to its wait.
- latency
- realistic
- failures
- false
- multiplier
- 1x, so an 800ms query waits 800ms
import { LATENCY_MULTIPLIER, useDemoSettings } from "@oration/canon/lib/demo-data";export function ReadingSettings() { const { latency, failures } = useDemoSettings(); const multiplier = LATENCY_MULTIPLIER[latency]; return ( <dl className="grid w-full max-w-sm grid-cols-[7rem_1fr] gap-x-3 gap-y-1.5 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"> <dt className="font-mono text-xs leading-5 text-muted-foreground"> latency </dt> <dd className="font-mono text-xs leading-5 text-foreground"> {latency} </dd> <dt className="font-mono text-xs leading-5 text-muted-foreground"> failures </dt> <dd className="font-mono text-xs leading-5 text-foreground"> {String(failures)} </dd> <dt className="font-mono text-xs leading-5 text-muted-foreground"> multiplier </dt> <dd className="text-foreground tabular-nums"> {multiplier}x, so an 800ms query waits {800 * multiplier}ms </dd> </dl> );}States#
| State | Treatment |
|---|---|
| Realistic | The default. Each query waits its own delay, 250 to 850ms unless set. |
| Instant | Waits are zero. Queries that aren't failing render data on the first frame. |
| Slow | Every wait is tripled. |
| Simulate errors | The first attempt of each query fails with a timeout; Retry succeeds. |
| Replayed | Every mounted query goes back to loading and resolves again; queries mounted later load fresh. |
Behavior#
- Settings live in a module-level store read through
useSyncExternalStore, so every component in the tab sees a change at once. - On the server and during hydration the snapshot is the default (Realistic, no failures). The stored value is read from localStorage (
oration:demo-settings) on first client read. setDemoSettings(patch)merges a partial, writes localStorage and notifies subscribers. In private mode the write fails quietly and the setting lasts for the session.replayLoading()bumps a cache epoch. Every key resolved before it counts as cold again.- Turning on Simulate errors doesn't touch keys that are already warm; replay loading afterwards to see the errors.
- Other tabs don't pick up a change until they reload; there is no
storageevent listener.
Do and don't#
useDemoSettings(), for example hiding a skeleton when latency is Instant.Content#
- The submenu is labeled Demo controls so nobody mistakes it for a product setting. Keep that word in any new control built on these settings.
- Labels are sentence case and literal: Latency, Instant, Realistic, Slow, Simulate errors, Replay loading states.
- Confirm a replay with a toast in the past tense: Loading states replayed.
Accessibility#
- In the user menu the latency choices are menu radio items and Simulate errors is a menu checkbox item, so their checked state is announced.
- Slow latency stretches every
aria-busyperiod. It's the setting to check that skeletons and focus behave while data is pending. - Replaying loading doesn't move focus. Content under the pointer or focus is swapped for a skeleton and back.
| Keys | Action |
|---|---|
| Enter | Opens the user menu from its trigger, or chooses the focused item. |
| ArrowRight | Opens the Demo controls submenu from its row. |
| ArrowLeft | Closes the submenu and returns to the user menu. |
| Esc | Closes the menu. |
API reference#
useDemoSettings
useDemoSettings(): DemoSettings, from @oration/canon/lib/demo-data. Takes no arguments and re-renders when settings change.
No props of its own.
Returns
What useDemoSettings returns: DemoSettings.
| Prop | Type | Default | Description |
|---|---|---|---|
latency | "instant" | "realistic" | "slow" | "realistic" | Scales every simulated wait by 0, 1 or 3. |
failures | boolean | false | When true, the first attempt of each query fails. |
setDemoSettings
setDemoSettings(patch: Partial<DemoSettings>): void.
| Prop | Type | Default | Description |
|---|---|---|---|
patchRequired | Partial<DemoSettings> | No default | Merged into the current settings, saved to localStorage and broadcast. |
replayLoading
replayLoading(): void. Forgets every resolved request so loading shows again.
No props of its own.
Also exported
Lower-level reads, mostly for useMockQuery.
| Prop | Type | Default | Description |
|---|---|---|---|
getDemoSettings | () => DemoSettings | No default | The current settings outside React. |
useCacheEpoch | () => number | No default | Re-renders when replayLoading() runs. |
getCacheEpoch | () => number | No default | The current epoch outside React. |
LATENCY_MULTIPLIER | Record<DemoLatency, number> | No default | { instant: 0, realistic: 1, slow: 3 }. |
DemoLatency, DemoSettings | type | No default | "instant" | "realistic" | "slow" and { latency: DemoLatency; failures: boolean }. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Nothing on screen says when latency is Slow or errors are simulated, so a reviewer who forgot the setting can file a slow or failing page as a bug.
Settings don't sync across tabs: a second tab keeps the old values until it reloads.