Files
punktfunk/web/src/sections/Dashboard/view.tsx
T
enricobuehler 0b252403cd
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
fix(web): fix the card inset at the root, not at the call sites
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.
2026-08-09 17:23:09 +02:00

281 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);