Files
siftlode/frontend/src/components/DebugRecorder.tsx
T
peter 1833f594cb fix(design): an audit of the S3d claims — the float shadow, filter hints, one action size per row (E28 S3d)
- A glass role's shadow is one token (shadow-float); the header search's drop-shadow utility had
  replaced its top highlight, and the debug pill's shadow-lg did the same. The surface-role lint
  rule now refuses a shadow size beside a role.
- Plex: every value that filters carries a hint (genre, certificate, studio had none), and the facts
  panel's year and certificate filter like the header's. filterKeyword -> filterValue.
- Downloads: the row resized its buttons with a [&_button] override that the save link escaped
  (28px beside 32px); the size is the primitive's md rung now.
2026-09-25 01:16:20 +02:00

416 lines
16 KiB
TypeScript

import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Flag, Square } from "lucide-react";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import {
closeRecorderStart,
onSessionChangeElsewhere,
openComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
useRecorderStartOpen,
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The debug recorder (E16): Start → reproduce → Stop.
//
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
// app shell, for a signed-in account other than the demo.
/** The time since `since`, as the app's other clocks show it, re-rendered every second. */
function useElapsed(since: string): string {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/purity -- the clock is READ at render and the interval above only re-renders — a `now` held in state is the moment the component mounted, which is the defect DiagnosticsSettings records from prod UAT.
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
}
export default function DebugRecorder() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const { session } = useDebugRecording();
const startOpen = useRecorderStartOpen();
const composing = useComposerSession();
const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
// Started or stopped in another tab: this one asks the server what is in force now.
useEffect(
() => onSessionChangeElsewhere(() => void qc.invalidateQueries({ queryKey: qk.me() })),
[qc],
);
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
const until = session?.until;
const minutes = me?.diagnostics?.session_minutes;
useEffect(() => {
if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => {
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() });
}, wait);
return () => window.clearTimeout(timer);
}, [id, until, minutes, qc, t]);
return (
<>
{startOpen && <StartDialog />}
{composing && <IssueComposer id={composing} />}
</>
);
}
function StartDialog() {
const { t } = useTranslation();
const me = useMe();
const { start } = useDebugRecording();
const [level, setLevel] = useState<DebugSessionLevel>("DEBUG");
const days = me.diagnostics.retention_days;
const minutes = me.diagnostics.session_minutes;
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
return (
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
<div className="flex flex-col items-start gap-1.5">
<span className="text-sm text-muted">{t("recorder.start.level")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("recorder.start.level")}
options={levels.map((l) => ({
value: l,
label: t(`settings.diagnostics.levels.${l}`),
}))}
value={level}
onChange={setLevel}
/>
</div>
<div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
<p>{t(`recorder.start.what.${level}`)}</p>
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
<p className="text-muted">
{t("recorder.start.kept", { count: days })}{" "}
{t("recorder.start.cap", { count: minutes })}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeRecorderStart}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
loading={start.isPending}
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
onClick={() => start.mutate(level)}
>
{t("recorder.start.confirm")}
</Button>
</div>
</div>
</Modal>
);
}
/** The pill: how long it has been recording, and the three things to do while it does. */
function RecorderPill() {
const { t } = useTranslation();
const { session, stop, stopping, shooting, markStep, screenshot } = useDebugRecording();
const [hidden, setHidden] = useState(false);
const stepOpen = useStepPrompt() !== null;
const stepButton = useRef<HTMLButtonElement>(null);
const steps = useStepCount(session?.id ?? "");
if (!session) return null;
return (
<div
role="group"
aria-label={t("recorder.pill.label")}
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 ${
hidden ? "invisible" : ""
}`}
>
<span
aria-hidden
className="w-2.5 h-2.5 shrink-0 rounded-full bg-danger ring-4 ring-danger/20 motion-safe:animate-pulse"
/>
<Elapsed since={session.started_at} />
<span className="chip hidden sm:inline-flex px-2 py-0.5 text-2xs">
{t(`settings.diagnostics.levels.${session.level}`)}
</span>
<span aria-hidden className="hidden sm:block w-px h-6 bg-border" />
<Button
ref={stepButton}
variant="outline-accent"
size="sm"
className="rounded-full"
icon={<Flag className="w-4 h-4" />}
label={t("recorder.pill.step")}
aria-expanded={stepOpen}
onClick={() => setStepPrompt(stepOpen ? null : "")}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.step")}
{steps ? ` · ${steps}` : ""}
</span>
</Button>
{canCaptureTab() && (
<Button
variant="outline"
size="sm"
className="rounded-full"
icon={<Camera className="w-4 h-4" />}
label={t("recorder.pill.screenshot")}
loading={shooting}
onClick={() => void screenshot(setHidden)}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.screenshot")}
{session.screenshots ? ` · ${session.screenshots}` : ""}
</span>
</Button>
)}
<Button
variant="danger"
size="sm"
className="rounded-full"
icon={<Square className="w-3.5 h-3.5 fill-current" />}
label={t("recorder.pill.stop")}
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
</Button>
{stepOpen && (
<StepPrompt
id={session.id}
onDone={(label) => {
if (label !== null) markStep(label);
setStepPrompt(null);
stepButton.current?.focus();
}}
/>
)}
</div>
);
}
function Elapsed({ since }: { since: string }) {
const { t } = useTranslation();
const time = useElapsed(since);
return (
// On a phone only the clock: the pill has to fit the content column beside the rail, and the
// red dot already says what it is.
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
<span className="hidden sm:inline">{t("recorder.pill.recording", { time })}</span>
<span className="sm:hidden">{time}</span>
</span>
);
}
/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */
export function RecorderSettings() {
return (
<div className="flex flex-col gap-4">
<RecorderControl />
<SessionHistory />
</div>
);
}
function RecorderControl() {
const { t } = useTranslation();
const { session, stop, stopping } = useDebugRecording();
if (!session)
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted max-w-prose">{t("recorder.settings.hint")}</p>
<Button
variant="outline"
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
onClick={openRecorderStart}
>
{t("recorder.settings.start")}
</Button>
</div>
);
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3 py-2 text-sm">
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-danger" aria-hidden />
<SettingsElapsed since={session.started_at} />
</span>
<Button
variant="danger-soft"
size="sm"
loading={stopping}
onClick={() => stop.mutate(session.id)}
>
{t("recorder.settings.stop")}
</Button>
</div>
);
}
function SettingsElapsed({ since }: { since: string }) {
const { t } = useTranslation();
const time = useElapsed(since);
return <span className="tabular-nums">{t("recorder.settings.recording", { time })}</span>;
}
/** "What did you just do?" — optional; Enter marks the step, Escape leaves it unmarked. */
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
const { t } = useTranslation();
const n = useStepCount(id) + 1;
const label = useStepPrompt() ?? "";
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last. It
// OWNS the keyboard while it is up (an autofocused field does), so the page's own keys stay quiet.
// It does NOT take the Tab, though: it is a field inside whatever dialog the pill is docked in,
// not a dialog of its own, and the trap belongs to that one — it spans the prompt as it spans the
// rest of the toast host. Claiming it here switched the dialog's off and left every control
// OUTSIDE this little box untrapped (deep-review round 3).
useLayer(true, () => onDone(null), { trapsTab: false });
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
onDone(label);
}
};
return (
<div
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
>
<label htmlFor="debug-step-label" className="text-sm text-muted">
{t("recorder.pill.stepPrompt", { n })}
</label>
<Input
className="w-full"
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setStepPrompt(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted">
<span>{t("recorder.pill.stepHint")}</span>
<Button variant="primary" size="sm" onClick={() => onDone(label)}>
{t("recorder.pill.mark")}
</Button>
</div>
</div>
);
}
/** The five words a past session can read as. A union, not `string`, because `chipTone` below
* matches on these same keys: untyped, a rename in one of them left the other matching nothing
* and every chip untoned, with the gate green (deep-review round 4). */
type HistoryState =
| "recorder.history.sending"
| "recorder.history.draft"
| "recorder.history.unwritten"
| "recorder.history.partial"
| "recorder.history.sent";
/** What a past session reads as. An issue that still owes the tracker its attachments, or an edit
* that shows its screenshots, is NOT "Sent": this list is where a user goes back to check, and a
* green chip there is how a half-delivered report gets forgotten. */
function historyState(s: DebugSession): HistoryState {
// A send under way outranks every other word here: this is the list a user comes back to in
// order to check on one, and both "draft" (no issue yet) and "half sent" (the steps it has
// already committed) would describe a send that STOPPED.
if (s.sending) return "recorder.history.sending";
if (!s.issue_url) return s.draft ? "recorder.history.draft" : "recorder.history.unwritten";
return s.pending_files.length > 0 || s.images_pending
? "recorder.history.partial"
: "recorder.history.sent";
}
/** The colour that reinforces that word. Exhaustive over `HistoryState`, so a key renamed in
* `historyState` is a compile error here rather than a chip that quietly loses its tone. */
const chipTone = (s: DebugSession): string => {
switch (historyState(s)) {
case "recorder.history.sent":
return "text-success";
case "recorder.history.sending":
return "text-accent";
case "recorder.history.partial":
return "text-warn";
case "recorder.history.draft":
case "recorder.history.unwritten":
return "";
}
};
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() {
const { t, i18n } = useTranslation();
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data)
return (
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
{t("recorder.history.failed")}
</StateMessage>
);
if (!q.data) return <LoadingState />;
return (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-muted">{t("recorder.history.title")}</div>
{items.length === 0 ? (
<p className="text-sm text-muted">{t("recorder.history.empty")}</p>
) : (
<ul className="flex flex-col divide-y divide-border/60 rounded-xl border border-border">
{items.map((s) => (
<li
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">
{new Date(s.started_at).toLocaleString(i18n.language)}
</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
<span className={`chip px-2 py-0.5 text-2xs ${chipTone(s)}`}>
{t(historyState(s))}
</span>
<Button variant="outline" size="xs" onClick={() => openComposer(s.id)}>
{t("recorder.history.open")}
</Button>
</li>
))}
</ul>
)}
</div>
);
}