ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m35s
ci / web (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 9m12s
nix / flake (pull_request) Failing after 19m50s
The broken inset on the Displays configuration card was the symptom. The cause is structural, and it had already been diagnosed at least twice in-tree without being fixed. Two faults, both in components/ui/card.tsx: 1. The padding was a RESPONSIVE COMPOUND: `p-4 pt-0 sm:p-6 sm:pt-0`. tailwind-merge resolves conflicts only within a variant, so any call-site override won at the base and lost at `sm:` — correct on a phone, wrong on every desktop. Measured on the Displays card before this change: padding-top 24px at 500px, 0px at 1440px. 2. `pt-0` encoded an assumption about a SIBLING that nothing enforced — "a CardHeader is above me and supplies the top inset". Delete the header, which is exactly what tabbing a page does since the tab label replaces the card title, and the top inset silently vanishes at ≥640px. Fix: - One single-variant utility, `p-padding-card` — the same `--spacing-padding-card` token @unom/ui's own Card uses, so nested cards finally agree on their inset. A single variant cannot half-lose an override. - Top inset is now self-correcting: `[&:not(:first-child)]:pt-0`. Ask the DOM instead of the author. A headerless CardContent keeps its inset with nothing to remember. Seven call sites had grown their own compensation in five dialects — `p-6`, `p-card pt-card sm:pt-card` (×3), `p-4 sm:pt-6` (×3), `pt-4 sm:pt-6`, and my own `pt-6` from the tabs commit. All removed; they are the symptom-fixes this replaces. LogsCard even carried a six-line comment correctly describing the trap and working around it locally — that comment is now three lines saying it no longer needs saying. `flush` stays: full-bleed content is a real intent, expressed as a prop the component honours rather than a utility that has to out-argue the one already there. Guarded by UI/Card → "Inset with and without header", a headered/headerless pair that has to look identical on every side. It must be checked at BOTH widths — a single width cannot show this class of bug, which is why it kept surviving. Verified by measuring computed padding at 500px and 1440px: first child 20px on all four sides, after-a-header 0px top and 20px elsewhere, identical at both widths. tsc clean, biome clean on every touched file, 9/9 server tests, build + i18n clean, 32/32 screenshots.
281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
import Section from "@unom/ui/section";
|
||
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
|
||
import type { FC, ReactNode } from "react";
|
||
import type { ActiveGame } from "@/api/gen/model/activeGame";
|
||
import type { AudioWiring } from "@/api/gen/model/audioWiring";
|
||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
|
||
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 { fmtNumber } from "@/lib/format";
|
||
import type { Loadable } from "@/lib/query";
|
||
import { m } from "@/paraglide/messages";
|
||
import { ActivityCard } from "./Activity";
|
||
import { RunningGames } from "./RunningGames";
|
||
|
||
export const DashboardView: FC<{
|
||
status: Loadable<RuntimeStatus>;
|
||
library?: GameEntry[];
|
||
onStopSession: () => void;
|
||
onRequestIdr: () => void;
|
||
onEndGame: (game: ActiveGame) => void;
|
||
isStopping: boolean;
|
||
isRequestingIdr: boolean;
|
||
isEndingGame: boolean;
|
||
}> = ({
|
||
status,
|
||
library,
|
||
onStopSession,
|
||
onRequestIdr,
|
||
onEndGame,
|
||
isStopping,
|
||
isRequestingIdr,
|
||
isEndingGame,
|
||
}) => {
|
||
const s = status.data;
|
||
return (
|
||
<Section maxWidth={false}>
|
||
<div className="flex flex-col gap-card">
|
||
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
|
||
<QueryState
|
||
isLoading={status.isLoading}
|
||
error={status.error}
|
||
refetch={status.refetch}
|
||
>
|
||
{s && (
|
||
<div className="flex flex-col gap-card">
|
||
<div className="grid gap-card sm:grid-cols-2 lg:grid-cols-4">
|
||
<StatCard
|
||
icon={<Video className="size-4" />}
|
||
label={m.status_video()}
|
||
on={s.video_streaming}
|
||
/>
|
||
<StatCard
|
||
icon={<Volume2 className="size-4" />}
|
||
label={m.status_audio()}
|
||
on={s.audio_streaming}
|
||
/>
|
||
{/* Both planes. GameStream and native (punktfunk/1) devices pair
|
||
into SEPARATE stores, and native is the DEFAULT one — counting
|
||
only the GameStream certs read as "0 paired" on a host every
|
||
one of whose clients was in fact paired. */}
|
||
<Card>
|
||
<CardContent className="flex flex-1 items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">
|
||
{m.status_paired_count()}
|
||
</span>
|
||
<span className="text-2xl font-semibold tabular-nums">
|
||
{s.paired_clients + s.native_paired_clients}
|
||
</span>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="flex flex-1 items-center justify-between">
|
||
<span className="text-sm text-muted-foreground">
|
||
{m.status_pin_pending()}
|
||
</span>
|
||
{/* The whole value used to be "●" or "—": no text, no state, colour
|
||
doing all the work — nothing for a screen reader to read out and
|
||
nothing for anyone who can't tell the two badges apart. */}
|
||
<Badge variant={s.pin_pending ? "default" : "outline"}>
|
||
{s.pin_pending
|
||
? m.status_pin_waiting()
|
||
: m.status_pin_none()}
|
||
</Badge>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* The wiring verdict (Windows hosts): WHICH endpoints carry game
|
||
audio and the microphone, and the degradations that used to be
|
||
visible only in the host log — a silent host looks identical to a
|
||
quiet game without this. */}
|
||
{s.audio && <AudioWiringCard audio={s.audio} />}
|
||
|
||
{/* Above the session card: a game the host is about to close is the most
|
||
time-sensitive thing on this page. */}
|
||
<RunningGames
|
||
games={s.games}
|
||
library={library}
|
||
onEnd={onEndGame}
|
||
isEnding={isEndingGame}
|
||
/>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-col items-start gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
||
<CardTitle className="flex items-center gap-2">
|
||
<MonitorPlay className="size-4" />
|
||
{m.status_session()}
|
||
{s.active_sessions > 1 && (
|
||
<Badge variant="secondary">
|
||
{m.status_sessions_active({ count: s.active_sessions })}
|
||
</Badge>
|
||
)}
|
||
</CardTitle>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!s.video_streaming || isRequestingIdr}
|
||
onClick={onRequestIdr}
|
||
>
|
||
<RefreshCw className="size-3.5" />
|
||
{m.action_request_idr()}
|
||
</Button>
|
||
<Button
|
||
variant={s.session ? "destructive" : "secondary"}
|
||
size="sm"
|
||
disabled={!s.session || isStopping}
|
||
onClick={onStopSession}
|
||
>
|
||
<ZapOff className="size-3.5" />
|
||
{m.action_stop_session()}
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{s.stream ? (
|
||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
|
||
<Field
|
||
label={m.stream_codec()}
|
||
value={s.stream.codec.toUpperCase()}
|
||
/>
|
||
<Field
|
||
label={m.stream_resolution()}
|
||
value={`${s.stream.width}×${s.stream.height}`}
|
||
/>
|
||
<Field
|
||
label={m.stream_fps()}
|
||
value={`${s.stream.fps} fps`}
|
||
/>
|
||
<Field
|
||
label={m.stream_bitrate()}
|
||
value={`${fmtNumber(s.stream.bitrate_kbps / 1000, 1)} Mbps`}
|
||
/>
|
||
{/* Bring-up and reconfigure cost, the parity floor and the packet
|
||
size: the host has reported all four for as long as this
|
||
endpoint has existed and the console showed none of them, so
|
||
"it takes ages to start" and "it hitches when I resize" had no
|
||
number attached anywhere. Native-plane only — null on
|
||
GameStream and null until the first frame lands, so the two
|
||
timings appear only once they mean something. */}
|
||
{s.stream.time_to_first_frame_ms != null && (
|
||
<Field
|
||
label={m.stream_first_frame()}
|
||
value={`${fmtNumber(s.stream.time_to_first_frame_ms)} ms`}
|
||
/>
|
||
)}
|
||
{s.stream.last_resize_ms != null && (
|
||
<Field
|
||
label={m.stream_last_resize()}
|
||
value={`${fmtNumber(s.stream.last_resize_ms)} ms`}
|
||
/>
|
||
)}
|
||
<Field
|
||
label={m.stream_packet_size()}
|
||
value={`${fmtNumber(s.stream.packet_size)} B`}
|
||
/>
|
||
<Field
|
||
label={m.stream_min_fec()}
|
||
value={fmtNumber(s.stream.min_fec)}
|
||
/>
|
||
</dl>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
{m.status_no_session()}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Below the session card: the past, under the present. */}
|
||
<ActivityCard />
|
||
</div>
|
||
)}
|
||
</QueryState>
|
||
</div>
|
||
</Section>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* One line per role plus a readiness badge; the degradation notes are spelled out because the
|
||
* failure they describe (silent audio, a mic that quietly vanished) is invisible everywhere
|
||
* else except the host log.
|
||
*/
|
||
const AudioWiringCard: FC<{ audio: AudioWiring }> = ({ audio }) => {
|
||
const badge: {
|
||
variant: "success" | "secondary" | "destructive";
|
||
text: string;
|
||
} =
|
||
audio.readiness === "full"
|
||
? { variant: "success", text: m.audio_ready() }
|
||
: audio.readiness === "audio_only"
|
||
? { variant: "secondary", text: m.audio_ready_no_mic() }
|
||
: audio.readiness === "mic_only"
|
||
? { variant: "destructive", text: m.audio_no_output() }
|
||
: { variant: "destructive", text: m.audio_none() };
|
||
const notes = [
|
||
audio.mic_withheld ? m.audio_mic_withheld() : undefined,
|
||
audio.last_resort ? m.audio_last_resort() : undefined,
|
||
audio.narrowing,
|
||
].filter((n): n is string => !!n);
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Volume2 className="size-4" />
|
||
{m.audio_wiring_title()}
|
||
</CardTitle>
|
||
<Badge variant={badge.variant}>{badge.text}</Badge>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-3">
|
||
<dl className="grid gap-4 sm:grid-cols-2">
|
||
<Field
|
||
label={m.audio_output()}
|
||
value={audio.loopback ?? m.audio_unavailable()}
|
||
/>
|
||
<Field
|
||
label={m.audio_microphone()}
|
||
value={audio.mic ?? m.audio_unavailable()}
|
||
/>
|
||
</dl>
|
||
{notes.length > 0 && (
|
||
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||
{notes.map((n) => (
|
||
<li key={n}>{n}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
const StatCard: FC<{ icon: ReactNode; label: string; on: boolean }> = ({
|
||
icon,
|
||
label,
|
||
on,
|
||
}) => (
|
||
<Card>
|
||
<CardContent className="flex flex-1 items-center justify-between">
|
||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
{icon}
|
||
{label}
|
||
</span>
|
||
<Badge variant={on ? "success" : "outline"}>
|
||
{on ? m.status_streaming() : m.status_idle()}
|
||
</Badge>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
|
||
const Field: FC<{ label: string; value: string }> = ({ label, value }) => (
|
||
<div>
|
||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||
<dd className="mt-0.5 font-medium tabular-nums">{value}</dd>
|
||
</div>
|
||
);
|