Files
punktfunk/web/src/stories/lib/fixtures.ts
T
enricobuehler a85e845255 fix(web): the console stops falling out of its own design system
A pre-release sweep of the management console for two things that no type check
and no diff can catch: primitives that were never @unom/ui's, and animation
that a nested motion parent quietly cancelled.

THE PRESET TILES ALL LANDED ON THE SAME FRAME. @unom/ui's <Section> sets
`delayChildren: stagger(...)`, so a page whose cards are direct descendants of
it staggers for free — which is why every page but one looked right. An
<AnimatedCard> is ALSO a motion element and sets no `delayChildren`, and the
Virtual displays preset tiles are cards nested INSIDE that page's config card,
so that card became their timing group. Measured in a headless browser: the
opacity spread between the first and last tile was 0.00 across the whole
animation (six tiles in lockstep), and is 0.98 now — a ~100 ms cascade matching
the rest of the console. The four hand-rolled copies of the stagger container
collapse into one `<Stagger>` that carries the explanation.

FIVE FILES IMPORTED THE WRONG BUTTON. `@unom/ui/button` exports both a plain
`Button` and the `AnimatedButton` that this console's wrapper re-exports under
the same name — so `import { Button } from "@unom/ui/button"` compiles, renders,
and silently opts out of the mount animation and the hover/tap response.
Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting
next to live ones.

THREE PRIMITIVES HAD NO WRAPPER, SO NOBODY REACHED FOR THEM. @unom/ui ships
form/select, form/textarea and form/checkbox; components/ui did not, and the
gap was filled with browser-chrome `<select>`, `<textarea>` and
`<input type="checkbox">` in the add-hook modal and both library forms. Select
needs the same token correction Tabs needed — upstream `text-secondary` is a
text colour, but here `--secondary` is a SURFACE, so the trigger's chevron and
placeholder rendered at near-zero contrast on the card behind them.

The hook timeout also stops accepting a value the host rejects: `min`/`max` on
a controlled `<input type="number">` are decoration (no form validation ever
runs), so 900 went into a field capped at 600 and failed later, at run time.
@unom/ui's InputNumber clamps on blur and lets the field be empty while you
retype instead of snapping to the fallback.

Storybook gains the page that had no story at all — the console's largest
config surface, and the reason this shipped unseen. Its <Card> wrapper is load
bearing: it reproduces the motion nesting that IS the bug.
2026-08-07 22:34:24 +02:00

396 lines
9.7 KiB
TypeScript

