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,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,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,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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,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,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,
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user