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.
This commit is contained in:
2026-08-07 22:34:24 +02:00
parent a8a4b11f5c
commit a85e845255
19 changed files with 497 additions and 83 deletions
+5 -13
View File
@@ -12,11 +12,12 @@ import {
Settings, Settings,
Workflow, Workflow,
} from "lucide-react"; } from "lucide-react";
import { motion, stagger } from "motion/react"; import { motion } from "motion/react";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { useHostEvents } from "@/api/events"; import { useHostEvents } from "@/api/events";
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins"; import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { Stagger, staggerProps } from "@/components/stagger";
import { Wordmark } from "@/components/wordmark"; import { Wordmark } from "@/components/wordmark";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n"; import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -72,10 +73,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<motion.nav <motion.nav
animate="enter" animate="enter"
initial="from" initial="from"
transition={{ {...staggerProps()}
delayChildren: stagger(0.1),
}}
variants={{ enter: {}, from: {} }}
className="flex flex-col gap-1" className="flex flex-col gap-1"
> >
{NAV.map(({ to, icon: Icon, label }) => ( {NAV.map(({ to, icon: Icon, label }) => (
@@ -148,13 +146,7 @@ function PluginNavSection() {
// stagger nor the variants and plugin entries simply appeared. They arrive asynchronously // stagger nor the variants and plugin entries simply appeared. They arrive asynchronously
// (and a fresh install adds one to a nav that is already on screen), which is exactly when // (and a fresh install adds one to a nav that is already on screen), which is exactly when
// the animation earns its keep. // the animation earns its keep.
<motion.div <Stagger root className="mt-6 flex flex-col gap-1">
animate="enter"
initial="from"
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="mt-6 flex flex-col gap-1"
>
<motion.p <motion.p
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }} variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70" className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
@@ -193,7 +185,7 @@ function PluginNavSection() {
</motion.div> </motion.div>
); );
})} })}
</motion.div> </Stagger>
); );
} }
+55
View File
@@ -0,0 +1,55 @@
import { type HTMLMotionProps, motion, stagger } from "motion/react";
import type { FC } from "react";
/** The house cadence, in seconds between siblings. */
export const STAGGER_GAP = 0.1;
/**
* The stagger-container contract as plain props — for the places that need a specific element
* (`motion.nav`) rather than the `<Stagger>` div below.
*
* The empty `enter`/`from` variants are not a placeholder: a motion element only PROPAGATES a
* variant it names, so a container that defines none stops the cascade dead and its children never
* animate at all.
*/
export const staggerProps = (gap: number = STAGGER_GAP) => ({
variants: { enter: {}, from: {} },
transition: { delayChildren: stagger(gap) },
});
/**
* The console's on-mount cadence: siblings arrive one after another, not all on the same frame.
*
* WHY THIS EXISTS AS A COMPONENT. Every animated primitive here (`Card`, `Button`, `Checkbox` — all
* `@unom/ui`) is a motion element whose `from`/`enter` variants are INHERITED from the nearest
* motion ancestor, and it is that ancestor which owns the timing. `@unom/ui`'s `<Section>` sets
* `delayChildren: stagger(...)`, which is why a page whose cards are direct descendants of the
* Section staggers for free.
*
* The trap is that an `<AnimatedCard>` is ALSO a motion element, and it sets no `delayChildren` — so
* a grid of cards nested inside a card becomes its own timing group and every tile lands at once.
* Nothing in the types catches that; it only shows up in a browser, beside a page that does it
* right. Wrapping the grid re-establishes the cadence.
*
* `root` is for a container with no animating motion ancestor to inherit from (the sidebar nav): it
* drives `from → enter` itself. Inside a `<Section>` or a card, leave it off — supplying
* `initial`/`animate` there would run the group on its own clock instead of the page's.
*/
export const Stagger: FC<
HTMLMotionProps<"div"> & {
/** Seconds between siblings. */
gap?: number;
/** Drive the enter animation instead of inheriting it — see above. */
root?: boolean;
}
> = ({ gap, root = false, transition, ...props }) => {
const base = staggerProps(gap);
return (
<motion.div
{...(root ? { initial: "from", animate: "enter" } : {})}
variants={base.variants}
transition={{ ...base.transition, ...transition }}
{...props}
/>
);
};
+7
View File
@@ -0,0 +1,7 @@
// The console's Checkbox IS @unom/ui's radix checkbox — shadcn-compatible tokens (border-input,
// data-checked:bg-primary), the tick drawn as an animated path, plus the shared toggle sound.
//
// It is `checked`/`onCheckedChange` (radix), NOT `checked`/`onChange` — a raw `<input
// type="checkbox">` swapped in here silently loses the brand entirely, which is exactly how two of
// them survived in the library forms.
export { Checkbox } from "@unom/ui/form/checkbox";
+16
View File
@@ -0,0 +1,16 @@
// The console's numeric field IS @unom/ui's form input-number — the Input with a number's rules
// layered on: it keeps a local draft while you type (so the field can be EMPTY on the way to a new
// value), commits only a finite in-range number, and clamps to `min`/`max` on blur.
//
// That is not cosmetic. The hand-rolled shape it replaces —
//
// <Input type="number" min={1} max={600}
// onChange={(e) => set({ timeout_s: Number(e.target.value) || 30 })} />
//
// — has two defects the shared component doesn't: clearing the field snaps it to the fallback
// instead of letting you retype, and `min`/`max` are decoration (an `<input>`'s range is only
// enforced by form validation, which a controlled field like this never runs) — so 900 went
// straight into a timeout capped at 600.
//
// `onChange` hands you a `number`, not an event.
export { InputNumber } from "@unom/ui/form/input-number";
+57
View File
@@ -0,0 +1,57 @@
// The console's Select IS @unom/ui's radix select, with the same two corrections the Tabs wrapper
// needs — @unom/ui's palette names don't all mean the same thing in this app's token set:
//
// • `text-secondary`, which the trigger uses for BOTH the placeholder and the chevron, is a
// *text* colour upstream. Here `--secondary` is a SURFACE (#241c3d dark / #ece6fb light), so the
// chevron rendered at near-zero contrast against the card it sits on — a select that looked like
// a plain box with no affordance at all. `text-muted-foreground` is this app's "quiet text".
// • `border-main` is the foreground colour — a near-white 1px border in dark, which would make a
// select shout next to the `border-input` used by every Input beside it.
//
// The trigger also defaults to `w-full` (upstream is `w-fit`) and to the Input's `rounded-md`: in
// this console a select is a form field in a stacked column, never an inline chip.
//
// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's
// tokens once, rather than restyling it at every call site.
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger as SelectTriggerBase,
SelectValue,
} from "@unom/ui/form/select";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const SelectTrigger = ({
className,
...props
}: ComponentProps<typeof SelectTriggerBase>) => (
<SelectTriggerBase
className={cn(
"w-full rounded-md border-input data-placeholder:text-muted-foreground",
"[&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
/>
);
SelectTrigger.displayName = "SelectTrigger";
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+4
View File
@@ -0,0 +1,4 @@
// The console's Textarea IS @unom/ui's form textarea — the Input's twin (same `border-input` /
// `placeholder:text-muted-foreground` / focus ring tokens, material gloss via UnomProviders), so a
// multi-line field sits beside a single-line one without either looking borrowed.
export { Textarea } from "@unom/ui/form/textarea";
+30 -23
View File
@@ -1,7 +1,7 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useState } from "react";
import type { HookEntry } from "@/api/gen/model/hookEntry"; import type { HookEntry } from "@/api/gen/model/hookEntry";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,7 +11,15 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
/** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts. /** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts.
@@ -104,18 +112,21 @@ export const HookForm: FC<{
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="hook-on">{m.automation_field_on()}</Label> <Label htmlFor="hook-on">{m.automation_field_on()}</Label>
<select {/* The event kinds are IDENTIFIERS, not prose — deliberately not routed through
id="hook-on" i18n, and shown in the host's own `domain.event` spelling so what you pick here
value={draft.on} reads the same as what you'd type into the SSE `?kinds=` filter. */}
onChange={(e) => set({ on: e.target.value })} <Select value={draft.on} onValueChange={(on) => set({ on })}>
className="w-full rounded-md border bg-background px-3 py-2 text-sm" <SelectTrigger id="hook-on">
> <SelectValue />
{EVENT_KINDS.map((k) => ( </SelectTrigger>
<option key={k} value={k}> <SelectContent>
{k} {EVENT_KINDS.map((k) => (
</option> <SelectItem key={k} value={k}>
))} {k}
</select> </SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{m.automation_field_on_help()} {m.automation_field_on_help()}
</p> </p>
@@ -222,14 +233,11 @@ export const HookForm: FC<{
<Label htmlFor="hook-debounce"> <Label htmlFor="hook-debounce">
{m.automation_field_debounce()} {m.automation_field_debounce()}
</Label> </Label>
<Input <InputNumber
id="hook-debounce" id="hook-debounce"
type="number"
min={0} min={0}
value={draft.debounce_ms ?? 0} value={draft.debounce_ms ?? 0}
onChange={(e) => onChange={(debounce_ms) => set({ debounce_ms })}
set({ debounce_ms: Number(e.target.value) || 0 })
}
/> />
</div> </div>
{kind === "run" && ( {kind === "run" && (
@@ -237,15 +245,14 @@ export const HookForm: FC<{
<Label htmlFor="hook-timeout"> <Label htmlFor="hook-timeout">
{m.automation_field_timeout()} {m.automation_field_timeout()}
</Label> </Label>
<Input {/* 600 is the host's own ceiling — the old field let 900 through, and the
hook then failed at run time rather than at the point of typing it. */}
<InputNumber
id="hook-timeout" id="hook-timeout"
type="number"
min={1} min={1}
max={600} max={600}
value={draft.timeout_s ?? 30} value={draft.timeout_s ?? 30}
onChange={(e) => onChange={(timeout_s) => set({ timeout_s })}
set({ timeout_s: Number(e.target.value) || 30 })
}
/> />
</div> </div>
)} )}
+15 -6
View File
@@ -1,6 +1,5 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useBlocker } from "@tanstack/react-router"; import { useBlocker } from "@tanstack/react-router";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { import {
@@ -38,7 +37,9 @@ import type {
Topology, Topology,
} from "@/api/gen/model"; } from "@/api/gen/model";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -243,7 +244,11 @@ const PRESET_ORDER = [
"custom", "custom",
] as const; ] as const;
const DisplayForm: FC<{ /**
* The policy form itself — pure, so Storybook can render it (with the `<Card>` wrapper the page puts
* around it, which is also its motion parent) without a host answering `/display/settings`.
*/
export const DisplayForm: FC<{
draft: DisplayPolicy; draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void; setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[]; presets: { id: string; summary: string; fields: EffectivePolicy }[];
@@ -431,7 +436,11 @@ const DisplayForm: FC<{
<Label className="mb-1 block text-base font-semibold"> <Label className="mb-1 block text-base font-semibold">
{m.display_preset()} {m.display_preset()}
</Label> </Label>
<div className="grid gap-3 sm:grid-cols-2"> {/* The preset tiles are cards nested INSIDE this page's config card, so their motion
parent is that card — which sets no `delayChildren` and therefore landed all six
on the same frame, unlike every other card grid in the console. `Stagger` gives
the group its own cadence back (see components/stagger.tsx). */}
<Stagger className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => { {PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id); const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields; const fields = id === "custom" ? undefined : p?.fields;
@@ -506,7 +515,7 @@ const DisplayForm: FC<{
</Card> </Card>
); );
})} })}
</div> </Stagger>
</div> </div>
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but {/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but
@@ -527,7 +536,7 @@ const DisplayForm: FC<{
</Button> </Button>
</div> </div>
{customPresets.length > 0 && ( {customPresets.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2"> <Stagger className="grid gap-3 sm:grid-cols-2">
{customPresets.map((p) => ( {customPresets.map((p) => (
<CustomPresetCard <CustomPresetCard
key={p.id} key={p.id}
@@ -540,7 +549,7 @@ const DisplayForm: FC<{
onDelete={() => removePreset(p)} onDelete={() => removePreset(p)}
/> />
))} ))}
</div> </Stagger>
)} )}
{presetError && ( {presetError && (
<p className="text-sm text-amber-600 dark:text-amber-500"> <p className="text-sm text-amber-600 dark:text-amber-500">
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react"; import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher"; import { ApiError } from "@/api/fetcher";
@@ -11,6 +10,7 @@ import {
} from "@/api/gen/session/session"; } from "@/api/gen/session/session";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
+1 -1
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import type { FC } from "react"; import type { FC } from "react";
import { import {
@@ -10,6 +9,7 @@ import {
import type { GpuState } from "@/api/gen/model"; import type { GpuState } from "@/api/gen/model";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors"; import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query"; import type { Loadable } from "@/lib/query";
+1 -1
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useState } from "react"; import { type FC, type ReactNode, useState } from "react";
import { ApiError } from "@/api/fetcher"; import { ApiError } from "@/api/fetcher";
@@ -11,6 +10,7 @@ import {
} from "@/api/gen/update/update"; } from "@/api/gen/update/update";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Dialog, Dialog,
+4 -4
View File
@@ -10,6 +10,7 @@ import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry"; import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors"; import { apiErrorMessage } from "@/lib/errors";
@@ -313,12 +314,11 @@ export const GameForm: FC<{
"Heroic" or "Lutris" tile without installing that source's plugin. */} "Heroic" or "Lutris" tile without installing that source's plugin. */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <Checkbox
id="lib-isLauncher" id="lib-isLauncher"
type="checkbox"
checked={form.isLauncher} checked={form.isLauncher}
onChange={(e) => onCheckedChange={(next) =>
setForm((f) => ({ ...f, isLauncher: e.target.checked })) setForm((f) => ({ ...f, isLauncher: next === true }))
} }
/> />
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label> <Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
+5 -13
View File
@@ -1,6 +1,5 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import { motion, stagger } from "motion/react";
import { type FC, useEffect, useMemo } from "react"; import { type FC, useEffect, useMemo } from "react";
import { import {
getGetLibraryQueryKey, getGetLibraryQueryKey,
@@ -9,6 +8,7 @@ import {
} from "@/api/gen/library/library"; } from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry"; import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors"; import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query"; import type { Loadable } from "@/lib/query";
@@ -108,13 +108,9 @@ export const LibraryGrid: FC<{
<p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"> <p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{m.library_launchers_title()} {m.library_launchers_title()}
</p> </p>
<motion.div <Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{launchers.map(card)} {launchers.map(card)}
</motion.div> </Stagger>
</div> </div>
)} )}
{all.length === 0 ? ( {all.length === 0 ? (
@@ -136,13 +132,9 @@ export const LibraryGrid: FC<{
) : ( ) : (
games.length > 0 && ( games.length > 0 && (
<div className="@container"> <div className="@container">
<motion.div <Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{games.map(card)} {games.map(card)}
</motion.div> </Stagger>
</div> </div>
) )
)} )}
+32 -19
View File
@@ -2,6 +2,7 @@ import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useState } from "react";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo"; import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,7 +11,15 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
/** /**
@@ -152,7 +161,8 @@ const renderable = (node: JsonSchemaNode): boolean => {
if (n.enum) return true; if (n.enum) return true;
if (n.type === "boolean" || n.type === "string") return true; if (n.type === "boolean" || n.type === "string") return true;
if (n.type === "number" || n.type === "integer") return true; if (n.type === "number" || n.type === "integer") return true;
if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true; if (n.type === "array" && flatten(n.items ?? {}).type === "string")
return true;
if (n.type === "object" && n.properties) { if (n.type === "object" && n.properties) {
return Object.values(n.properties).every(renderable); return Object.values(n.properties).every(renderable);
} }
@@ -173,7 +183,8 @@ const ConfigForm: FC<{
// Fall back to the JSON editor when there is no schema, or any field is a shape the generic // Fall back to the JSON editor when there is no schema, or any field is a shape the generic
// form can't express (a non-enum union, a $ref). Partial rendering would be worse than none: // form can't express (a non-enum union, a $ref). Partial rendering would be worse than none:
// a field silently missing from the form is a setting the operator cannot change. // a field silently missing from the form is a setting the operator cannot change.
const canRender = props !== undefined && Object.values(props).every(renderable); const canRender =
props !== undefined && Object.values(props).every(renderable);
if (!canRender) { if (!canRender) {
return ( return (
@@ -181,8 +192,8 @@ const ConfigForm: FC<{
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{m.library_source_settings_json_hint()} {m.library_source_settings_json_hint()}
</p> </p>
<textarea <Textarea
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs" className="h-64 font-mono text-xs"
value={raw} value={raw}
onChange={(e) => onRaw(e.target.value)} onChange={(e) => onRaw(e.target.value)}
spellCheck={false} spellCheck={false}
@@ -255,18 +266,21 @@ const Field: FC<{
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
<select <Select
id={id}
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={String(value ?? node.default ?? node.enum[0])} value={String(value ?? node.default ?? node.enum[0])}
onChange={(e) => onChange(e.target.value)} onValueChange={onChange}
> >
{node.enum.map((opt) => ( <SelectTrigger id={id} size="sm">
<option key={opt} value={opt}> <SelectValue />
{opt} </SelectTrigger>
</option> <SelectContent>
))} {node.enum.map((opt) => (
</select> <SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
{node.description && ( {node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p> <p className="text-xs text-muted-foreground">{node.description}</p>
)} )}
@@ -279,11 +293,10 @@ const Field: FC<{
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <Checkbox
id={id} id={id}
type="checkbox"
checked={checked} checked={checked}
onChange={(e) => onChange(e.target.checked)} onCheckedChange={(next) => onChange(next === true)}
/> />
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
</div> </div>
@@ -300,9 +313,9 @@ const Field: FC<{
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
<textarea <Textarea
id={id} id={id}
className="h-24 w-full rounded-md border bg-background p-2 font-mono text-xs" className="h-24 font-mono text-xs"
value={list.join("\n")} value={list.join("\n")}
onChange={(e) => onChange={(e) =>
onChange( onChange(
+1 -1
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { UserPlus, X } from "lucide-react"; import { UserPlus, X } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import type { PendingDevice } from "@/api/gen/model"; import type { PendingDevice } from "@/api/gen/model";
@@ -11,6 +10,7 @@ import {
useListPendingDevices, useListPendingDevices,
} from "@/api/gen/native/native"; } from "@/api/gen/native/native";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query"; import type { Loadable } from "@/lib/query";
+1 -1
View File
@@ -1,8 +1,8 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react"; import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useEffect, useState } from "react"; import { type FC, useEffect, useState } from "react";
import type { StoreEntry } from "@/api/store"; import type { StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
+95
View File
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
import { DisplayForm } from "@/sections/Displays/DisplayCard";
import {
displayCustomPresets,
displayEffective,
displayPolicy,
displayPresets,
} from "./lib/fixtures";
/**
* The **Virtual displays** policy form the console's largest configuration surface, and until now
* the only page with no story at all. That gap is why a real regression shipped unseen: the preset
* tiles are cards nested INSIDE the page's config card, so their motion parent is that card rather
* than the page's `<Section>`, and a card sets no `delayChildren` every tile landed on the same
* frame while every other grid in the console staggered. It is invisible in a diff and invisible to
* `tsc`; only a rendered page shows it.
*
* So the `<Card>` wrapper below is NOT decoration. It reproduces the page's motion nesting, which is
* the thing under test dropping it would make the story pass for the wrong reason.
*/
const Harness = ({ seed }: { seed: DisplayPolicy }) => {
const [draft, setDraft] = useState<DisplayPolicy>(seed);
return (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={displayPresets}
customPresets={displayCustomPresets}
serverEffective={displayEffective}
serverCaptureMonitor={() => null}
apply={setDraft}
applyAxis={(patch) => setDraft({ ...draft, ...patch })}
saveDraft={() => {}}
busy={false}
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
);
};
const meta = {
title: "Pages/Displays",
component: Harness,
args: { seed: displayPolicy },
} satisfies Meta<typeof Harness>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A host sitting on a built-in preset — the tiles, and the operator's saved bundles below them. */
export const Default: Story = {};
/** "Custom" reveals every axis by hand: the long form under the tiles. */
export const CustomFields: Story = {
args: { seed: { ...displayPolicy, preset: "custom" } },
};
/** A fresh host has saved no bundles of its own — the custom rail collapses to just "Save as". */
export const NoCustomPresets: Story = {
args: { seed: displayPolicy },
render: (args) => (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={args.seed}
setDraft={() => {}}
presets={displayPresets}
customPresets={[]}
serverEffective={displayEffective}
serverCaptureMonitor={() => null}
apply={() => {}}
applyAxis={() => {}}
saveDraft={() => {}}
busy={false}
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
),
};
+71
View File
@@ -1,6 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
const meta = { const meta = {
title: "UI/Inputs", title: "UI/Inputs",
@@ -28,3 +37,65 @@ export const Form: Story = {
</div> </div>
), ),
}; };
/**
* The non-text controls, side by side with an Input the comparison that matters, because the
* failure mode these wrappers exist to prevent is a control that looks borrowed from another app.
* A raw `<select>` / `<textarea>` / `<input type="checkbox">` renders in the browser's own chrome
* and ignores the brand tokens entirely; each of the three had survived that way somewhere in the
* console until this story existed to show them together.
*/
export const Controls: Story = {
render: () => (
<div className="max-w-sm space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ctl-event">Event</Label>
<Select defaultValue="session.started">
<SelectTrigger id="ctl-event">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session.started">session.started</SelectItem>
<SelectItem value="session.ended">session.ended</SelectItem>
<SelectItem value="game.running">game.running</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ctl-cmd">Command</Label>
<Input id="ctl-cmd" placeholder="/usr/local/bin/on-stream.sh" />
</div>
<div className="space-y-1.5">
<Label htmlFor="ctl-paths">Extra library folders</Label>
<Textarea
id="ctl-paths"
className="h-24 font-mono text-xs"
placeholder={"/mnt/games\n/mnt/roms"}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox id="ctl-launcher" defaultChecked />
<Label htmlFor="ctl-launcher">This entry opens a launcher</Label>
</div>
</div>
),
};
/** The select with its list open — the surface, the tick, and the hover highlight. */
export const SelectOpen: Story = {
render: () => (
<div className="max-w-sm">
<Select defaultValue="session.started" open>
<SelectTrigger aria-label="Event">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="client.connected">client.connected</SelectItem>
<SelectItem value="session.started">session.started</SelectItem>
<SelectItem value="session.ended">session.ended</SelectItem>
<SelectItem value="game.running">game.running</SelectItem>
</SelectContent>
</Select>
</div>
),
};
+96
View File
@@ -3,6 +3,9 @@
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor"; import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { Capture } from "@/api/gen/model/capture"; import type { Capture } from "@/api/gen/model/capture";
import type { CaptureMeta } from "@/api/gen/model/captureMeta"; 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 { GameEntry } from "@/api/gen/model/gameEntry";
import type { HostInfo } from "@/api/gen/model/hostInfo"; import type { HostInfo } from "@/api/gen/model/hostInfo";
import type { NativeClient } from "@/api/gen/model/nativeClient"; import type { NativeClient } from "@/api/gen/model/nativeClient";
@@ -297,3 +300,96 @@ export const nativeClients: NativeClient[] = [
]; ];
export const pairingIdle: PairingStatus = { pin_pending: false }; 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,
};