feat(web): move Virtual displays to its own nav section; roomier preset grid

The Host page was crowded (identity, codecs, ports, GPU, displays, compositors) and the
virtual-display config surface is large enough to warrant its own home.

- New **Virtual displays** nav section: `/displays` route + `sections/Displays` (moved
  DisplayCard out of `sections/Host`), a `MonitorPlay` sidebar entry after Host, and
  `nav_displays` i18n. Removed the displays card from the Host page/view.
- On its own page the card splits into two: **Configuration** (presets + custom axes) and
  **Live displays** (the live list + arrangement table) — room to breathe.
- Presets now render in a max-2-column grid (`sm:grid-cols-2`) with larger padding, a bigger
  section heading + preset titles (text-base semibold), roomier spacing, and bottom-aligned
  "what it sets" badges so the cards line up.

web tsc + vite build + biome-lint green; deployed + verified on the Mutter box (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 14:54:24 +00:00
parent 8986667b78
commit 6b4f9f86ed
8 changed files with 82 additions and 50 deletions
+2
View File
@@ -4,6 +4,7 @@
"app_tagline": "Verwaltungskonsole", "app_tagline": "Verwaltungskonsole",
"nav_dashboard": "Übersicht", "nav_dashboard": "Übersicht",
"nav_host": "Host", "nav_host": "Host",
"nav_displays": "Virtuelle Anzeigen",
"nav_clients": "Gekoppelte Geräte", "nav_clients": "Gekoppelte Geräte",
"nav_pairing": "Kopplung", "nav_pairing": "Kopplung",
"nav_library": "Bibliothek", "nav_library": "Bibliothek",
@@ -49,6 +50,7 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.", "gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"host_displays": "Virtuelle Displays", "host_displays": "Virtuelle Displays",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.", "host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_config_title": "Konfiguration",
"display_preset": "Voreinstellung", "display_preset": "Voreinstellung",
"display_preset_custom": "Benutzerdefiniert", "display_preset_custom": "Benutzerdefiniert",
"display_preset_default": "Standard", "display_preset_default": "Standard",
+2
View File
@@ -4,6 +4,7 @@
"app_tagline": "management console", "app_tagline": "management console",
"nav_dashboard": "Dashboard", "nav_dashboard": "Dashboard",
"nav_host": "Host", "nav_host": "Host",
"nav_displays": "Virtual displays",
"nav_clients": "Paired clients", "nav_clients": "Paired clients",
"nav_pairing": "Pairing", "nav_pairing": "Pairing",
"nav_library": "Library", "nav_library": "Library",
@@ -49,6 +50,7 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.", "gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"host_displays": "Virtual displays", "host_displays": "Virtual displays",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.", "host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_config_title": "Configuration",
"display_preset": "Preset", "display_preset": "Preset",
"display_preset_custom": "Custom", "display_preset_custom": "Custom",
"display_preset_default": "Default", "display_preset_default": "Default",
+2
View File
@@ -4,6 +4,7 @@ import {
GaugeCircle, GaugeCircle,
KeyRound, KeyRound,
LibraryBig, LibraryBig,
MonitorPlay,
ScrollText, ScrollText,
Server, Server,
Settings, Settings,
@@ -21,6 +22,7 @@ const MLink = motion(Link);
const NAV = [ const NAV = [
{ to: "/", icon: Activity, label: () => m.nav_dashboard() }, { to: "/", icon: Activity, label: () => m.nav_dashboard() },
{ to: "/host", icon: Server, label: () => m.nav_host() }, { to: "/host", icon: Server, label: () => m.nav_host() },
{ to: "/displays", icon: MonitorPlay, label: () => m.nav_displays() },
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() }, { to: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionDisplays } from "@/sections/Displays";
export const Route = createFileRoute("/displays")({ component: SectionDisplays });
@@ -60,9 +60,10 @@ export const DisplaySection: FC = () => {
); );
return ( return (
<div className="flex flex-col gap-card">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{m.host_displays()}</CardTitle> <CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p> <p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
@@ -78,9 +79,17 @@ export const DisplaySection: FC = () => {
/> />
)} )}
</QueryState> </QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.display_live()}</CardTitle>
</CardHeader>
<CardContent>
<LiveDisplays /> <LiveDisplays />
</CardContent> </CardContent>
</Card> </Card>
</div>
); );
}; };
@@ -142,10 +151,10 @@ const DisplayForm: FC<{
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* One-click presets */} {/* One-click presets — a 2-up grid so each has room to breathe */}
<div className="space-y-2"> <div className="space-y-3">
<Label>{m.display_preset()}</Label> <Label className="text-base font-semibold">{m.display_preset()}</Label>
<div className="grid gap-2"> <div className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => { {PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id); const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields; const fields = id === "custom" ? undefined : p?.fields;
@@ -153,8 +162,10 @@ const DisplayForm: FC<{
const selected = preset === id; const selected = preset === id;
const soon = DISABLED_PRESETS.has(id); const soon = DISABLED_PRESETS.has(id);
const cls = [ const cls = [
"w-full rounded-md border p-3 text-left transition-colors", "flex h-full flex-col rounded-lg border p-4 text-left transition-colors",
selected ? "border-primary ring-1 ring-primary" : "hover:bg-muted/50", selected
? "border-primary ring-1 ring-primary"
: "hover:border-primary/40 hover:bg-muted/50",
soon ? "opacity-60" : "", soon ? "opacity-60" : "",
].join(" "); ].join(" ");
return ( return (
@@ -166,7 +177,7 @@ const DisplayForm: FC<{
className={cls} className={cls}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium"> <span className="text-base font-semibold">
{(PRESET_LABEL[id] ?? (() => id))()} {(PRESET_LABEL[id] ?? (() => id))()}
{soon && ( {soon && (
<span className="ml-2 text-xs font-normal text-muted-foreground"> <span className="ml-2 text-xs font-normal text-muted-foreground">
@@ -178,9 +189,11 @@ const DisplayForm: FC<{
<Badge variant="success">{m.display_preset_current()}</Badge> <Badge variant="success">{m.display_preset_current()}</Badge>
)} )}
</div> </div>
{summary && <p className="mt-0.5 text-xs text-muted-foreground">{summary}</p>} {summary && (
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
)}
{fields && ( {fields && (
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge> <Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge> <Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge> <Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge>
@@ -361,10 +374,9 @@ const LiveDisplays: FC = () => {
); );
return ( return (
<div className="space-y-2 border-t pt-4"> <div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h4 className="text-sm font-medium">{m.display_live()}</h4>
{kept.length > 0 && ( {kept.length > 0 && (
<div className="flex justify-end">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -373,8 +385,8 @@ const LiveDisplays: FC = () => {
> >
{m.display_release_all()} {m.display_release_all()}
</Button> </Button>
)}
</div> </div>
)}
{displays.length === 0 ? ( {displays.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p> <p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
) : ( ) : (
+22
View File
@@ -0,0 +1,22 @@
import Section from "@unom/ui/section";
import type { FC } from "react";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { DisplaySection } from "./DisplayCard";
/**
* The **Virtual displays** page (design/display-management.md): the host's virtual-display policy
* (presets + every axis) plus the live-display list + multi-monitor arrangement. Its own nav
* section — the config surface is large enough to warrant the room, and it kept the Host page busy.
*/
export const SectionDisplays: FC = () => {
useLocale();
return (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection />
</div>
</Section>
);
};
+1 -9
View File
@@ -1,7 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host"; import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n"; import { useLocale } from "@/lib/i18n";
import { DisplaySection } from "./DisplayCard";
import { GpuSection } from "./GpuCard"; import { GpuSection } from "./GpuCard";
import { HostView } from "./view"; import { HostView } from "./view";
@@ -10,12 +9,5 @@ export const SectionHost: FC = () => {
const host = useGetHostInfo(); const host = useGetHostInfo();
const compositors = useListCompositors(); const compositors = useListCompositors();
return ( return <HostView host={host} compositors={compositors} gpu={<GpuSection />} />;
<HostView
host={host}
compositors={compositors}
gpu={<GpuSection />}
displays={<DisplaySection />}
/>
);
}; };
+1 -5
View File
@@ -13,9 +13,7 @@ export const HostView: FC<{
compositors: Loadable<AvailableCompositor[]>; compositors: Loadable<AvailableCompositor[]>;
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */ /** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
gpu?: ReactNode; gpu?: ReactNode;
/** The virtual-display management card (self-contained container — see `DisplayCard.tsx`). */ }> = ({ host, compositors, gpu }) => {
displays?: ReactNode;
}> = ({ host, compositors, gpu, displays }) => {
const h = host.data; const h = host.data;
return ( return (
<Section maxWidth={false}> <Section maxWidth={false}>
@@ -83,8 +81,6 @@ export const HostView: FC<{
{gpu} {gpu}
{displays}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle> <CardTitle>{m.host_compositors()}</CardTitle>