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.
396 lines
9.7 KiB
TypeScript
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,
|
|
};
|