Skip to content

Demo controls

Global latency and failure settings for demos, and a way to replay loading.

Status
Beta
Level
Utility
Category
Utilities
Adoption
Not used yet
import { useDemoSettings } from "@oration/canon/lib/demo-data";
packages/canon/src/lib/demo-data.ts

Demo controls

Latency
Simulate errors

Upcoming payment runs

Loading
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 setDemoSettings and replayLoading.

When not to use

  • Showing one component's error state while building it. Pass fail: true to 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#

  1. 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.
  2. Latency. A radio group: Instant (0x), Realistic (1x, the default) and Slow (3x).
  3. Simulate errors. A checkbox item. Fails the first attempt of every query.
  4. Replay loading states. Calls replayLoading() and confirms with a Loading states replayed toast.

Examples#

In the user menu

Where people find the settings: the user menu, under Demo controls. These are the real settings, so a change here applies to the whole prototype in this browser.

import { Button } from "@oration/canon/components/button";import {  DropdownMenu,  DropdownMenuCheckboxItem,  DropdownMenuContent,  DropdownMenuGroup,  DropdownMenuItem,  DropdownMenuLabel,  DropdownMenuRadioGroup,  DropdownMenuRadioItem,  DropdownMenuSeparator,  DropdownMenuSub,  DropdownMenuSubContent,  DropdownMenuSubTrigger,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import {  type DemoLatency,  replayLoading,  setDemoSettings,  useDemoSettings,} from "@oration/canon/lib/demo-data";import {  ChevronsUpDownIcon,  FlaskConicalIcon,  RotateCcwIcon,  UserIcon,} from "lucide-react";export function UserMenu() {    const settings = useDemoSettings();    return (        <DropdownMenu>            <DropdownMenuTrigger                render={<Button type="button" variant="ghost" />}            >                Maya Okafor                <ChevronsUpDownIcon data-icon="inline-end" aria-hidden="true" />            </DropdownMenuTrigger>            <DropdownMenuContent className="w-56">                <DropdownMenuGroup>                    <DropdownMenuLabel>                        maya.okafor@cedarline.com                    </DropdownMenuLabel>                </DropdownMenuGroup>                <DropdownMenuItem                    onClick={() =>                        toast.add({ title: "Profile settings opened" })                    }                >                    <UserIcon aria-hidden="true" />                    Profile                </DropdownMenuItem>                <DropdownMenuSeparator />                <DropdownMenuSub>                    <DropdownMenuSubTrigger>                        <FlaskConicalIcon aria-hidden="true" />                        Demo controls                    </DropdownMenuSubTrigger>                    <DropdownMenuSubContent className="w-56">                        <DropdownMenuGroup>                            <DropdownMenuLabel>Latency</DropdownMenuLabel>                            <DropdownMenuRadioGroup                                value={settings.latency}                                onValueChange={(value) =>                                    setDemoSettings({                                        latency: value as DemoLatency,                                    })                                }                            >                                <DropdownMenuRadioItem value="instant">                                    Instant                                </DropdownMenuRadioItem>                                <DropdownMenuRadioItem value="realistic">                                    Realistic                                </DropdownMenuRadioItem>                                <DropdownMenuRadioItem value="slow">                                    Slow                                </DropdownMenuRadioItem>                            </DropdownMenuRadioGroup>                        </DropdownMenuGroup>                        <DropdownMenuSeparator />                        <DropdownMenuCheckboxItem                            checked={settings.failures}                            onCheckedChange={(checked) =>                                setDemoSettings({ failures: Boolean(checked) })                            }                        >                            Simulate errors                        </DropdownMenuCheckboxItem>                        <DropdownMenuItem                            onClick={() => {                                replayLoading();                                toast.add({ title: "Loading states replayed" });                            }}                        >                            <RotateCcwIcon aria-hidden="true" />                            Replay loading states                        </DropdownMenuItem>                    </DropdownMenuSubContent>                </DropdownMenuSub>            </DropdownMenuContent>        </DropdownMenu>    );}

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#

States
StateTreatment
RealisticThe default. Each query waits its own delay, 250 to 850ms unless set.
InstantWaits are zero. Queries that aren't failing render data on the first frame.
SlowEvery wait is tripled.
Simulate errorsThe first attempt of each query fails with a timeout; Retry succeeds.
ReplayedEvery 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 storage event listener.

Do and don't#

Do. Review every new data view at Slow and with Simulate errors before calling it done.
Don't. Only ever look at a view on a warm cache, where loading and error never appear.
Do. Read the settings only inside the mock data layer.
Don't. Branch product code on useDemoSettings(), for example hiding a skeleton when latency is Instant.
Do. Replay loading after changing the failure switch, so warm views make a new attempt.
Don't. Flip Simulate errors and conclude the error state is broken because nothing changed.

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-busy period. 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.
Keyboard interactions
KeysAction
EnterOpens the user menu from its trigger, or chooses the focused item.
ArrowRightOpens the Demo controls submenu from its row.
ArrowLeftCloses the submenu and returns to the user menu.
EscCloses 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.

Props of Returns
PropTypeDefaultDescription
latency"instant" | "realistic" | "slow""realistic"Scales every simulated wait by 0, 1 or 3.
failuresbooleanfalseWhen true, the first attempt of each query fails.

setDemoSettings

setDemoSettings(patch: Partial<DemoSettings>): void.

Props of setDemoSettings
PropTypeDefaultDescription
patchRequiredPartial<DemoSettings>No defaultMerged 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.

Props of Also exported
PropTypeDefaultDescription
getDemoSettings() => DemoSettingsNo defaultThe current settings outside React.
useCacheEpoch() => numberNo defaultRe-renders when replayLoading() runs.
getCacheEpoch() => numberNo defaultThe current epoch outside React.
LATENCY_MULTIPLIERRecord<DemoLatency, number>No default{ instant: 0, realistic: 1, slow: 3 }.
DemoLatency, DemoSettingstypeNo 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.