diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx
index 1b270589..bb8398d4 100644
--- a/web/.storybook/preview.tsx
+++ b/web/.storybook/preview.tsx
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defaultMaterialTheme, MaterialProvider } from "@unom/ui/material";
import Section from "@unom/ui/section";
import { useEffect } from "react";
+import { DialogsProvider } from "../src/components/dialogs";
// React Query is present so any query-backed component mounts without a real
// host. Stories should feed mock data rather than fetch — retries are off so a
@@ -51,15 +52,21 @@ export default definePreview({
return (
-
-
-
-
-
-
-
+ {/* The console's confirm/prompt, same as __root mounts. Any section that can
+ delete or discard something calls `useDialogs()`, which THROWS without
+ this — so it belongs beside the other app-level providers rather than in
+ each story's decorators. */}
+
+
+
+
+
+
+
+
+
);
diff --git a/web/messages/de.json b/web/messages/de.json
index 56f2cb49..0af6c264 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -225,6 +225,7 @@
"display_layout_manual": "Manuell",
"display_preset_custom_label": "Eigene Voreinstellungen",
"display_preset_save_as": "Als Voreinstellung speichern…",
+ "display_preset_save_title": "Als Voreinstellung speichern",
"display_preset_name": "Name der Voreinstellung",
"display_preset_edit": "Umbenennen",
"display_preset_update": "Auf aktuelle Einstellungen aktualisieren",
@@ -254,7 +255,8 @@
"pairing_native_cancel": "Abbrechen",
"pairing_native_devices": "Gekoppelte Geräte",
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
- "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
+ "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln?",
+ "pairing_native_unpair_body": "Es muss sich erneut koppeln, um zu verbinden.",
"pairing_protocol": "Protokoll",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
@@ -262,7 +264,8 @@
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
"pairing_pending_approve": "Freigeben",
"pairing_pending_deny": "Ablehnen",
- "pairing_pending_name_prompt": "Gerät benennen:",
+ "pairing_pending_name_prompt": "Gerät benennen",
+ "pairing_pending_name_title": "Dieses Gerät zulassen",
"pairing_pending_age_just_now": "gerade eben",
"pairing_pending_age_secs": "vor {s}s",
"pairing_pending_age_mins": "vor {min} min",
@@ -324,7 +327,8 @@
"library_provider_filter": "Nur diese zeigen",
"library_provider_show_all": "Alle zeigen",
"library_provider_purge": "Einträge dieses Anbieters entfernen",
- "library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen? Das entfernt sie nur aus der Bibliothek.",
+ "library_provider_purge_confirm": "Alle {count} von „{provider}“ synchronisierten Einträge entfernen?",
+ "library_provider_purge_body": "Das entfernt sie nur aus der Bibliothek.",
"library_provider_purged": "Die von „{provider}“ synchronisierten Einträge wurden entfernt.",
"library_provider_purge_failed": "Die Einträge dieses Anbieters konnten nicht entfernt werden.",
"library_save": "Speichern",
@@ -332,13 +336,18 @@
"library_cancel": "Abbrechen",
"library_edit": "Bearbeiten",
"library_delete": "Löschen",
- "library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
+ "library_delete_confirm": "Dieses eigene Spiel löschen?",
+ "library_delete_body": "Das kann nicht rückgängig gemacht werden.",
"settings_title": "Einstellungen",
"settings_language": "Sprache",
"common_loading": "Wird geladen…",
"common_error": "Etwas ist schiefgelaufen.",
"common_retry": "Erneut versuchen",
"common_cancel": "Abbrechen",
+ "common_confirm": "Bestätigen",
+ "common_save": "Speichern",
+ "common_discard": "Verwerfen",
+ "common_remove": "Entfernen",
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
"login_title": "Anmelden",
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
@@ -412,7 +421,8 @@
"stats_view": "Ansehen",
"stats_download": "Herunterladen",
"stats_delete": "Löschen",
- "stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
+ "stats_delete_confirm": "Diese Aufnahme löschen?",
+ "stats_delete_body": "Das kann nicht rückgängig gemacht werden.",
"stats_detail_title": "Aufnahme-Details",
"stats_close": "Schließen",
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
@@ -462,7 +472,8 @@
"store_stopped": "Läuft nicht",
"store_version_unknown": "Version unbekannt",
"store_uninstall": "Deinstallieren",
- "store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
+ "store_uninstall_confirm": "{title} deinstallieren?",
+ "store_uninstall_body": "Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
"store_sources_title": "Katalogquellen",
@@ -477,7 +488,8 @@
"store_source_fetched": "zuletzt geladen {when}",
"store_source_never": "nie",
"store_source_remove": "Quelle entfernen",
- "store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
+ "store_source_remove_confirm": "Die Quelle „{name}“ entfernen?",
+ "store_source_remove_body": "Bereits daraus installierte Plugins bleiben installiert.",
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
"store_add_source_title": "Katalogquelle hinzufügen",
@@ -536,8 +548,11 @@
"games_state_exited": "Beendet",
"games_state_grace": "Wartet auf Client",
"games_closing_in": "Client ist weg – wird in {time} geschlossen, falls er nicht zurückkommt",
- "games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele. Fortfahren?",
- "action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese. Fortfahren?",
+ "games_end_all_waiting_confirm": "Dieses Spiel hat keine ID, die der Host einzeln ansprechen kann — es jetzt zu beenden beendet alle {count} wartenden Spiele.",
+ "games_end_all_waiting_title": "Alle {count} wartenden Spiele beenden?",
+ "action_stop_session_all_confirm": "Der Host kennt nur einen Stopp, und der beendet jede laufende Sitzung — alle {count}, nicht nur diese.",
+ "action_stop_session_all_title": "Jede laufende Sitzung beenden?",
+ "action_stop_session_all": "Alle Sitzungen beenden",
"games_end_now": "Jetzt beenden",
"session_game_title": "Wenn ein Spiel oder eine Sitzung endet",
"session_game_help": "Eine Streaming-Sitzung und das Spiel, das sie gestartet hat, können ihr Schicksal teilen. Diese Einstellungen betreffen das Spiel; das Offenhalten oben betrifft die Anzeige, und beide haben eigene Zeitfenster.",
diff --git a/web/messages/en.json b/web/messages/en.json
index da6286ae..87da9002 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -225,6 +225,7 @@
"display_layout_manual": "Manual",
"display_preset_custom_label": "Custom presets",
"display_preset_save_as": "Save as preset…",
+ "display_preset_save_title": "Save as preset",
"display_preset_name": "Preset name",
"display_preset_edit": "Rename",
"display_preset_update": "Update to current settings",
@@ -254,7 +255,8 @@
"pairing_native_cancel": "Cancel",
"pairing_native_devices": "Paired devices",
"pairing_native_empty": "No devices paired yet.",
- "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
+ "pairing_native_unpair_confirm": "Unpair this device?",
+ "pairing_native_unpair_body": "It will need to pair again to connect.",
"pairing_protocol": "Protocol",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
@@ -262,7 +264,8 @@
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
"pairing_pending_approve": "Approve",
"pairing_pending_deny": "Deny",
- "pairing_pending_name_prompt": "Name this device:",
+ "pairing_pending_name_prompt": "Name this device",
+ "pairing_pending_name_title": "Approve this device",
"pairing_pending_age_just_now": "just now",
"pairing_pending_age_secs": "{s}s ago",
"pairing_pending_age_mins": "{min} min ago",
@@ -324,7 +327,8 @@
"library_provider_filter": "Show only these",
"library_provider_show_all": "Show all",
"library_provider_purge": "Remove this provider's entries",
- "library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”? This only removes them from the library.",
+ "library_provider_purge_confirm": "Remove all {count} entries synced by “{provider}”?",
+ "library_provider_purge_body": "This only removes them from the library.",
"library_provider_purged": "Removed the entries synced by “{provider}”.",
"library_provider_purge_failed": "Could not remove this provider's entries.",
"library_save": "Save",
@@ -332,13 +336,18 @@
"library_cancel": "Cancel",
"library_edit": "Edit",
"library_delete": "Delete",
- "library_delete_confirm": "Delete this custom game? This can't be undone.",
+ "library_delete_confirm": "Delete this custom game?",
+ "library_delete_body": "This can't be undone.",
"settings_title": "Settings",
"settings_language": "Language",
"common_loading": "Loading…",
"common_error": "Something went wrong.",
"common_retry": "Retry",
"common_cancel": "Cancel",
+ "common_confirm": "Confirm",
+ "common_save": "Save",
+ "common_discard": "Discard",
+ "common_remove": "Remove",
"common_unauthorized": "Session expired — redirecting to sign in…",
"login_title": "Sign in",
"login_subtitle": "Enter the management password to continue. Don't know what to do?",
@@ -412,7 +421,8 @@
"stats_view": "View",
"stats_download": "Download",
"stats_delete": "Delete",
- "stats_delete_confirm": "Delete this recording? This can't be undone.",
+ "stats_delete_confirm": "Delete this recording?",
+ "stats_delete_body": "This can't be undone.",
"stats_detail_title": "Recording detail",
"stats_close": "Close",
"stats_no_samples": "This recording has no samples.",
@@ -462,7 +472,8 @@
"store_stopped": "Not running",
"store_version_unknown": "version unknown",
"store_uninstall": "Uninstall",
- "store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
+ "store_uninstall_confirm": "Uninstall {title}?",
+ "store_uninstall_body": "You can install it again from the catalog.",
"store_uninstall_failed": "Could not start the removal.",
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
"store_sources_title": "Catalog sources",
@@ -477,7 +488,8 @@
"store_source_fetched": "last fetched {when}",
"store_source_never": "never",
"store_source_remove": "Remove source",
- "store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
+ "store_source_remove_confirm": "Remove the source “{name}”?",
+ "store_source_remove_body": "Plugins already installed from it stay installed.",
"store_source_remove_failed": "Could not remove the source.",
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
"store_add_source_title": "Add a catalog source",
@@ -536,8 +548,11 @@
"games_state_exited": "Ended",
"games_state_grace": "Waiting for client",
"games_closing_in": "Its client is gone — closing in {time} unless it comes back",
- "games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close. Continue?",
- "action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one. Continue?",
+ "games_end_all_waiting_confirm": "This game has no id the host can single out, so ending it now ends all {count} games waiting to close.",
+ "games_end_all_waiting_title": "End all {count} waiting games?",
+ "action_stop_session_all_confirm": "The host has one stop, and it ends every live session — all {count} of them, not just this one.",
+ "action_stop_session_all_title": "Stop every live session?",
+ "action_stop_session_all": "Stop every session",
"games_end_now": "End now",
"session_game_title": "When a game or a session ends",
"session_game_help": "A streaming session and the game it launched can share a fate. These settings are about the game; the keep-alive above is about the display, and the two have separate timers.",
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/dialogs.tsx b/web/src/components/dialogs.tsx
new file mode 100644
index 00000000..edde5cff
--- /dev/null
+++ b/web/src/components/dialogs.tsx
@@ -0,0 +1,209 @@
+import {
+ createContext,
+ type FC,
+ type ReactNode,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { m } from "@/paraglide/messages";
+
+/**
+ * `window.confirm` / `window.prompt`, replaced by the console's own Dialog.
+ *
+ * WHY A PROVIDER AND NOT A COMPONENT PER SITE. The native calls this replaces are *expressions* —
+ * `if (!confirm(…)) return;` — sixteen of them, threaded through mutation handlers and one router
+ * navigation guard. Rewriting each into "hold the pending action in state, render a dialog, run the
+ * action from its onConfirm" would have put a piece of dialog machinery in every section file and
+ * turned each linear handler inside out. Handing back a PROMISE keeps the call sites the shape they
+ * already are:
+ *
+ * if (!(await confirm({ title: … }))) return;
+ *
+ * which is also why the navigation guard could come along: TanStack's `shouldBlockFn` accepts
+ * `Promise`. The one native prompt that necessarily stays is `beforeunload` — a reload or
+ * a tab close is the browser's dialog to draw, not ours.
+ */
+export type ConfirmOptions = {
+ /** The question. Short — it is a heading. */
+ title: string;
+ /** What the operator is not being told by the title alone: the consequence. */
+ description?: string;
+ /** Affirmative label. Use the same verb as the control that opened the dialog. */
+ confirmLabel?: string;
+ /** Paint the affirmative button red — anything that destroys, removes, or interrupts. */
+ destructive?: boolean;
+};
+
+export type PromptOptions = {
+ title: string;
+ description?: string;
+ confirmLabel?: string;
+ /** Field label. */
+ label: string;
+ defaultValue?: string;
+ placeholder?: string;
+};
+
+type Dialogs = {
+ /** Resolves true only if the operator confirmed; false on cancel, Esc, or overlay click. */
+ confirm: (options: ConfirmOptions) => Promise;
+ /** Resolves the entered text, or null if the operator backed out — same contract as `prompt`. */
+ promptText: (options: PromptOptions) => Promise;
+};
+
+type Pending =
+ | { kind: "confirm"; options: ConfirmOptions; settle: (ok: boolean) => void }
+ | {
+ kind: "prompt";
+ options: PromptOptions;
+ settle: (text: string | null) => void;
+ };
+
+const DialogsContext = createContext(null);
+
+/** Settle a request negatively. Must switch on `kind` — the two `settle`s take different types. */
+const cancel = (p: Pending | null) => {
+ if (!p) return;
+ if (p.kind === "confirm") p.settle(false);
+ else p.settle(null);
+};
+
+export const DialogsProvider: FC<{ children: ReactNode }> = ({ children }) => {
+ const [pending, setPending] = useState(null);
+ const [draft, setDraft] = useState("");
+
+ // `setPending` takes the updater form so a second ask while one is already open cannot strand the
+ // first promise unresolved — the caller would await forever. Two at once shouldn't happen (the
+ // open dialog is modal), but the navigation guard can fire from outside the page's own UI.
+ const api = useMemo(
+ () => ({
+ confirm: (options) =>
+ new Promise((resolve) =>
+ setPending((prev) => {
+ cancel(prev);
+ return { kind: "confirm", options, settle: resolve };
+ }),
+ ),
+ promptText: (options) =>
+ new Promise((resolve) => {
+ setDraft(options.defaultValue ?? "");
+ setPending((prev) => {
+ cancel(prev);
+ return { kind: "prompt", options, settle: resolve };
+ });
+ }),
+ }),
+ [],
+ );
+
+ /** Resolve and close. Every exit — button, Esc, overlay — goes through here exactly once. */
+ const close = (value: boolean | string | null) => {
+ if (!pending) return;
+ if (pending.kind === "confirm") pending.settle(value === true);
+ else pending.settle(typeof value === "string" ? value : null);
+ setPending(null);
+ };
+
+ const cancelled = () => close(pending?.kind === "prompt" ? null : false);
+ const options = pending?.options;
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+/**
+ * The console's `confirm` / `prompt`. Both return a promise, so a handler reads top to bottom:
+ *
+ * const onDelete = async () => {
+ * if (!(await confirm({ title: m.x(), confirmLabel: m.y(), destructive: true }))) return;
+ * await remove.mutateAsync(…);
+ * };
+ */
+export const useDialogs = (): Dialogs => {
+ const ctx = useContext(DialogsContext);
+ if (!ctx)
+ throw new Error(
+ "useDialogs must be used inside (__root)",
+ );
+ return ctx;
+};
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..77f5659c
--- /dev/null
+++ b/web/src/components/ui/select.tsx
@@ -0,0 +1,72 @@
+// 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 as SelectItemBase,
+ 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";
+
+// The highlighted row. Upstream paints it `bg-main/25` — a neutral grey wash, since `--main` is the
+// foreground colour. Everywhere else in this console the "this is the one" wash is brand violet
+// (`bg-primary/15` on the nav's hover and active states, `ring-primary` on a chosen preset card), so
+// a grey row is the odd one out the moment a select sits next to any of them.
+const SelectItem = ({
+ className,
+ ...props
+}: ComponentProps) => (
+
+);
+SelectItem.displayName = "SelectItem";
+
+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/routes/__root.tsx b/web/src/routes/__root.tsx
index df63434a..c5625bf4 100644
--- a/web/src/routes/__root.tsx
+++ b/web/src/routes/__root.tsx
@@ -13,6 +13,7 @@ import { Toaster } from "@unom/ui/toast";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { AppShell } from "@/components/app-shell";
+import { DialogsProvider } from "@/components/dialogs";
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
import appCss from "@/styles.css?url";
@@ -68,13 +69,18 @@ function RootComponent() {
animated at full strength even for someone whose OS asks for less. "user" honours
the OS setting. */}
- {isLogin ? (
-
- ) : (
-
+ {/* The console's own confirm/prompt, in place of the browser's grey boxes. Mounted
+ at the root because the navigation guard on the Displays page asks for one
+ while LEAVING that page — see components/dialogs.tsx. */}
+
+ {isLogin ? (
-
- )}
+ ) : (
+
+
+
+ )}
+
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
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. */}
+
{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/Automation/index.tsx b/web/src/sections/Automation/index.tsx
index 297329ff..5762b9ef 100644
--- a/web/src/sections/Automation/index.tsx
+++ b/web/src/sections/Automation/index.tsx
@@ -6,6 +6,7 @@ import { ApiError } from "@/api/fetcher";
import { useGetHooks } from "@/api/gen/hooks/hooks";
import type { HookEntry } from "@/api/gen/model/hookEntry";
import { hookAction, hookFilterSummary, useSaveHooks } from "@/api/hooks";
+import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,6 +37,7 @@ import { HookForm } from "./HookForm";
*/
export const SectionAutomation: FC = () => {
useLocale();
+ const { confirm } = useDialogs();
const query = useGetHooks();
const save = useSaveHooks();
@@ -70,8 +72,13 @@ export const SectionAutomation: FC = () => {
setEditing(null);
};
- const remove = (index: number) => {
- if (!confirm(m.automation_delete_confirm())) return;
+ const remove = async (index: number) => {
+ const ok = await confirm({
+ title: m.automation_delete_confirm(),
+ confirmLabel: m.automation_delete(),
+ destructive: true,
+ });
+ if (!ok) return;
setHooks((prev) => (prev ?? []).filter((_, i) => i !== index));
};
diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx
index 2e697bcd..15f8662d 100644
--- a/web/src/sections/Dashboard/index.tsx
+++ b/web/src/sections/Dashboard/index.tsx
@@ -9,6 +9,7 @@ import {
useRequestIdr,
useStopSession,
} from "@/api/gen/session/session";
+import { useDialogs } from "@/components/dialogs";
import { apiErrorMessage } from "@/lib/errors";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -17,6 +18,7 @@ import { DashboardView } from "./view";
export const SectionDashboard: FC = () => {
useLocale();
const qc = useQueryClient();
+ const { confirm } = useDialogs();
// Session/game transitions arrive on the event stream now (api/events.ts invalidates this key),
// so the timer only has to cover what events cannot: the live stream numbers — codec, resolution,
// fps, bitrate — which change continuously while something is streaming. Idle, it is a slow
@@ -61,23 +63,26 @@ export const SectionDashboard: FC = () => {
* - `POST /game/end` with `app_id: null` means "end EVERY waiting game" to the host, and a grace
* row for an operator-typed command carries no `app_id` — so that row ended all of them.
*/
- const onEndGame = (game: ActiveGame) => {
+ const onEndGame = async (game: ActiveGame) => {
const games = status.data?.games ?? [];
if (game.state === "grace") {
const waiting = games.filter((g) => g.state === "grace").length;
- if (
- !game.app_id &&
- waiting > 1 &&
- !confirm(m.games_end_all_waiting_confirm({ count: waiting }))
- )
- return;
+ if (!game.app_id && waiting > 1) {
+ const ok = await confirm({
+ title: m.games_end_all_waiting_title({ count: waiting }),
+ description: m.games_end_all_waiting_confirm({ count: waiting }),
+ confirmLabel: m.games_end_now(),
+ destructive: true,
+ });
+ if (!ok) return;
+ }
endGame.mutate(
{ data: { app_id: game.app_id ?? null } },
{ onSuccess: invalidate, onError: failed(m.games_end_failed()) },
);
return;
}
- if (!confirmStopAll()) return;
+ if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
@@ -86,18 +91,23 @@ export const SectionDashboard: FC = () => {
/** Shared by "End now" on a live row and the card's own Stop-session button: with more than one
* session live, stopping is not a per-client action and the operator has to know that. */
- const confirmStopAll = (): boolean => {
+ const confirmStopAll = (): Promise => {
const active = status.data?.active_sessions ?? 0;
- if (active <= 1) return true;
- return confirm(m.action_stop_session_all_confirm({ count: active }));
+ if (active <= 1) return Promise.resolve(true);
+ return confirm({
+ title: m.action_stop_session_all_title(),
+ description: m.action_stop_session_all_confirm({ count: active }),
+ confirmLabel: m.action_stop_session_all(),
+ destructive: true,
+ });
};
return (
{
- if (!confirmStopAll()) return;
+ onStopSession={async () => {
+ if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx
index 5692f0c0..8c28170b 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 {
@@ -37,10 +36,13 @@ import type {
Preset,
Topology,
} from "@/api/gen/model";
+import { type ConfirmOptions, useDialogs } from "@/components/dialogs";
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 { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
@@ -54,6 +56,7 @@ import { m } from "@/paraglide/messages";
*/
export const DisplaySection: FC = () => {
const qc = useQueryClient();
+ const { confirm } = useDialogs();
const q = useGetDisplaySettings();
const save = useSetDisplaySettings();
@@ -163,8 +166,12 @@ export const DisplaySection: FC = () => {
// clicking "Host" in the sidebar is a client-side route change the browser never hears about,
// so the draft vanished with no prompt at all. The router's blocker covers in-app navigation
// AND still arms `beforeunload` for the reload case, so it replaces the listener outright.
+ //
+ // `shouldBlockFn` may return a promise, which is what lets this ask with the console's own
+ // dialog rather than the browser's. `enableBeforeUnload` stays native by necessity: a reload or
+ // a tab close is the browser's dialog to draw, and it will not wait on ours.
useBlocker({
- shouldBlockFn: () => !confirm(m.display_discard_confirm()),
+ shouldBlockFn: async () => !(await confirm(discardPrompt())),
enableBeforeUnload: () => dirty,
disabled: !dirty,
});
@@ -233,6 +240,17 @@ export const DisplaySection: FC = () => {
);
};
+/**
+ * The gate on anything that would throw unsaved Custom fields away — asked from three places (a
+ * preset click, applying a saved preset, and leaving the page), so it is written once. A function
+ * rather than a constant because the message is resolved per locale, at call time.
+ */
+const discardPrompt = (): ConfirmOptions => ({
+ title: m.display_discard_confirm(),
+ confirmLabel: m.common_discard(),
+ destructive: true,
+});
+
/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */
const PRESET_ORDER = [
"default",
@@ -243,7 +261,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 }[];
@@ -279,6 +301,7 @@ const DisplayForm: FC<{
error,
}) => {
const qc = useQueryClient();
+ const { confirm, promptText } = useDialogs();
const createPreset = useCreateCustomPreset();
const updatePreset = useUpdateCustomPreset();
const deletePreset = useDeleteCustomPreset();
@@ -309,11 +332,10 @@ const DisplayForm: FC<{
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
// effective behavior (nothing changes until you Save).
- const pickPreset = (id: string) => {
+ const pickPreset = async (id: string) => {
// A preset click overwrites the whole policy, so hand-edits that were never saved would
// vanish without a word — the same failure as not finding the Save button, one click later.
- if (dirty && id !== "custom" && !confirm(m.display_discard_confirm()))
- return;
+ if (dirty && id !== "custom" && !(await confirm(discardPrompt()))) return;
// Already hand-editing: re-seeding would fill in defaults for fields the stored policy
// leaves unset and flag "unsaved changes" for a click that changed nothing.
if (id === "custom" && isCustom) return;
@@ -344,8 +366,8 @@ const DisplayForm: FC<{
// Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the
// one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…).
- const applyCustomPreset = (p: CustomPreset) => {
- if (dirty && !confirm(m.display_discard_confirm())) return;
+ const applyCustomPreset = async (p: CustomPreset) => {
+ if (dirty && !(await confirm(discardPrompt()))) return;
apply({
version: 1,
preset: "custom",
@@ -372,8 +394,13 @@ const DisplayForm: FC<{
const anyCustomSelected = customPresets.some(customSelected);
// Save the currently-in-force behavior (built-in OR hand-edited) as a new named preset.
- const saveAsPreset = () => {
- const name = prompt(m.display_preset_name())?.trim();
+ const saveAsPreset = async () => {
+ const name = (
+ await promptText({
+ title: m.display_preset_save_title(),
+ label: m.display_preset_name(),
+ })
+ )?.trim();
if (!name) return; // cancelled or empty
createPreset.mutate(
{
@@ -386,8 +413,14 @@ const DisplayForm: FC<{
{ onSuccess: invalidateSettings },
);
};
- const renamePreset = (p: CustomPreset) => {
- const name = prompt(m.display_preset_name(), p.name)?.trim();
+ const renamePreset = async (p: CustomPreset) => {
+ const name = (
+ await promptText({
+ title: m.display_preset_edit(),
+ label: m.display_preset_name(),
+ defaultValue: p.name,
+ })
+ )?.trim();
if (!name) return;
updatePreset.mutate(
{
@@ -413,8 +446,13 @@ const DisplayForm: FC<{
},
{ onSuccess: invalidateSettings },
);
- const removePreset = (p: CustomPreset) => {
- if (!confirm(m.display_preset_delete_confirm())) return;
+ const removePreset = async (p: CustomPreset) => {
+ const ok = await confirm({
+ title: m.display_preset_delete_confirm(),
+ confirmLabel: m.display_preset_delete(),
+ destructive: true,
+ });
+ if (!ok) return;
deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings });
};
@@ -431,7 +469,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 +548,7 @@ const DisplayForm: FC<{
);
})}
-
+
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but
@@ -527,7 +569,7 @@ const DisplayForm: FC<{
{customPresets.length > 0 && (
-
);
diff --git a/web/src/sections/Displays/SessionGameCard.tsx b/web/src/sections/Displays/SessionGameCard.tsx
index 0ee9755a..a51e12ce 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";
@@ -142,6 +142,13 @@ export const SessionGameCard: FC = () => {
htmlFor="session-grace-seconds"
>
+ {/* Deliberately NOT `InputNumber`, unlike the numeric fields on
+ the policy card next door. This one writes to the HOST on
+ blur, and InputNumber commits while you type — so its own
+ blur-time clamp would race the apply below, which still
+ closes over the pre-clamp value. The host is the authority
+ here regardless: it clamps to 10..=86400 on write and
+ answers with what it actually stored. */}
+ {/* These two stay `type="number"` over a STRING field rather than becoming
+ `InputNumber` like the policy card's numbers: both are OPTIONAL metadata
+ where empty means "don't send it" (see `int()` above), and InputNumber's
+ contract is `value: number` — it cannot express "unset", so adopting it
+ would invent a year for every entry that hasn't got one. The type here
+ only asks for a numeric keypad. */}
void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
+ const { confirm } = useDialogs();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
@@ -53,7 +55,13 @@ export const LibraryGridSection: FC<{
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: GameEntry) => {
- if (!confirm(m.library_delete_confirm())) return;
+ const ok = await confirm({
+ title: m.library_delete_confirm(),
+ description: m.library_delete_body(),
+ confirmLabel: m.library_delete(),
+ destructive: true,
+ });
+ if (!ok) return;
try {
await remove.mutateAsync({ id: customId(entry) });
} catch (e) {
@@ -108,13 +116,9 @@ export const LibraryGrid: FC<{