// Mock API payloads for the page stories — typed against the generated models so
// they stay honest if the OpenAPI schema changes.
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { Capture } from "@/api/gen/model/capture";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
import type { CustomPreset } from "@/api/gen/model/customPreset";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import type { EffectivePolicy } from "@/api/gen/model/effectivePolicy";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import type { NativeClient } from "@/api/gen/model/nativeClient";
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
import type { PairedClient } from "@/api/gen/model/pairedClient";
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import type { StatsSample } from "@/api/gen/model/statsSample";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
export const hostInfo: HostInfo = {
abi_version: 2,
app_version: "7.1.450.0",
codecs: ["h264", "hevc", "av1"],
gamestream: true,
gfe_version: "3.23.0.74",
hostname: "ENRICOS-DESKTOP",
local_ip: "192.168.1.173",
os: "linux/fedora/bazzite",
os_name: "Bazzite 42 (Kinoite)",
ports: {
audio: 48000,
control: 47999,
http: 47989,
https: 47984,
mgmt: 47990,
rtsp: 48010,
video: 47998,
},
uniqueid: "0f8a1c3e9b7d4a62",
version: "0.2.0",
};
export const compositors: AvailableCompositor[] = [
{ id: "kwin", label: "KWin (Plasma)", available: true, default: true },
{ id: "gamescope", label: "gamescope", available: true, default: false },
{ id: "mutter", label: "Mutter (GNOME)", available: false, default: false },
{ id: "wlroots", label: "Sway / wlroots", available: false, default: false },
];
export const statusActive: RuntimeStatus = {
video_streaming: true,
audio_streaming: true,
paired_clients: 3,
native_paired_clients: 2,
pin_pending: false,
active_sessions: 1,
session: { width: 5120, height: 1440, fps: 240 },
stream: {
codec: "hevc",
width: 5120,
height: 1440,
fps: 240,
bitrate_kbps: 150_000,
min_fec: 5,
packet_size: 1392,
},
games: [
{
session_id: 1,
client: "Living room TV",
app_id: "steam:1145360",
title: "Hades",
store: "steam",
plane: "native",
state: "running",
},
],
};
export const statusIdle: RuntimeStatus = {
video_streaming: false,
audio_streaming: false,
paired_clients: 1,
native_paired_clients: 0,
pin_pending: true,
active_sessions: 0,
session: null,
stream: null,
games: [],
};
/**
* Idle, but a game its client walked away from is still running — on a countdown to being closed.
* The state the running-game card exists for.
*/
export const statusGrace: RuntimeStatus = {
...statusIdle,
games: [
{
client: "Living room TV",
app_id: "steam:1145360",
title: "Hades",
store: "steam",
plane: "native",
state: "grace",
grace_remaining_s: 252,
},
],
};
export const pairedClients: PairedClient[] = [
{
fingerprint:
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
subject: "enricos-macbook",
not_before_unix: 1_718_000_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
subject: "living-room-tv",
not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint:
"0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff",
subject: null,
},
];
const noArt = { header: null, hero: null, logo: null, portrait: null };
export const library: GameEntry[] = [
{
id: "steam:1245620",
store: "steam",
title: "Elden Ring",
art: noArt,
launch: null,
},
{
id: "steam:1086940",
store: "steam",
title: "Baldur's Gate 3",
art: noArt,
launch: null,
},
{
id: "steam:413150",
store: "steam",
title: "Stardew Valley",
art: noArt,
launch: null,
},
{
id: "custom:retroarch",
store: "custom",
title: "RetroArch",
art: noArt,
launch: null,
},
// An emulated title with the metadata fields filled — exercises the platform
// badge (non-PC) and the year in the caption.
{
id: "custom:sotc",
store: "custom",
title: "Shadow of the Colossus",
art: noArt,
launch: null,
platform: "PS2",
developer: "Team Ico",
publisher: "Sony Computer Entertainment",
release_year: 2005,
genres: ["Adventure"],
tags: ["favorite"],
region: "PAL",
players: 1,
},
];
// --- Performance (stats) page ------------------------------------------------
export const statsStatusIdle: StatsStatus = {
armed: false,
kind: "native",
sample_count: 0,
started_unix_ms: 0,
elapsed_ms: 0,
};
// A native-path pipeline: capture → submit → encode → send. Deterministic (no
// Math.random) so the screenshot is byte-stable across CI runs; a gentle sine
// gives the charts a realistic shape without a live capture.
const STAGE_BASE_US: Record<string, number> = {
capture: 320,
submit: 90,
encode: 760,
send: 140,
};
const STAGE_ORDER = ["capture", "submit", "encode", "send"];
function buildSamples(n: number): StatsSample[] {
const out: StatsSample[] = [];
for (let i = 0; i < n; i++) {
const wobble = Math.sin(i / 4);
out.push({
t_ms: i * 1000,
session_id: 1,
fps: 240,
repeat_fps: i % 3 === 0 ? 2 : 1,
mbps: 920 + wobble * 55,
bitrate_kbps: 150_000,
frames_dropped: i % 17 === 0 ? 1 : 0,
packets_dropped: i % 9 === 0 ? 2 : 0,
send_dropped: 0,
fec_recovered: i % 5 === 0 ? 3 : 1,
stages: STAGE_ORDER.map((name) => {
const base = STAGE_BASE_US[name] ?? 100;
const p50 = Math.round(base + wobble * base * 0.15);
return { name, p50_us: p50, p99_us: Math.round(p50 * 1.8) };
}),
});
}
return out;
}
export const captureMetas: CaptureMeta[] = [
{
id: "cap-20260628-2041",
client: "enricos-macbook",
kind: "native",
codec: "hevc",
width: 5120,
height: 1440,
fps: 240,
duration_ms: 92_000,
sample_count: 92,
started_unix_ms: 1_782_415_260_000,
encoder_backend: "nvenc",
gpu: "NVIDIA GeForce RTX 4090",
},
{
id: "cap-20260628-1903",
client: "living-room-tv",
kind: "gamestream",
codec: "av1",
width: 3840,
height: 2160,
fps: 120,
duration_ms: 240_000,
sample_count: 240,
started_unix_ms: 1_782_409_380_000,
},
];
export const captureDetail: Capture = {
meta: captureMetas[0] as CaptureMeta,
samples: buildSamples(60),
};
// --- Pairing page ------------------------------------------------------------
export const nativePairArmed: NativePairStatus = {
enabled: true,
armed: true,
pin: "4827",
expires_in_secs: 98,
paired_clients: 2,
};
export const pendingDevices: PendingDevice[] = [
{
id: 1,
name: "studio-deck",
fingerprint:
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
age_secs: 8,
},
{
id: 2,
name: "Mac Mini",
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
age_secs: 30,
},
];
export const nativeClients: NativeClient[] = [
{
name: "enricos-macbook",
fingerprint:
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
},
{
name: "living-room-tv",
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
},
];
export const pairingIdle: PairingStatus = { pin_pending: false };
/** The six axes a preset expands to — the baseline the built-ins vary from. */
const policyFields = (
over: Partial<EffectivePolicy> = {},
): EffectivePolicy => ({
identity: "per-client",
keep_alive: { mode: "duration", seconds: 300 },
layout: { mode: "auto-row", positions: {} },
max_displays: 4,
mode_conflict: "separate",
topology: "auto",
...over,
});
/**
* The built-in presets as `GET /display/settings` returns them. Summaries are the host's own prose
* (it composes them from the fields), so they are literal strings here rather than i18n messages.
*/
export const displayPresets: {
id: string;
summary: string;
fields: EffectivePolicy;
}[] = [
{
id: "default",
summary:
"A virtual display per client, released 5 minutes after it disconnects.",
fields: policyFields(),
},
{
id: "shared-desktop",
summary:
"Every client sees the same desktop — no extra displays are created.",
fields: policyFields({
identity: "shared",
topology: "primary",
mode_conflict: "join",
}),
},
{
id: "hotdesk",
summary:
"One display at a time; the physical heads go dark while you stream.",
fields: policyFields({
topology: "exclusive",
max_displays: 1,
keep_alive: { mode: "off" },
}),
},
{
id: "workstation",
summary: "Adds a display beside the monitors already on the desk.",
fields: policyFields({ topology: "extend" }),
},
{
id: "gaming-rig",
summary:
"Pins the display so it survives every disconnect — free it with Release.",
fields: policyFields({
topology: "exclusive",
keep_alive: { mode: "forever" },
}),
},
];
/** Two operator-saved bundles, so the custom-preset rail has something in it. */
export const displayCustomPresets: CustomPreset[] = [
{
id: "cp-couch",
name: "Couch (TV only)",
fields: policyFields({
topology: "exclusive",
max_displays: 1,
keep_alive: { mode: "forever" },
}),
game_session: "dedicated",
},
{
id: "cp-office",
name: "Office desk",
fields: policyFields({ topology: "extend", identity: "shared" }),
},
];
/** What the host reports as in force — the `default` preset's expansion. */
export const displayEffective: EffectivePolicy = policyFields();
/** The stored policy: a plain built-in pick, which is what most hosts sit on. */
export const displayPolicy: DisplayPolicy = {
preset: "default",
game_session: "auto",
version: 1,
};