Merge pull request 'The console stops borrowing the browser's controls — and its preset tiles stop landing all at once' (#100) from worktree-web-console-sweep into main
ci / bun-nix (push) Successful in 32s
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
ci / rust-arm64 (push) Successful in 2m28s
windows-host / package (push) Failing after 1m31s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
deb / build-publish-client-arm64 (push) Successful in 4m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m27s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish (push) Successful in 5m37s
ci / rust (push) Successful in 7m27s
arch / build-publish (push) Successful in 8m48s
docker / deploy-docs (push) Failing after 6m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m22s

Reviewed-on: #100
This commit was merged in pull request #100.
This commit is contained in:
2026-08-07 21:08:42 +00:00
32 changed files with 1053 additions and 182 deletions
+16 -9
View File
@@ -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 (
<QueryClientProvider client={queryClient}>
<MaterialProvider theme={defaultMaterialTheme}>
<div className={dark ? "dark" : ""}>
<Section maxWidth={false}>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</Section>
</div>
{/* 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. */}
<DialogsProvider>
<div className={dark ? "dark" : ""}>
<Section maxWidth={false}>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</Section>
</div>
</DialogsProvider>
</MaterialProvider>
</QueryClientProvider>
);
+24 -9
View File
@@ -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.",
+24 -9
View File
@@ -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.",
+5 -13
View File
@@ -12,11 +12,12 @@ import {
Settings,
Workflow,
} from "lucide-react";
import { motion, stagger } from "motion/react";
import { motion } from "motion/react";
import { type ReactNode, useState } from "react";
import { useHostEvents } from "@/api/events";
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
import { BrandMark } from "@/components/brand-mark";
import { Stagger, staggerProps } from "@/components/stagger";
import { Wordmark } from "@/components/wordmark";
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
import { cn } from "@/lib/utils";
@@ -72,10 +73,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<motion.nav
animate="enter"
initial="from"
transition={{
delayChildren: stagger(0.1),
}}
variants={{ enter: {}, from: {} }}
{...staggerProps()}
className="flex flex-col gap-1"
>
{NAV.map(({ to, icon: Icon, label }) => (
@@ -148,13 +146,7 @@ function PluginNavSection() {
// stagger nor the variants and plugin entries simply appeared. They arrive asynchronously
// (and a fresh install adds one to a nav that is already on screen), which is exactly when
// the animation earns its keep.
<motion.div
animate="enter"
initial="from"
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="mt-6 flex flex-col gap-1"
>
<Stagger root className="mt-6 flex flex-col gap-1">
<motion.p
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
@@ -193,7 +185,7 @@ function PluginNavSection() {
</motion.div>
);
})}
</motion.div>
</Stagger>
);
}
+209
View File
@@ -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<boolean>`. 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<boolean>;
/** Resolves the entered text, or null if the operator backed out — same contract as `prompt`. */
promptText: (options: PromptOptions) => Promise<string | null>;
};
type Pending =
| { kind: "confirm"; options: ConfirmOptions; settle: (ok: boolean) => void }
| {
kind: "prompt";
options: PromptOptions;
settle: (text: string | null) => void;
};
const DialogsContext = createContext<Dialogs | null>(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<Pending | null>(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<Dialogs>(
() => ({
confirm: (options) =>
new Promise<boolean>((resolve) =>
setPending((prev) => {
cancel(prev);
return { kind: "confirm", options, settle: resolve };
}),
),
promptText: (options) =>
new Promise<string | null>((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 (
<DialogsContext.Provider value={api}>
{children}
<Dialog
open={pending !== null}
onOpenChange={(open) => {
if (!open) cancelled();
}}
>
{pending && options && (
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{options.title}</DialogTitle>
{/* Radix warns when a dialog has no description; render the element only when
there is one to say, and tell it so explicitly otherwise. */}
{options.description ? (
<DialogDescription>{options.description}</DialogDescription>
) : (
<DialogDescription className="sr-only">
{options.title}
</DialogDescription>
)}
</DialogHeader>
{pending.kind === "prompt" && (
<div className="space-y-2">
<Label htmlFor="dialog-prompt">{pending.options.label}</Label>
<Input
id="dialog-prompt"
// Autofocus is right here and nowhere else: a modal text prompt exists
// to be typed into, and it took the focus automatically as a `prompt()`.
autoFocus
autoComplete="off"
value={draft}
placeholder={pending.options.placeholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter submits — the reflex `prompt()` trained everyone into.
if (e.key === "Enter") {
e.preventDefault();
close(draft);
}
}}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={cancelled}>
{m.common_cancel()}
</Button>
<Button
variant={
pending.kind === "confirm" && pending.options.destructive
? "destructive"
: "default"
}
onClick={() => close(pending.kind === "prompt" ? draft : true)}
>
{options.confirmLabel ??
(pending.kind === "prompt"
? m.common_save()
: m.common_confirm())}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</DialogsContext.Provider>
);
};
/**
* 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 <DialogsProvider> (__root)",
);
return ctx;
};
+55
View File
@@ -0,0 +1,55 @@
import { type HTMLMotionProps, motion, stagger } from "motion/react";
import type { FC } from "react";
/** The house cadence, in seconds between siblings. */
export const STAGGER_GAP = 0.1;
/**
* The stagger-container contract as plain props — for the places that need a specific element
* (`motion.nav`) rather than the `<Stagger>` div below.
*
* The empty `enter`/`from` variants are not a placeholder: a motion element only PROPAGATES a
* variant it names, so a container that defines none stops the cascade dead and its children never
* animate at all.
*/
export const staggerProps = (gap: number = STAGGER_GAP) => ({
variants: { enter: {}, from: {} },
transition: { delayChildren: stagger(gap) },
});
/**
* The console's on-mount cadence: siblings arrive one after another, not all on the same frame.
*
* WHY THIS EXISTS AS A COMPONENT. Every animated primitive here (`Card`, `Button`, `Checkbox` — all
* `@unom/ui`) is a motion element whose `from`/`enter` variants are INHERITED from the nearest
* motion ancestor, and it is that ancestor which owns the timing. `@unom/ui`'s `<Section>` sets
* `delayChildren: stagger(...)`, which is why a page whose cards are direct descendants of the
* Section staggers for free.
*
* The trap is that an `<AnimatedCard>` is ALSO a motion element, and it sets no `delayChildren` — so
* a grid of cards nested inside a card becomes its own timing group and every tile lands at once.
* Nothing in the types catches that; it only shows up in a browser, beside a page that does it
* right. Wrapping the grid re-establishes the cadence.
*
* `root` is for a container with no animating motion ancestor to inherit from (the sidebar nav): it
* drives `from → enter` itself. Inside a `<Section>` or a card, leave it off — supplying
* `initial`/`animate` there would run the group on its own clock instead of the page's.
*/
export const Stagger: FC<
HTMLMotionProps<"div"> & {
/** Seconds between siblings. */
gap?: number;
/** Drive the enter animation instead of inheriting it — see above. */
root?: boolean;
}
> = ({ gap, root = false, transition, ...props }) => {
const base = staggerProps(gap);
return (
<motion.div
{...(root ? { initial: "from", animate: "enter" } : {})}
variants={base.variants}
transition={{ ...base.transition, ...transition }}
{...props}
/>
);
};
+7
View File
@@ -0,0 +1,7 @@
// The console's Checkbox IS @unom/ui's radix checkbox — shadcn-compatible tokens (border-input,
// data-checked:bg-primary), the tick drawn as an animated path, plus the shared toggle sound.
//
// It is `checked`/`onCheckedChange` (radix), NOT `checked`/`onChange` — a raw `<input
// type="checkbox">` swapped in here silently loses the brand entirely, which is exactly how two of
// them survived in the library forms.
export { Checkbox } from "@unom/ui/form/checkbox";
+16
View File
@@ -0,0 +1,16 @@
// The console's numeric field IS @unom/ui's form input-number — the Input with a number's rules
// layered on: it keeps a local draft while you type (so the field can be EMPTY on the way to a new
// value), commits only a finite in-range number, and clamps to `min`/`max` on blur.
//
// That is not cosmetic. The hand-rolled shape it replaces —
//
// <Input type="number" min={1} max={600}
// onChange={(e) => set({ timeout_s: Number(e.target.value) || 30 })} />
//
// — has two defects the shared component doesn't: clearing the field snaps it to the fallback
// instead of letting you retype, and `min`/`max` are decoration (an `<input>`'s range is only
// enforced by form validation, which a controlled field like this never runs) — so 900 went
// straight into a timeout capped at 600.
//
// `onChange` hands you a `number`, not an event.
export { InputNumber } from "@unom/ui/form/input-number";
+72
View File
@@ -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<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";
// 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<typeof SelectItemBase>) => (
<SelectItemBase
className={cn("focus:bg-primary/15 focus:text-foreground", className)}
{...props}
/>
);
SelectItem.displayName = "SelectItem";
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+4
View File
@@ -0,0 +1,4 @@
// The console's Textarea IS @unom/ui's form textarea — the Input's twin (same `border-input` /
// `placeholder:text-muted-foreground` / focus ring tokens, material gloss via UnomProviders), so a
// multi-line field sits beside a single-line one without either looking borrowed.
export { Textarea } from "@unom/ui/form/textarea";
+12 -6
View File
@@ -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. */}
<MotionConfig reducedMotion="user">
{isLogin ? (
<Outlet />
) : (
<AppShell>
{/* 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. */}
<DialogsProvider>
{isLogin ? (
<Outlet />
</AppShell>
)}
) : (
<AppShell>
<Outlet />
</AppShell>
)}
</DialogsProvider>
</MotionConfig>
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
<Toaster />
+30 -23
View File
@@ -1,7 +1,7 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { type FC, useEffect, useState } from "react";
import type { HookEntry } from "@/api/gen/model/hookEntry";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -11,7 +11,15 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { InputNumber } from "@/components/ui/input-number";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { m } from "@/paraglide/messages";
/** The event kinds the host publishes, plus the `domain.*` wildcards the hook filter accepts.
@@ -104,18 +112,21 @@ export const HookForm: FC<{
<div className="space-y-2">
<Label htmlFor="hook-on">{m.automation_field_on()}</Label>
<select
id="hook-on"
value={draft.on}
onChange={(e) => set({ on: e.target.value })}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
{EVENT_KINDS.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
{/* The event kinds are IDENTIFIERS, not prose — deliberately not routed through
i18n, and shown in the host's own `domain.event` spelling so what you pick here
reads the same as what you'd type into the SSE `?kinds=` filter. */}
<Select value={draft.on} onValueChange={(on) => set({ on })}>
<SelectTrigger id="hook-on">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EVENT_KINDS.map((k) => (
<SelectItem key={k} value={k}>
{k}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{m.automation_field_on_help()}
</p>
@@ -222,14 +233,11 @@ export const HookForm: FC<{
<Label htmlFor="hook-debounce">
{m.automation_field_debounce()}
</Label>
<Input
<InputNumber
id="hook-debounce"
type="number"
min={0}
value={draft.debounce_ms ?? 0}
onChange={(e) =>
set({ debounce_ms: Number(e.target.value) || 0 })
}
onChange={(debounce_ms) => set({ debounce_ms })}
/>
</div>
{kind === "run" && (
@@ -237,15 +245,14 @@ export const HookForm: FC<{
<Label htmlFor="hook-timeout">
{m.automation_field_timeout()}
</Label>
<Input
{/* 600 is the host's own ceiling — the old field let 900 through, and the
hook then failed at run time rather than at the point of typing it. */}
<InputNumber
id="hook-timeout"
type="number"
min={1}
max={600}
value={draft.timeout_s ?? 30}
onChange={(e) =>
set({ timeout_s: Number(e.target.value) || 30 })
}
onChange={(timeout_s) => set({ timeout_s })}
/>
</div>
)}
+9 -2
View File
@@ -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));
};
+23 -13
View File
@@ -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<boolean> => {
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 (
<DashboardView
status={status}
library={library.data}
onStopSession={() => {
if (!confirmStopAll()) return;
onStopSession={async () => {
if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
onSuccess: invalidate,
onError: failed(m.action_stop_failed()),
+77 -44
View File
@@ -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 `<Card>` wrapper the page puts
* around it, which is also its motion parent) without a host answering `/display/settings`.
*/
export const DisplayForm: FC<{
draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
@@ -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<{
<Label className="mb-1 block text-base font-semibold">
{m.display_preset()}
</Label>
<div className="grid gap-3 sm:grid-cols-2">
{/* The preset tiles are cards nested INSIDE this page's config card, so their motion
parent is that card — which sets no `delayChildren` and therefore landed all six
on the same frame, unlike every other card grid in the console. `Stagger` gives
the group its own cadence back (see components/stagger.tsx). */}
<Stagger className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields;
@@ -506,7 +548,7 @@ const DisplayForm: FC<{
</Card>
);
})}
</div>
</Stagger>
</div>
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but
@@ -527,7 +569,7 @@ const DisplayForm: FC<{
</Button>
</div>
{customPresets.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
<Stagger className="grid gap-3 sm:grid-cols-2">
{customPresets.map((p) => (
<CustomPresetCard
key={p.id}
@@ -540,7 +582,7 @@ const DisplayForm: FC<{
onDelete={() => removePreset(p)}
/>
))}
</div>
</Stagger>
)}
{presetError && (
<p className="text-sm text-amber-600 dark:text-amber-500">
@@ -609,16 +651,14 @@ const DisplayForm: FC<{
</Button>
{ka.mode === "duration" && (
<div className="flex items-center gap-2">
<Input
<InputNumber
id="display-keep-alive-seconds"
aria-label={m.display_keep_alive_seconds()}
type="number"
min={0}
className="w-24"
value={ka.seconds}
disabled={busy}
onChange={(e) => {
const n = Math.max(0, Number(e.target.value) || 0);
onChange={(n) => {
setKeepSecs(n);
setDraft({
...draft,
@@ -682,23 +722,17 @@ const DisplayForm: FC<{
/>
<Field label={m.display_max()} htmlFor="display-max">
<Input
{/* 1..=16 is the host's own clamp on write. InputNumber holds the field to it
and — unlike the arithmetic this replaces — lets it be EMPTY on the way to
a new number instead of snapping to 1 the moment you clear it. */}
<InputNumber
id="display-max"
type="number"
min={1}
max={16}
className="w-24"
value={draft.max_displays ?? 4}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
max_displays: Math.min(
16,
Math.max(1, Number(e.target.value) || 1),
),
})
}
onChange={(max_displays) => setDraft({ ...draft, max_displays })}
/>
</Field>
@@ -1199,28 +1233,27 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
X
</Label>
<Input
{/* A screen left of or above the origin has a NEGATIVE coordinate, and the
arithmetic this replaces made one almost untypable: `Number("-") || 0`
is 0, so the lone minus sign was rewritten to "0" before the digits
could be typed. InputNumber holds "-" as an incomplete draft instead.
`Math.trunc` stays — positions are whole pixels. */}
<InputNumber
id={`disp-x-${slot}`}
type="number"
className="w-24"
value={p.x}
disabled={saveLayout.isPending}
onChange={(e) =>
setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))
}
onChange={(n) => setXY(slot, "x", Math.trunc(n))}
/>
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
Y
</Label>
<Input
<InputNumber
id={`disp-y-${slot}`}
type="number"
className="w-24"
value={p.y}
disabled={saveLayout.isPending}
onChange={(e) =>
setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))
}
onChange={(n) => setXY(slot, "y", Math.trunc(n))}
/>
</div>
);
@@ -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"
>
<div className="flex items-center gap-2">
{/* 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. */}
<Input
id="session-grace-seconds"
type="number"
+1 -1
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import {
@@ -10,6 +9,7 @@ import {
import type { GpuState } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
+1 -1
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useState } from "react";
import { ApiError } from "@/api/fetcher";
@@ -11,6 +10,7 @@ import {
} from "@/api/gen/update/update";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
+10 -4
View File
@@ -10,6 +10,7 @@ import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiErrorMessage } from "@/lib/errors";
@@ -313,12 +314,11 @@ export const GameForm: FC<{
"Heroic" or "Lutris" tile without installing that source's plugin. */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
<Checkbox
id="lib-isLauncher"
type="checkbox"
checked={form.isLauncher}
onChange={(e) =>
setForm((f) => ({ ...f, isLauncher: e.target.checked }))
onCheckedChange={(next) =>
setForm((f) => ({ ...f, isLauncher: next === true }))
}
/>
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
@@ -362,6 +362,12 @@ export const GameForm: FC<{
onChange={set("publisher")}
/>
</div>
{/* 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. */}
<div className="grid grid-cols-2 gap-4">
<Field
id="releaseYear"
+14 -14
View File
@@ -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,
@@ -8,7 +7,9 @@ import {
useGetLibrary,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { useDialogs } from "@/components/dialogs";
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";
@@ -29,6 +30,7 @@ export const LibraryGridSection: FC<{
onEntries?: (entries: GameEntry[]) => 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<{
<p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{m.library_launchers_title()}
</p>
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
<Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
{launchers.map(card)}
</motion.div>
</Stagger>
</div>
)}
{all.length === 0 ? (
@@ -136,13 +140,9 @@ export const LibraryGrid: FC<{
) : (
games.length > 0 && (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
<Stagger className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
{games.map(card)}
</motion.div>
</Stagger>
</div>
)
)}
+32 -19
View File
@@ -2,6 +2,7 @@ import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useState } from "react";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -10,7 +11,15 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { m } from "@/paraglide/messages";
/**
@@ -152,7 +161,8 @@ const renderable = (node: JsonSchemaNode): boolean => {
if (n.enum) return true;
if (n.type === "boolean" || n.type === "string") return true;
if (n.type === "number" || n.type === "integer") return true;
if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true;
if (n.type === "array" && flatten(n.items ?? {}).type === "string")
return true;
if (n.type === "object" && n.properties) {
return Object.values(n.properties).every(renderable);
}
@@ -173,7 +183,8 @@ const ConfigForm: FC<{
// Fall back to the JSON editor when there is no schema, or any field is a shape the generic
// form can't express (a non-enum union, a $ref). Partial rendering would be worse than none:
// a field silently missing from the form is a setting the operator cannot change.
const canRender = props !== undefined && Object.values(props).every(renderable);
const canRender =
props !== undefined && Object.values(props).every(renderable);
if (!canRender) {
return (
@@ -181,8 +192,8 @@ const ConfigForm: FC<{
<p className="text-xs text-muted-foreground">
{m.library_source_settings_json_hint()}
</p>
<textarea
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs"
<Textarea
className="h-64 font-mono text-xs"
value={raw}
onChange={(e) => onRaw(e.target.value)}
spellCheck={false}
@@ -255,18 +266,21 @@ const Field: FC<{
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<select
id={id}
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
<Select
value={String(value ?? node.default ?? node.enum[0])}
onChange={(e) => onChange(e.target.value)}
onValueChange={onChange}
>
{node.enum.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<SelectTrigger id={id} size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{node.enum.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
@@ -279,11 +293,10 @@ const Field: FC<{
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<input
<Checkbox
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
onCheckedChange={(next) => onChange(next === true)}
/>
<Label htmlFor={id}>{label}</Label>
</div>
@@ -300,9 +313,9 @@ const Field: FC<{
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<textarea
<Textarea
id={id}
className="h-24 w-full rounded-md border bg-background p-2 font-mono text-xs"
className="h-24 font-mono text-xs"
value={list.join("\n")}
onChange={(e) =>
onChange(
+9 -1
View File
@@ -16,6 +16,7 @@ import {
useInstallPlugin,
useStoreCatalog,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -42,6 +43,7 @@ export const SourcesSection: FC<{
onFilter: (provider: string | null) => void;
}> = ({ activeFilter, onFilter }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const purge = useDeleteProviderEntries();
@@ -68,7 +70,13 @@ export const SourcesSection: FC<{
const onPurge = async (source: ScannerInfo) => {
const provider = source.provider ?? source.id;
const count = source.entries ?? 0;
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
const ok = await confirm({
title: m.library_provider_purge_confirm({ provider, count }),
description: m.library_provider_purge_body(),
confirmLabel: m.common_remove(),
destructive: true,
});
if (!ok) return;
try {
await purge.mutateAsync({ provider });
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
+10 -2
View File
@@ -11,6 +11,7 @@ import {
useListNativeClients,
useUnpairNativeClient,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -43,6 +44,7 @@ export interface PairedRow {
*/
export const PairedDevicesSection: FC = () => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const native = useListNativeClients();
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
@@ -65,8 +67,14 @@ export const PairedDevicesSection: FC = () => {
),
];
const onUnpair = (protocol: PairedProtocol, fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
const onUnpair = async (protocol: PairedProtocol, fingerprint: string) => {
const ok = await confirm({
title: m.pairing_native_unpair_confirm(),
description: m.pairing_native_unpair_body(),
confirmLabel: m.action_unpair(),
destructive: true,
});
if (!ok) return;
if (protocol === "native") {
unpairNative.mutate(
{ fingerprint },
+10 -3
View File
@@ -1,5 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { UserPlus, X } from "lucide-react";
import type { FC } from "react";
import type { PendingDevice } from "@/api/gen/model";
@@ -10,7 +9,9 @@ import {
useDenyPendingDevice,
useListPendingDevices,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
@@ -27,6 +28,7 @@ export const PendingDevicesSection: FC = () => {
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
// the rows carry an age that should not visibly lag.
const { promptText } = useDialogs();
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
@@ -35,8 +37,13 @@ export const PendingDevicesSection: FC = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
const onApprove = async (id: number, currentName: string) => {
const name = await promptText({
title: m.pairing_pending_name_title(),
label: m.pairing_pending_name_prompt(),
defaultValue: currentName,
confirmLabel: m.pairing_pending_approve(),
});
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
+10 -2
View File
@@ -9,6 +9,7 @@ import {
useStatsRecordingDelete,
useStatsRecordingsList,
} from "@/api/gen/stats/stats";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -36,11 +37,18 @@ export const RecordingsSection: FC<{
onSelect: (id: string | null) => void;
}> = ({ selectedId, onSelect }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const recordings = useStatsRecordingsList();
const del = useStatsRecordingDelete();
const onDelete = (id: string) => {
if (!confirm(m.stats_delete_confirm())) return;
const onDelete = async (id: string) => {
const ok = await confirm({
title: m.stats_delete_confirm(),
description: m.stats_delete_body(),
confirmLabel: m.stats_delete(),
destructive: true,
});
if (!ok) return;
del.mutate(
{ id },
{
+1 -1
View File
@@ -1,8 +1,8 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
+9 -1
View File
@@ -17,6 +17,7 @@ import {
useSetSource,
useStoreSources,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -49,6 +50,7 @@ const fmtFetched = (secs: number): string =>
* what trusting a third-party catalog means before anything is written to the host.
*/
export const SourcesTab: FC = () => {
const { confirm } = useDialogs();
const sources = useStoreSources();
const refresh = useRefreshCatalog();
const save = useSetSource();
@@ -82,7 +84,13 @@ export const SourcesTab: FC = () => {
};
const onRemove = async (source: StoreSource) => {
if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return;
const ok = await confirm({
title: m.store_source_remove_confirm({ name: source.name }),
description: m.store_source_remove_body(),
confirmLabel: m.store_source_remove(),
destructive: true,
});
if (!ok) return;
try {
await remove.mutateAsync(source.name);
} catch (e) {
+9 -4
View File
@@ -12,6 +12,7 @@ import {
useStoreJobs,
useUninstallPlugin,
} from "@/api/store";
import { useDialogs } from "@/components/dialogs";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
@@ -31,6 +32,7 @@ type StoreTab = "browse" | "installed" | "sources";
*/
export const SectionStore: FC = () => {
useLocale();
const { confirm } = useDialogs();
const [tab, setTab] = useState<StoreTab>("browse");
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
@@ -122,10 +124,13 @@ export const SectionStore: FC = () => {
};
const onUninstall = async (plugin: InstalledPlugin) => {
if (
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
)
return;
const ok = await confirm({
title: m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }),
description: m.store_uninstall_body(),
confirmLabel: m.store_uninstall(),
destructive: true,
});
if (!ok) return;
try {
const { job } = await uninstall.mutateAsync(plugin.pkg);
setJobId(job);
+84
View File
@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useState } from "react";
import { useDialogs } from "@/components/dialogs";
import { Button } from "@/components/ui/button";
import { m } from "@/paraglide/messages";
/**
* The console's confirm/prompt, in place of `window.confirm` / `window.prompt`.
*
* These replaced sixteen native calls. The native ones could not be storied at all a browser
* dialog is chrome, outside the page and outside any screenshot which is part of why they went
* unnoticed for so long in an otherwise fully-branded console.
*
* `open` fires the dialog on mount so the screenshot harness catches it without an interaction.
*/
const Demo = ({
open,
kind,
}: {
open: boolean;
kind: "destructive" | "plain" | "prompt";
}) => {
const { confirm, promptText } = useDialogs();
const [answer, setAnswer] = useState<string>("—");
const ask = async () => {
if (kind === "prompt") {
const name = await promptText({
title: m.display_preset_save_title(),
label: m.display_preset_name(),
defaultValue: "Couch (TV only)",
});
setAnswer(name ?? "cancelled");
return;
}
const ok = await confirm(
kind === "destructive"
? {
title: m.library_delete_confirm(),
description: m.library_delete_body(),
confirmLabel: m.library_delete(),
destructive: true,
}
: {
title: m.display_discard_confirm(),
confirmLabel: m.common_discard(),
},
);
setAnswer(String(ok));
};
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once, on mount, for the shot
useEffect(() => {
if (open) void ask();
}, [open]);
return (
<div className="space-y-4">
<Button onClick={ask}>Ask</Button>
<p className="text-sm text-muted-foreground">answered: {answer}</p>
</div>
);
};
// No `DialogsProvider` decorator here on purpose: `.storybook/preview.tsx` mounts it for every
// story, exactly as `__root` does for every route. A second one would work but would quietly render
// a second dialog host.
const meta = {
title: "UI/Dialogs",
component: Demo,
args: { open: true, kind: "destructive" },
} satisfies Meta<typeof Demo>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Anything that destroys or removes: red affirmative, and the consequence spelled out under it. */
export const Destructive: Story = {};
/** A plain choice — no red, because nothing is lost that the operator did not already choose. */
export const Plain: Story = { args: { kind: "plain" } };
/** The prompt: a real labelled field, autofocused, Enter to submit. */
export const Prompt: Story = { args: { kind: "prompt" } };
+95
View File
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
import { DisplayForm } from "@/sections/Displays/DisplayCard";
import {
displayCustomPresets,
displayEffective,
displayPolicy,
displayPresets,
} from "./lib/fixtures";
/**
* The **Virtual displays** policy form the console's largest configuration surface, and until now
* the only page with no story at all. That gap is why a real regression shipped unseen: the preset
* tiles are cards nested INSIDE the page's config card, so their motion parent is that card rather
* than the page's `<Section>`, and a card sets no `delayChildren` every tile landed on the same
* frame while every other grid in the console staggered. It is invisible in a diff and invisible to
* `tsc`; only a rendered page shows it.
*
* So the `<Card>` wrapper below is NOT decoration. It reproduces the page's motion nesting, which is
* the thing under test dropping it would make the story pass for the wrong reason.
*/
const Harness = ({ seed }: { seed: DisplayPolicy }) => {
const [draft, setDraft] = useState<DisplayPolicy>(seed);
return (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={displayPresets}
customPresets={displayCustomPresets}
serverEffective={displayEffective}
serverCaptureMonitor={() => null}
apply={setDraft}
applyAxis={(patch) => setDraft({ ...draft, ...patch })}
saveDraft={() => {}}
busy={false}
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
);
};
const meta = {
title: "Pages/Displays",
component: Harness,
args: { seed: displayPolicy },
} satisfies Meta<typeof Harness>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A host sitting on a built-in preset — the tiles, and the operator's saved bundles below them. */
export const Default: Story = {};
/** "Custom" reveals every axis by hand: the long form under the tiles. */
export const CustomFields: Story = {
args: { seed: { ...displayPolicy, preset: "custom" } },
};
/** A fresh host has saved no bundles of its own — the custom rail collapses to just "Save as". */
export const NoCustomPresets: Story = {
args: { seed: displayPolicy },
render: (args) => (
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DisplayForm
draft={args.seed}
setDraft={() => {}}
presets={displayPresets}
customPresets={[]}
serverEffective={displayEffective}
serverCaptureMonitor={() => null}
apply={() => {}}
applyAxis={() => {}}
saveDraft={() => {}}
busy={false}
dirty={false}
revert={() => {}}
/>
</CardContent>
</Card>
),
};
+71
View File
@@ -1,6 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
const meta = {
title: "UI/Inputs",
@@ -28,3 +37,65 @@ export const Form: Story = {
</div>
),
};
/**
* The non-text controls, side by side with an Input the comparison that matters, because the
* failure mode these wrappers exist to prevent is a control that looks borrowed from another app.
* A raw `<select>` / `<textarea>` / `<input type="checkbox">` renders in the browser's own chrome
* and ignores the brand tokens entirely; each of the three had survived that way somewhere in the
* console until this story existed to show them together.
*/
export const Controls: Story = {
render: () => (
<div className="max-w-sm space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ctl-event">Event</Label>
<Select defaultValue="session.started">
<SelectTrigger id="ctl-event">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session.started">session.started</SelectItem>
<SelectItem value="session.ended">session.ended</SelectItem>
<SelectItem value="game.running">game.running</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ctl-cmd">Command</Label>
<Input id="ctl-cmd" placeholder="/usr/local/bin/on-stream.sh" />
</div>
<div className="space-y-1.5">
<Label htmlFor="ctl-paths">Extra library folders</Label>
<Textarea
id="ctl-paths"
className="h-24 font-mono text-xs"
placeholder={"/mnt/games\n/mnt/roms"}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox id="ctl-launcher" defaultChecked />
<Label htmlFor="ctl-launcher">This entry opens a launcher</Label>
</div>
</div>
),
};
/** The select with its list open — the surface, the tick, and the hover highlight. */
export const SelectOpen: Story = {
render: () => (
<div className="max-w-sm">
<Select defaultValue="session.started" open>
<SelectTrigger aria-label="Event">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="client.connected">client.connected</SelectItem>
<SelectItem value="session.started">session.started</SelectItem>
<SelectItem value="session.ended">session.ended</SelectItem>
<SelectItem value="game.running">game.running</SelectItem>
</SelectContent>
</Select>
</div>
),
};
+96
View File
@@ -3,6 +3,9 @@
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { Capture } from "@/api/gen/model/capture";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
import type { CustomPreset } from "@/api/gen/model/customPreset";
import type { DisplayPolicy } from "@/api/gen/model/displayPolicy";
import type { EffectivePolicy } from "@/api/gen/model/effectivePolicy";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import type { NativeClient } from "@/api/gen/model/nativeClient";
@@ -297,3 +300,96 @@ export const nativeClients: NativeClient[] = [
];
export const pairingIdle: PairingStatus = { pin_pending: false };
/** The six axes a preset expands to — the baseline the built-ins vary from. */
const policyFields = (
over: Partial<EffectivePolicy> = {},
): EffectivePolicy => ({
identity: "per-client",
keep_alive: { mode: "duration", seconds: 300 },
layout: { mode: "auto-row", positions: {} },
max_displays: 4,
mode_conflict: "separate",
topology: "auto",
...over,
});
/**
* The built-in presets as `GET /display/settings` returns them. Summaries are the host's own prose
* (it composes them from the fields), so they are literal strings here rather than i18n messages.
*/
export const displayPresets: {
id: string;
summary: string;
fields: EffectivePolicy;
}[] = [
{
id: "default",
summary:
"A virtual display per client, released 5 minutes after it disconnects.",
fields: policyFields(),
},
{
id: "shared-desktop",
summary:
"Every client sees the same desktop — no extra displays are created.",
fields: policyFields({
identity: "shared",
topology: "primary",
mode_conflict: "join",
}),
},
{
id: "hotdesk",
summary:
"One display at a time; the physical heads go dark while you stream.",
fields: policyFields({
topology: "exclusive",
max_displays: 1,
keep_alive: { mode: "off" },
}),
},
{
id: "workstation",
summary: "Adds a display beside the monitors already on the desk.",
fields: policyFields({ topology: "extend" }),
},
{
id: "gaming-rig",
summary:
"Pins the display so it survives every disconnect — free it with Release.",
fields: policyFields({
topology: "exclusive",
keep_alive: { mode: "forever" },
}),
},
];
/** Two operator-saved bundles, so the custom-preset rail has something in it. */
export const displayCustomPresets: CustomPreset[] = [
{
id: "cp-couch",
name: "Couch (TV only)",
fields: policyFields({
topology: "exclusive",
max_displays: 1,
keep_alive: { mode: "forever" },
}),
game_session: "dedicated",
},
{
id: "cp-office",
name: "Office desk",
fields: policyFields({ topology: "extend", identity: "shared" }),
},
];
/** What the host reports as in force — the `default` preset's expansion. */
export const displayEffective: EffectivePolicy = policyFields();
/** The stored policy: a plain built-in pick, which is what most hosts sit on. */
export const displayPolicy: DisplayPolicy = {
preset: "default",
game_session: "auto",
version: 1,
};