fix(web): clearer topology/identity copy, capped description width, mobile More-nav, preset spacing
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m18s
windows-host / package (push) Successful in 7m38s
android / android (push) Successful in 12m11s
arch / build-publish (push) Successful in 9m7s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 4m47s
ci / bench (push) Successful in 5m3s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish (push) Successful in 4m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m28s
docker / deploy-docs (push) Successful in 20s
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m18s
windows-host / package (push) Successful in 7m38s
android / android (push) Successful in 12m11s
arch / build-publish (push) Successful in 9m7s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 4m47s
ci / bench (push) Successful in 5m3s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish (push) Successful in 4m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m28s
docker / deploy-docs (push) Successful in 20s
Console polish on the Virtual displays card + shell: - Topology help now leads with the streamed display's role (Extend/Primary/Exclusive) instead of the confusing physical-monitor-only framing; notes the headless case. Identity help spells out the actual behavior (stable per-client identity → the desktop reapplies that client's scaling/resolution on reconnect) + what Shared / Per-client / Per-client+resolution each do. - Cap description/help width at max-w-prose so long help text isn't a full-viewport line on large screens. - Mobile bottom nav: 8 flat tabs were too cramped → 4 pinned tabs + a "More" tab whose sheet holds the rest (Performance/Logs/Pairing/Settings), "More" highlighted when the active route is in the overflow. - More breathing room under the "Preset" heading. web tsc + biome + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"nav_pairing": "Kopplung",
|
||||
"nav_library": "Bibliothek",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_more": "Mehr",
|
||||
"status_title": "Live-Status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
@@ -87,7 +88,7 @@
|
||||
"display_preset_current": "Aktiv",
|
||||
"display_preset_soon": "in Kürze",
|
||||
"display_keep_alive_help": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.",
|
||||
"display_topology_help": "Was mit den physischen Monitoren des Hosts während des Streamings geschieht.",
|
||||
"display_topology_help": "Wie sich die gestreamte Anzeige in den Desktop des Hosts einfügt. Erweitern: ein zusätzlicher Bildschirm neben deinen Monitoren. Primär: die gestreamte Anzeige wird der primäre Ausgang, deine Monitore bleiben. Exklusiv: die gestreamte Anzeige ist der einzige Ausgang — physische Monitore werden beim Streamen deaktiviert und danach wiederhergestellt. Auf einem Host ohne Monitor legt es nur fest, ob die gestreamte Anzeige primär ist.",
|
||||
"display_conflict": "Wenn ein weiterer Client verbindet",
|
||||
"display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.",
|
||||
"display_conflict_separate": "Eigene Anzeige",
|
||||
@@ -95,7 +96,7 @@
|
||||
"display_conflict_join": "Ansicht teilen",
|
||||
"display_conflict_reject": "Besetzt — ablehnen",
|
||||
"display_identity": "Client-Identität",
|
||||
"display_identity_help": "Jedem Client eine stabile Anzeige geben, damit der Desktop seine Monitor-Einstellungen merkt (z. B. Skalierung).",
|
||||
"display_identity_help": "Ob die gestreamte Anzeige eine stabile Client-Identität trägt, sodass der Desktop des Hosts die Monitor-Einstellungen dieses Clients (Skalierung, Auflösung) merkt und beim erneuten Verbinden wieder anwendet. Geteilt: eine Identität für alle. Pro Client: jedes Gerät eigene. Pro Client + Auflösung: separate Einstellungen je Gerät und Auflösung.",
|
||||
"display_identity_shared": "Geteilt",
|
||||
"display_identity_per_client": "Pro Client",
|
||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"nav_pairing": "Pairing",
|
||||
"nav_library": "Library",
|
||||
"nav_settings": "Settings",
|
||||
"nav_more": "More",
|
||||
"status_title": "Live status",
|
||||
"status_video": "Video",
|
||||
"status_audio": "Audio",
|
||||
@@ -87,7 +88,7 @@
|
||||
"display_preset_current": "Active",
|
||||
"display_preset_soon": "coming soon",
|
||||
"display_keep_alive_help": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.",
|
||||
"display_topology_help": "What happens to the host's physical monitors while streaming.",
|
||||
"display_topology_help": "How the streamed display fits into the host's desktop. Extend: an extra screen alongside your monitors. Primary: the streamed display becomes the primary output, your monitors kept. Exclusive: the streamed display is the sole output — physical monitors are disabled while streaming and restored after. On a headless host it just sets whether the streamed display is the primary.",
|
||||
"display_conflict": "When another client connects",
|
||||
"display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.",
|
||||
"display_conflict_separate": "Own display",
|
||||
@@ -95,7 +96,7 @@
|
||||
"display_conflict_join": "Share view",
|
||||
"display_conflict_reject": "Busy — reject",
|
||||
"display_identity": "Per-client identity",
|
||||
"display_identity_help": "Give each client a stable display so the desktop remembers its per-monitor settings (e.g. scaling).",
|
||||
"display_identity_help": "Whether the streamed display carries a stable per-client identity, so the host's desktop remembers that client's per-monitor settings (scaling, resolution) and reapplies them when it reconnects. Shared: one identity for everyone. Per client: each device keeps its own. Per client + resolution: a device keeps separate settings per resolution it connects at.",
|
||||
"display_identity_shared": "Shared",
|
||||
"display_identity_per_client": "Per client",
|
||||
"display_identity_per_client_mode": "Per client + resolution",
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import {
|
||||
Activity,
|
||||
GaugeCircle,
|
||||
KeyRound,
|
||||
LibraryBig,
|
||||
MonitorPlay,
|
||||
MoreHorizontal,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { Wordmark } from "@/components/wordmark";
|
||||
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
||||
@@ -30,6 +31,11 @@ const NAV = [
|
||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||
] as const;
|
||||
|
||||
// On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a
|
||||
// "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More".
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
||||
const MOBILE_OVERFLOW = NAV.slice(4);
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
// Read the locale so the whole shell re-renders on a language switch.
|
||||
useLocale();
|
||||
@@ -108,29 +114,88 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom tab bar (< sm): the primary navigation on phones. */}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */
|
||||
function MobileNav() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
// Highlight "More" when the current route lives in the overflow.
|
||||
const overflowActive = MOBILE_OVERFLOW.some(
|
||||
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`),
|
||||
);
|
||||
// Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon
|
||||
// at the same height.
|
||||
const tab =
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors";
|
||||
const lbl =
|
||||
"flex h-7 w-full items-center justify-center text-center text-[10px] leading-tight";
|
||||
return (
|
||||
<>
|
||||
{/* Tap-outside backdrop, under the bar (z-50) but over the page. */}
|
||||
{moreOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-40 bg-black/40 sm:hidden"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card/95 backdrop-blur sm:hidden"
|
||||
className="fixed inset-x-0 bottom-0 z-50 sm:hidden"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||
>
|
||||
{NAV.map(({ to, icon: Icon, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
className="flex flex-1 flex-col items-center justify-center gap-1 px-0.5 py-2 text-muted-foreground transition-colors"
|
||||
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||
{/* The "More" sheet sits directly above the bar (bottom-full of the fixed nav). */}
|
||||
{moreOpen && (
|
||||
<div className="absolute inset-x-0 bottom-full border-t bg-card/95 backdrop-blur">
|
||||
<div className="grid grid-cols-4 gap-1 p-2">
|
||||
{MOBILE_OVERFLOW.map(({ to, icon: Icon, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
className={cn(tab, "rounded-md")}
|
||||
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
<span className={lbl}>{label()}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex border-t bg-card/95 backdrop-blur">
|
||||
{MOBILE_PRIMARY.map(({ to, icon: Icon, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
className={tab}
|
||||
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
<span className={lbl}>{label()}</span>
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen((o) => !o)}
|
||||
aria-expanded={moreOpen}
|
||||
className={cn(
|
||||
tab,
|
||||
(moreOpen || overflowActive) && "text-[var(--brand-light)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
{/* Fixed two-line-tall box so a 1- or 2-line label keeps every icon
|
||||
at the same height (the labels vary by locale). */}
|
||||
<span className="flex h-7 w-full items-center justify-center text-center text-[10px] leading-tight">
|
||||
{label()}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
<MoreHorizontal className="size-5 shrink-0" />
|
||||
<span className={lbl}>{m.nav_more()}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export const DisplaySection: FC = () => {
|
||||
<CardTitle>{m.display_config_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
|
||||
<p className="max-w-prose text-sm text-muted-foreground">{m.host_displays_help()}</p>
|
||||
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
|
||||
{q.data && draft && (
|
||||
<DisplayForm
|
||||
@@ -154,8 +154,8 @@ const DisplayForm: FC<{
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* One-click presets — a 2-up grid so each has room to breathe */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold">{m.display_preset()}</Label>
|
||||
<div className="space-y-4">
|
||||
<Label className="mb-1 block text-base font-semibold">{m.display_preset()}</Label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{PRESET_ORDER.map((id) => {
|
||||
const p = presets.find((x) => x.id === id);
|
||||
@@ -341,7 +341,7 @@ const DisplayForm: FC<{
|
||||
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||||
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||||
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -357,7 +357,7 @@ const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
|
||||
<div className="space-y-3">
|
||||
<Label className="block">{label}</Label>
|
||||
{children}
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
{help && <p className="max-w-prose text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user