diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index a501cb76..1f78a4ef 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -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 }) { {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. - + ); })} - + ); } diff --git a/web/src/components/stagger.tsx b/web/src/components/stagger.tsx new file mode 100644 index 00000000..ac4ac0aa --- /dev/null +++ b/web/src/components/stagger.tsx @@ -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 `` 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 `
` sets + * `delayChildren: stagger(...)`, which is why a page whose cards are direct descendants of the + * Section staggers for free. + * + * The trap is that an `` 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 `
` 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 ( + + ); +}; diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..3df5ef40 --- /dev/null +++ b/web/src/components/ui/checkbox.tsx @@ -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 `` 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"; diff --git a/web/src/components/ui/input-number.tsx b/web/src/components/ui/input-number.tsx new file mode 100644 index 00000000..dc525e37 --- /dev/null +++ b/web/src/components/ui/input-number.tsx @@ -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 — +// +// 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 ``'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"; diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 00000000..9e33889e --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -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) => ( + +); +SelectTrigger.displayName = "SelectTrigger"; + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/web/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx new file mode 100644 index 00000000..40df7fa6 --- /dev/null +++ b/web/src/components/ui/textarea.tsx @@ -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"; diff --git a/web/src/sections/Automation/HookForm.tsx b/web/src/sections/Automation/HookForm.tsx index f7197ae5..51a384e4 100644 --- a/web/src/sections/Automation/HookForm.tsx +++ b/web/src/sections/Automation/HookForm.tsx @@ -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<{
- + {/* 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. */} +

{m.automation_field_on_help()}

@@ -222,14 +233,11 @@ export const HookForm: FC<{ - - set({ debounce_ms: Number(e.target.value) || 0 }) - } + onChange={(debounce_ms) => set({ debounce_ms })} />
{kind === "run" && ( @@ -237,15 +245,14 @@ export const HookForm: FC<{ - - set({ timeout_s: Number(e.target.value) || 30 }) - } + onChange={(timeout_s) => set({ timeout_s })} /> )} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 5692f0c0..12a3c264 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -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 `` 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<{ -
+ {/* 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). */} + {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<{ ); })} -
+ {/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but @@ -527,7 +536,7 @@ const DisplayForm: FC<{ {customPresets.length > 0 && ( -
+ {customPresets.map((p) => ( removePreset(p)} /> ))} -
+ )} {presetError && (

diff --git a/web/src/sections/Displays/SessionGameCard.tsx b/web/src/sections/Displays/SessionGameCard.tsx index 0ee9755a..92779d15 100644 --- a/web/src/sections/Displays/SessionGameCard.tsx +++ b/web/src/sections/Displays/SessionGameCard.tsx @@ -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"; diff --git a/web/src/sections/Host/GpuCard.tsx b/web/src/sections/Host/GpuCard.tsx index cd02902c..09ae372c 100644 --- a/web/src/sections/Host/GpuCard.tsx +++ b/web/src/sections/Host/GpuCard.tsx @@ -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"; diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx index 710ea30c..2b46fa35 100644 --- a/web/src/sections/Host/UpdateCard.tsx +++ b/web/src/sections/Host/UpdateCard.tsx @@ -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, diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index 79447e50..c3caf164 100644 --- a/web/src/sections/Library/GameForm.tsx +++ b/web/src/sections/Library/GameForm.tsx @@ -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. */}

- - setForm((f) => ({ ...f, isLauncher: e.target.checked })) + onCheckedChange={(next) => + setForm((f) => ({ ...f, isLauncher: next === true })) } /> diff --git a/web/src/sections/Library/LibraryGrid.tsx b/web/src/sections/Library/LibraryGrid.tsx index 690e54fc..7d451182 100644 --- a/web/src/sections/Library/LibraryGrid.tsx +++ b/web/src/sections/Library/LibraryGrid.tsx @@ -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<{

{m.library_launchers_title()}

- + {launchers.map(card)} - +
)} {all.length === 0 ? ( @@ -136,13 +132,9 @@ export const LibraryGrid: FC<{ ) : ( games.length > 0 && (
- + {games.map(card)} - +
) )} diff --git a/web/src/sections/Library/SourceSettings.tsx b/web/src/sections/Library/SourceSettings.tsx index 2ff5dd7a..2a001258 100644 --- a/web/src/sections/Library/SourceSettings.tsx +++ b/web/src/sections/Library/SourceSettings.tsx @@ -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<{

{m.library_source_settings_json_hint()}

-