diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md
index 9bf6475b4..040bb9bc5 100644
--- a/app/features/scanner/README.md
+++ b/app/features/scanner/README.md
@@ -63,11 +63,12 @@ opens it, for anyone, through the same handoff Inspect uses.
`Matches` (`core/csv/matches.ts`, one row per game, the rows the cards
render) and `Raw detections` (`core/csv/events.ts`, one row per event).
Column names stay English keys.
-- **Debug gate** (`use-debug.ts`: DEV/ADMIN role or `?debug=true`):
- `Save frame as fixture`, `?telemetry=true`, and the `Raw detections`
- disclosure inside a match card (the per-event cards with Inspect). The
- screenshot view (`ScreenshotPage.tsx`) is not gated; the fixtures view is
- dev-only.
+- **Debug gate** (`use-debug.ts`: DEV/ADMIN role or `?debug=true`, which
+ Settings → Debug → `Enable debug` sets):
+ `Save frame as fixture` (Settings → Debug, live only), `?telemetry=true`,
+ and the `Raw detections` disclosure inside a match card (the per-event
+ cards with Inspect). The screenshot view (`ScreenshotPage.tsx`) is not
+ gated; the fixtures view is dev-only.
## Clips
@@ -188,7 +189,12 @@ sequenceDiagram
- `core/match-builder.ts` turns a timeline into `ScannerMatch`es: a MapStart
opens a match, a scoreboard closes one (claiming the last 8 min of deaths
when the intro was missed), minimaps group per map by confirmed stage
- change and >5 min gap. An event belongs to at most one match; deaths
+ change and >5 min gap. A battle history screen (battle log, replay
+ browser) showing a game already built — same scoreboard fingerprint (the
+ order-free paint/K+A/deaths/specials lines), recording time within 20 min
+ of its play time — joins that match's sources instead of forming a new
+ one, so browsing the log after playing neither adds a card nor re-uploads
+ (the match was already sent). An event belongs to at most one match; deaths
reveal enemy builds (`ability-harvest.ts`), the personal results screen
(`ScoreboardOwn`, seen within `OWN_RESULTS_WINDOW_SECONDS` of a closed
match's scoreboard) completes the POV player's full build, and minimap
diff --git a/app/features/scanner/components/LiveView.tsx b/app/features/scanner/components/LiveView.tsx
index 592a67d25..d557f1c86 100644
--- a/app/features/scanner/components/LiveView.tsx
+++ b/app/features/scanner/components/LiveView.tsx
@@ -3,7 +3,7 @@
* capture preview, the LIVE/IDLE status line, Stop — over the same
* SessionView. The capture itself lives in live-session.ts and outlives this view.
*/
-import { Camera, Square } from "lucide-react";
+import { Square } from "lucide-react";
import { SendouButton } from "~/components/elements/Button";
import { LocaleTime } from "~/components/LocaleTime";
import { useUser } from "~/features/auth/core/user";
@@ -27,7 +27,6 @@ import { matchContaining } from "./sendou-ingest";
import type { ScanEvent } from "./session-data";
import { useScannerSettings } from "./settings";
import { sendLive } from "./upload";
-import { useDebug } from "./use-debug";
/** a game is "being read" while its newest event is this fresh */
const READING_WINDOW_MS = 60_000;
@@ -39,7 +38,6 @@ export function LiveView() {
const settings = useScannerSettings();
const user = useUser();
const [, setParams] = useSearchParamsTyped(scannerSearchParams);
- const debug = useDebug();
const session = currentSession(feed);
const events = session?.events ?? [];
@@ -55,7 +53,7 @@ export function LiveView() {
newest.match.winner === null &&
Date.now() - (session?.endedAt ?? 0) < READING_WINDOW_MS;
const uploadNote = !user
- ? "Upload off · log in"
+ ? "Upload off (log in)"
: settings.upload
? "Upload on ✓"
: "Upload off";
@@ -99,7 +97,7 @@ export function LiveView() {
if (id !== undefined) void sendLive(matchContaining(id));
}}
getFrame={frameLoader}
- emptyText="Play a game — it shows up here as soon as its intro or results screen is read."
+ emptyText="Play a game — it shows up here once its results screen is read."
header={(info) => (
- {debug ? (
- }
- onClick={saveCurrentFrameAsFixture}
- >
- Save frame as fixture
-
- ) : null}
Stop
-
+
>
}
>
diff --git a/app/features/scanner/components/MatchCard.module.css b/app/features/scanner/components/MatchCard.module.css
index dbb40aac0..2d80ba3f1 100644
--- a/app/features/scanner/components/MatchCard.module.css
+++ b/app/features/scanner/components/MatchCard.module.css
@@ -82,8 +82,15 @@
gap: var(--s-2) var(--s-3);
}
+.numberColumn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--s-0-5);
+}
+
.number {
- font-size: var(--font-2xs);
+ font-size: 0.55rem;
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.08em;
@@ -91,6 +98,12 @@
white-space: nowrap;
}
+.scannedAt {
+ font-size: var(--font-2xs);
+ color: var(--color-text);
+ white-space: nowrap;
+}
+
.mode {
flex-shrink: 0;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx
index af13e03b2..958720159 100644
--- a/app/features/scanner/components/MatchCard.tsx
+++ b/app/features/scanner/components/MatchCard.tsx
@@ -14,6 +14,7 @@ import { Ability } from "~/components/Ability";
import { SendouButton } from "~/components/elements/Button";
import { GameTimeline } from "~/components/GameTimeline";
import { ModeImage, WeaponImage } from "~/components/Image";
+import { LocaleTime } from "~/components/LocaleTime";
import { matchScoresFromObjective } from "~/components/objective-timeline-utils";
import { StageBannerBox } from "~/components/StageBannerBox";
import { abilities as ALL_ABILITIES } from "~/modules/in-game-lists/abilities";
@@ -121,6 +122,9 @@ export function MatchCard({
const result = matchResult(match);
const pov = povPlayer(match);
const matchOrigin = timelineOrigin(match);
+ const scannedAt = built.sources.find(
+ (event) => event.detectedAt !== undefined,
+ )?.detectedAt;
const meta = [
kind === "vod" && match.startsAt !== null
? `at ${formatPosition(match.startsAt - originT)}`
@@ -143,7 +147,16 @@ export function MatchCard({
const head = (
{match.mode !== null ? (
) : null}
diff --git a/app/features/scanner/components/SettingsPopover.tsx b/app/features/scanner/components/SettingsPopover.tsx
index a4dd4dda3..0e1127d91 100644
--- a/app/features/scanner/components/SettingsPopover.tsx
+++ b/app/features/scanner/components/SettingsPopover.tsx
@@ -1,10 +1,10 @@
/**
* The settings popover, opened from ⚙ on the landing and the live header:
- * the upload and clip toggles, the retention notes, and in development the
- * fixtures link. The source lives on the landing's Live card, the one place
+ * the upload and clip toggles, the retention notes, and the debug tools
+ * (enabling debug mode, saving the live frame, the fixtures link in development). The source lives on the landing's Live card, the one place
* it must be right.
*/
-import { Settings } from "lucide-react";
+import { Bug, Camera, Settings } from "lucide-react";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import {
@@ -13,6 +13,7 @@ import {
} from "~/components/elements/ChipRadio";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSwitch } from "~/components/elements/Switch";
+import { useSearchParam } from "~/modules/search-params/hooks";
import { SCANNER_PAGE } from "~/utils/urls";
import { MAX_SESSIONS } from "../core/sessions";
import { scannerSearchParams } from "../scanner-search-params";
@@ -25,13 +26,23 @@ import {
useScannerSettings,
} from "./settings";
import { isLoggedIn } from "./upload";
+import { useDebug } from "./use-debug";
/** a step of one frame-ish: fine enough to tune by ear, coarse enough to reach a second in a few clicks */
const AUDIO_OFFSET_STEP_MS = 25;
-export function SettingsPopover() {
+export function SettingsPopover({
+ onSaveFrame,
+}: {
+ /** set while capturing: downloads the current live frame */
+ onSaveFrame?: () => void;
+}) {
const settings = useScannerSettings();
const loggedIn = isLoggedIn();
+ const debug = useDebug();
+ const [, setDebugParam] = useSearchParam(scannerSearchParams, "debug");
+ const showFixturesLink = process.env.NODE_ENV === "development";
+ const showSaveFrame = debug && onSaveFrame !== undefined;
return (
Sessions: last 30 days or {MAX_SESSIONS} sessions.
- {process.env.NODE_ENV === "development" ? (
+ {!debug || showSaveFrame || showFixturesLink ? (
Debug