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:
@@ -12,11 +12,12 @@ import {
|
||||
Settings,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useHostEvents } from "@/api/events";
|
||||
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { Stagger, staggerProps } from "@/components/stagger";
|
||||
import { Wordmark } from "@/components/wordmark";
|
||||
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -72,10 +73,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<motion.nav
|
||||
animate="enter"
|
||||
initial="from"
|
||||
transition={{
|
||||
delayChildren: stagger(0.1),
|
||||
}}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
{...staggerProps()}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{NAV.map(({ to, icon: Icon, label }) => (
|
||||
@@ -148,13 +146,7 @@ function PluginNavSection() {
|
||||
// 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
|
||||
// the animation earns its keep.
|
||||
<motion.div
|
||||
animate="enter"
|
||||
initial="from"
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="mt-6 flex flex-col gap-1"
|
||||
>
|
||||
<Stagger root className="mt-6 flex flex-col gap-1">
|
||||
<motion.p
|
||||
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
|
||||
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>
|
||||
</Stagger>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Checkbox } from "@unom/ui/form/checkbox";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import type { HookEntry } from "@/api/gen/model/hookEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputNumber } from "@/components/ui/input-number";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** 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">
|
||||
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
|
||||
<select
|
||||
id="hook-on"
|
||||
value={draft.on}
|
||||
onChange={(e) => set({ on: e.target.value })}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{EVENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* The event kinds are IDENTIFIERS, not prose — deliberately not routed through
|
||||
i18n, and shown in the host's own `domain.event` spelling so what you pick here
|
||||
reads the same as what you'd type into the SSE `?kinds=` filter. */}
|
||||
<Select value={draft.on} onValueChange={(on) => set({ on })}>
|
||||
<SelectTrigger id="hook-on">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EVENT_KINDS.map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{k}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.automation_field_on_help()}
|
||||
</p>
|
||||
@@ -222,14 +233,11 @@ export const HookForm: FC<{
|
||||
<Label htmlFor="hook-debounce">
|
||||
{m.automation_field_debounce()}
|
||||
</Label>
|
||||
<Input
|
||||
<InputNumber
|
||||
id="hook-debounce"
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.debounce_ms ?? 0}
|
||||
onChange={(e) =>
|
||||
set({ debounce_ms: Number(e.target.value) || 0 })
|
||||
}
|
||||
onChange={(debounce_ms) => set({ debounce_ms })}
|
||||
/>
|
||||
</div>
|
||||
{kind === "run" && (
|
||||
@@ -237,15 +245,14 @@ export const HookForm: FC<{
|
||||
<Label htmlFor="hook-timeout">
|
||||
{m.automation_field_timeout()}
|
||||
</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"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={draft.timeout_s ?? 30}
|
||||
onChange={(e) =>
|
||||
set({ timeout_s: Number(e.target.value) || 30 })
|
||||
}
|
||||
onChange={(timeout_s) => set({ timeout_s })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useBlocker } from "@tanstack/react-router";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
@@ -38,7 +37,9 @@ import type {
|
||||
Topology,
|
||||
} from "@/api/gen/model";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Stagger } from "@/components/stagger";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -243,7 +244,11 @@ const PRESET_ORDER = [
|
||||
"custom",
|
||||
] 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;
|
||||
setDraft: (p: DisplayPolicy) => void;
|
||||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||||
@@ -431,7 +436,11 @@ const DisplayForm: FC<{
|
||||
<Label className="mb-1 block text-base font-semibold">
|
||||
{m.display_preset()}
|
||||
</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) => {
|
||||
const p = presets.find((x) => x.id === id);
|
||||
const fields = id === "custom" ? undefined : p?.fields;
|
||||
@@ -506,7 +515,7 @@ const DisplayForm: FC<{
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stagger>
|
||||
</div>
|
||||
|
||||
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but
|
||||
@@ -527,7 +536,7 @@ const DisplayForm: FC<{
|
||||
</Button>
|
||||
</div>
|
||||
{customPresets.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Stagger className="grid gap-3 sm:grid-cols-2">
|
||||
{customPresets.map((p) => (
|
||||
<CustomPresetCard
|
||||
key={p.id}
|
||||
@@ -540,7 +549,7 @@ const DisplayForm: FC<{
|
||||
onDelete={() => removePreset(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Stagger>
|
||||
)}
|
||||
{presetError && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { type FC, type ReactNode, useEffect, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "@/api/gen/session/session";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
import type { GpuState } from "@/api/gen/model";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "@/api/gen/update/update";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CustomInput } from "@/api/gen/model/customInput";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
@@ -313,12 +314,11 @@ export const GameForm: FC<{
|
||||
"Heroic" or "Lutris" tile without installing that source's plugin. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
<Checkbox
|
||||
id="lib-isLauncher"
|
||||
type="checkbox"
|
||||
checked={form.isLauncher}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, isLauncher: e.target.checked }))
|
||||
onCheckedChange={(next) =>
|
||||
setForm((f) => ({ ...f, isLauncher: next === true }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import { type FC, useEffect, useMemo } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
} from "@/api/gen/library/library";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Stagger } from "@/components/stagger";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
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">
|
||||
{m.library_launchers_title()}
|
||||
</p>
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{launchers.map(card)}
|
||||
</motion.div>
|
||||
</Stagger>
|
||||
</div>
|
||||
)}
|
||||
{all.length === 0 ? (
|
||||
@@ -136,13 +132,9 @@ export const LibraryGrid: FC<{
|
||||
) : (
|
||||
games.length > 0 && (
|
||||
<div className="@container">
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{games.map(card)}
|
||||
</motion.div>
|
||||
</Stagger>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { toast } from "@unom/ui/toast";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,7 +11,15 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
@@ -152,7 +161,8 @@ const renderable = (node: JsonSchemaNode): boolean => {
|
||||
if (n.enum) return true;
|
||||
if (n.type === "boolean" || n.type === "string") 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) {
|
||||
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
|
||||
// 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.
|
||||
const canRender = props !== undefined && Object.values(props).every(renderable);
|
||||
const canRender =
|
||||
props !== undefined && Object.values(props).every(renderable);
|
||||
|
||||
if (!canRender) {
|
||||
return (
|
||||
@@ -181,8 +192,8 @@ const ConfigForm: FC<{
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.library_source_settings_json_hint()}
|
||||
</p>
|
||||
<textarea
|
||||
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs"
|
||||
<Textarea
|
||||
className="h-64 font-mono text-xs"
|
||||
value={raw}
|
||||
onChange={(e) => onRaw(e.target.value)}
|
||||
spellCheck={false}
|
||||
@@ -255,18 +266,21 @@ const Field: FC<{
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<select
|
||||
id={id}
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
<Select
|
||||
value={String(value ?? node.default ?? node.enum[0])}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
{node.enum.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id={id} size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{node.enum.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground">{node.description}</p>
|
||||
)}
|
||||
@@ -279,11 +293,10 @@ const Field: FC<{
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
<Checkbox
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
onCheckedChange={(next) => onChange(next === true)}
|
||||
/>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
@@ -300,9 +313,9 @@ const Field: FC<{
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<textarea
|
||||
<Textarea
|
||||
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")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { UserPlus, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { PendingDevice } from "@/api/gen/model";
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
useListPendingDevices,
|
||||
} from "@/api/gen/native/native";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Checkbox } from "@unom/ui/form/checkbox";
|
||||
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import type { StoreEntry } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const meta = {
|
||||
title: "UI/Inputs",
|
||||
@@ -28,3 +37,65 @@ export const Form: Story = {
|
||||
</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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
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";
|
||||
@@ -297,3 +300,96 @@ export const nativeClients: NativeClient[] = [
|
||||
];
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user