diff --git a/app/components/StageBannerBox.module.css b/app/components/StageBannerBox.module.css
new file mode 100644
index 000000000..f369fb285
--- /dev/null
+++ b/app/components/StageBannerBox.module.css
@@ -0,0 +1,13 @@
+.banner {
+ background-image:
+ linear-gradient(
+ to right,
+ var(--stage-banner-fade, var(--color-bg-high)) 35%,
+ transparent 80%
+ ),
+ var(--stage-banner);
+ background-origin: border-box;
+ background-position: right center;
+ background-size: cover;
+ background-repeat: no-repeat;
+}
diff --git a/app/components/StageBannerBox.tsx b/app/components/StageBannerBox.tsx
new file mode 100644
index 000000000..8c5e33aeb
--- /dev/null
+++ b/app/components/StageBannerBox.tsx
@@ -0,0 +1,33 @@
+import clsx from "clsx";
+import type * as React from "react";
+import type { StageId } from "~/modules/in-game-lists/types";
+import { stageBannerImageUrl } from "~/utils/urls";
+import styles from "./StageBannerBox.module.css";
+
+/**
+ * Box with a stage banner image fading in from the right. The fade color
+ * defaults to `--color-bg-high`; override per use with the
+ * `--stage-banner-fade` CSS variable.
+ */
+export function StageBannerBox({
+ stageId,
+ className,
+ children,
+}: {
+ stageId: StageId;
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/features/img-export/components/SeasonSummaryGraphic.module.css b/app/features/img-export/components/SeasonSummaryGraphic.module.css
index db6bc5612..c0ab198e3 100644
--- a/app/features/img-export/components/SeasonSummaryGraphic.module.css
+++ b/app/features/img-export/components/SeasonSummaryGraphic.module.css
@@ -55,13 +55,7 @@
}
.bestStageRow {
- background-image:
- linear-gradient(to right, var(--graphic-row-bg) 35%, transparent 80%),
- var(--best-stage-banner);
- background-origin: border-box;
- background-position: right center;
- background-size: cover;
- background-repeat: no-repeat;
+ --stage-banner-fade: var(--graphic-row-bg);
}
.bestStageName {
diff --git a/app/features/img-export/components/SeasonSummaryGraphic.tsx b/app/features/img-export/components/SeasonSummaryGraphic.tsx
index c28216ebd..edb1cc194 100644
--- a/app/features/img-export/components/SeasonSummaryGraphic.tsx
+++ b/app/features/img-export/components/SeasonSummaryGraphic.tsx
@@ -12,11 +12,12 @@ import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { TierImage, WeaponImage } from "~/components/Image";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
+import { StageBannerBox } from "~/components/StageBannerBox";
import { TierPill } from "~/components/TierPill";
import type { TierName } from "~/features/mmr/mmr-constants";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
-import { stageBannerImageUrl, userSeasonsPage } from "~/utils/urls";
+import { userSeasonsPage } from "~/utils/urls";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
@@ -264,13 +265,9 @@ export function SeasonSummaryGraphic({
) : null}
{bestStage ? (
-
{t("user:seasons.summary.bestStage")}
@@ -281,7 +278,7 @@ export function SeasonSummaryGraphic({
{Math.round(bestStage.winratePercentage)}%
-
+
) : null}
diff --git a/app/features/scanner/components/EventsSummary.tsx b/app/features/scanner/components/EventsSummary.tsx
new file mode 100644
index 000000000..17f771bca
--- /dev/null
+++ b/app/features/scanner/components/EventsSummary.tsx
@@ -0,0 +1,51 @@
+/**
+ * One light line summarizing a scan's raw detections as per-type counts,
+ * with a toggle for the full event card feed — the matches are the main
+ * view, the events stay one click away.
+ */
+
+import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
+import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
+import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
+import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
+import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
+import { SCOREBOARD_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-replay/index";
+
+const EVENT_TYPE_LABELS: Record
= {
+ [MAP_START_EVENT_TYPE]: "map start",
+ [DEATH_EVENT_TYPE]: "death",
+ [MINIMAP_EVENT_TYPE]: "minimap",
+ [SCOREBOARD_EVENT_TYPE]: "scoreboard",
+ [SCOREBOARD_REPLAY_EVENT_TYPE]: "replay scoreboard",
+ [SCOREBOARD_OWN_EVENT_TYPE]: "own result",
+};
+
+export function EventsSummary({
+ events,
+ open,
+ onToggle,
+}: {
+ events: ReadonlyArray<{ type: string }>;
+ open: boolean;
+ onToggle: () => void;
+}) {
+ const counts = new Map();
+ for (const event of events) {
+ counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
+ }
+ const parts = [...counts.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([type, count]) => {
+ const label = EVENT_TYPE_LABELS[type] ?? type;
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
+ });
+
+ return (
+
+ {parts.join(" · ")}
+
+
+ );
+}
diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx
index 22075e381..ca28b037c 100644
--- a/app/features/scanner/components/LivePage.tsx
+++ b/app/features/scanner/components/LivePage.tsx
@@ -10,6 +10,7 @@ import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
+import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import {
clearEvents,
@@ -22,10 +23,13 @@ import {
} from "../store/events";
import { AnalyzerClient } from "../worker/client";
import { EventCard } from "./EventCard";
+import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
+import { MatchCard } from "./MatchCard";
import {
+ aggregateSendStatus,
matchContaining,
type SendouUser,
sendMatches,
@@ -69,6 +73,7 @@ export function LivePage({
const [feed, setFeed] = useState([]);
const [running, setRunning] = useState(false);
const [sendouError, setSendouError] = useState(null);
+ const [eventsOpen, setEventsOpen] = useState(false);
const [liveSend, setLiveSend] = useState(false);
const liveSendRef = useRef(false);
const sendingRef = useRef(false);
@@ -300,32 +305,83 @@ export function LivePage({
- {feed.length === 0 &&
No detections yet.
}
- {feed.map((e) => (
-
loadEventFrame(e.id!)
- : undefined
- }
- send={e.send}
- onSend={
- SENDOU_UPLOAD_ENABLED &&
- sendouUser &&
- e.id !== undefined &&
- INGESTABLE_TYPES.includes(e.type)
- ? () => void send(matchContaining(e.id!), { manual: true })
- : undefined
- }
+ {feed.length === 0 ? (
+ No detections yet.
+ ) : null}
+ {[...buildScannerMatches(feed)]
+ .reverse()
+ .map((built, reverseIndex) => {
+ const id = built.sources[0]!.id!;
+ const ingestable = isIngestableMatch(built.match);
+ return (
+ void send(matchContaining(id), { manual: true })
+ : undefined
+ }
+ >
+ {built.sources.map((e) => (
+ loadEventFrame(e.id!)
+ : undefined
+ }
+ />
+ ))}
+
+ );
+ })}
+ {feed.length > 0 ? (
+ setEventsOpen(!eventsOpen)}
/>
- ))}
+ ) : null}
+ {eventsOpen
+ ? feed.map((e) => (
+ loadEventFrame(e.id!)
+ : undefined
+ }
+ send={e.send}
+ onSend={
+ SENDOU_UPLOAD_ENABLED &&
+ sendouUser &&
+ e.id !== undefined &&
+ INGESTABLE_TYPES.includes(e.type)
+ ? () =>
+ void send(matchContaining(e.id!), { manual: true })
+ : undefined
+ }
+ />
+ ))
+ : null}
diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx
new file mode 100644
index 000000000..0835bdd96
--- /dev/null
+++ b/app/features/scanner/components/MatchCard.tsx
@@ -0,0 +1,230 @@
+/**
+ * Glanceable card for one ScannerMatch in the live feed: stage banner
+ * background, mode + stage, score, team weapons, and the match's /ingest
+ * status. The source event cards render inside the expandable detail
+ * section, so the raw per-event view stays one click away.
+ */
+
+import clsx from "clsx";
+import { ChevronDown } from "lucide-react";
+import type * as React from "react";
+import { useState } from "react";
+import { SendouButton } from "~/components/elements/Button";
+import { ModeImage, WeaponImage } from "~/components/Image";
+import { StageBannerBox } from "~/components/StageBannerBox";
+import type { ScannerMatch } from "../core/scanner-match";
+import type { SendStatus } from "../store/events";
+import { formatTime } from "./format";
+import { lobbyLabel, modeLabel, stageLabel } from "./labels";
+
+const SEND_CHIP_LABELS: Record
= {
+ queued: "queued",
+ sending: "sending…",
+ sent: "ingested",
+ failed: "failed",
+};
+
+export function MatchCard({
+ match,
+ send,
+ onSend,
+ live = false,
+ ingestable = true,
+ children,
+}: {
+ match: ScannerMatch;
+ /** the match's /ingest status, aggregated from its source events */
+ send?: SendStatus;
+ /** when set, shows a Send/Retry button for this match */
+ onSend?: () => void;
+ /** still being played: no closing scoreboard yet and the scan is running */
+ live?: boolean;
+ /** false = isIngestableMatch rejected it (not a private battle) */
+ ingestable?: boolean;
+ /** expandable detail content, typically the source event cards */
+ children?: React.ReactNode;
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ // one-shot flash animations only on a state *change*, so already-sent
+ // matches don't replay the glow on every mount
+ const [prevSendState, setPrevSendState] = useState(send?.state);
+ const [flash, setFlash] = useState<"sent" | "failed" | null>(null);
+ if (prevSendState !== send?.state) {
+ setPrevSendState(send?.state);
+ setFlash(
+ send?.state === "sent" || send?.state === "failed" ? send.state : null,
+ );
+ }
+
+ const meta = [
+ modeLabel(match.mode),
+ ingestable ? null : lobbyLabel(match.lobby),
+ match.startsAt !== null ? timeRangeLabel(match) : null,
+ match.matchScores
+ ? `set ${match.matchScores[0] ?? "?"}–${match.matchScores[1] ?? "?"}`
+ : null,
+ match.replayCode,
+ match.cast ? "cast" : null,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ const inner = (
+ <>
+
+ {match.mode !== null ? (
+
+ ) : null}
+
+
+ {stageLabel(match.stage) ?? "Unknown stage"}
+
+ {meta ?
{meta}
: null}
+
+
+
+ {live ? (
+
+
+ live
+
+ ) : (
+
+ )}
+
+ {onSend && send?.state !== "sent" && send?.state !== "sending" ? (
+
+ ) : null}
+
+ {children ? (
+
}
+ className={clsx("match-expand", { expanded })}
+ aria-expanded={expanded}
+ aria-label={expanded ? "Hide events" : "Show events"}
+ onPress={() => setExpanded(!expanded)}
+ />
+ ) : null}
+
+ {send?.state === "failed" && send.error ? (
+ {send.error}
+ ) : null}
+ {expanded && children ? (
+ {children}
+ ) : null}
+ >
+ );
+
+ const className = clsx("match-card", send?.state, {
+ live,
+ "flash-sent": flash === "sent",
+ "flash-failed": flash === "failed",
+ });
+
+ return match.stage !== null ? (
+
+ {inner}
+
+ ) : (
+ {inner}
+ );
+}
+
+function timeRangeLabel(match: ScannerMatch): string {
+ const start = formatTime(match.startsAt!);
+ return match.endsAt !== null && match.endsAt !== match.startsAt
+ ? `${start}–${formatTime(match.endsAt)}`
+ : start;
+}
+
+function Score({ match }: { match: ScannerMatch }) {
+ const [alpha, bravo] = match.teams;
+ if (alpha.score === null && bravo.score === null) return null;
+
+ // scoreboard-sourced matches list the winners first
+ const winnerKnown = match.winner !== null;
+ return (
+
+
+ {alpha.score ?? "?"}
+
+ –
+
+ {bravo.score ?? "?"}
+
+
+ );
+}
+
+function TeamWeapons({ match }: { match: ScannerMatch }) {
+ const weaponsOf = (team: 0 | 1) =>
+ match.teams[team].players
+ .map((player, index) => ({
+ weaponId: player.weaponId,
+ pov: match.pov?.team === team && match.pov.index === index,
+ }))
+ .filter((weapon) => weapon.weaponId !== null);
+ const alpha = weaponsOf(0);
+ const bravo = weaponsOf(1);
+ if (alpha.length + bravo.length === 0) return null;
+
+ return (
+
+ {alpha.map((weapon, i) => (
+
+ ))}
+ {alpha.length > 0 && bravo.length > 0 ? (
+ vs
+ ) : null}
+ {bravo.map((weapon, i) => (
+
+ ))}
+
+ );
+}
+
+function StatusChip({
+ send,
+ ingestable,
+ live,
+}: {
+ send?: SendStatus;
+ ingestable: boolean;
+ live: boolean;
+}) {
+ if (!ingestable) return not ingested;
+ if (send) {
+ return (
+
+ {send.state === "queued" || send.state === "sending" ? (
+
+ ) : null}
+ {send.state === "sent" ? "✓ " : null}
+ {SEND_CHIP_LABELS[send.state]}
+ {send.state === "sent"
+ ? ` ${new Date(send.at).toLocaleTimeString()}`
+ : null}
+
+ );
+ }
+ if (live) return null;
+ return not sent;
+}
diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx
index c32001749..ce259cde8 100644
--- a/app/features/scanner/components/VodPage.tsx
+++ b/app/features/scanner/components/VodPage.tsx
@@ -17,7 +17,9 @@ import { Link } from "react-router";
import { openVodScan } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import type { DetectedEvent } from "../core/detectors/types";
+import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
+import type { SendStatus } from "../store/events";
import {
deleteVod,
listVods,
@@ -28,10 +30,12 @@ import {
} from "../store/vods";
import { AnalyzerPool, defaultPoolSize } from "../worker/pool";
import { EventCard, type GetFrame } from "./EventCard";
+import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import type { FixtureData } from "./fixture-export";
import { SENDOU_UPLOAD_ENABLED } from "./flags";
import { formatTime } from "./format";
+import { MatchCard } from "./MatchCard";
import {
countIngestableMatches,
type SendouUser,
@@ -72,7 +76,13 @@ interface Progress {
/** "Upload as results" progress/outcome shown next to the button. */
type ResultsSend =
| { state: "sending"; sent: number; total: number }
- | { state: "done"; sent: number; total: number; error: string | null };
+ | {
+ state: "done";
+ sent: number;
+ total: number;
+ error: string | null;
+ at: number;
+ };
export function VodPage({
sendouUser,
@@ -105,6 +115,7 @@ export function VodPage({
const [vods, setVods] = useState([]);
const [error, setError] = useState(null);
const [over, setOver] = useState(false);
+ const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState(null);
const abilityMap = useMemo(
@@ -112,6 +123,21 @@ export function VodPage({
[matches],
);
+ const builtMatches = buildScannerMatches(matches.map((m) => m.event));
+ const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
+
+ // "Upload as results" sends the whole scan in one go, so its outcome maps
+ // onto every ingestable card; a partial failure (some chunks sent, some
+ // not) can't be attributed per match — the bulk status text covers it
+ const bulkSend: SendStatus | undefined =
+ resultsSend?.state === "sending"
+ ? { state: "sending", at: 0 }
+ : resultsSend?.state === "done" && resultsSend.error === null
+ ? { state: "sent", at: resultsSend.at }
+ : resultsSend?.state === "done" && resultsSend.sent === 0
+ ? { state: "failed", at: resultsSend.at }
+ : undefined;
+
// only offered once the whole VoD has been processed (a stored VoD is a
// completed scan by construction)
const upload = useMemo(
@@ -145,6 +171,7 @@ export function VodPage({
sent: report.sentMatches,
total: report.totalMatches,
error: report.error,
+ at: Date.now(),
});
}, []);
@@ -174,6 +201,7 @@ export function VodPage({
setError(null);
setMatches([]);
setResultsSend(null);
+ setEventsOpen(false);
setProgress(null);
setGateScore(null);
setMethod(null);
@@ -324,6 +352,7 @@ export function VodPage({
matchesRef.current = loaded;
setMatches(loaded);
setResultsSend(null);
+ setEventsOpen(false);
setFileName(name);
setSource("stored");
setStatus("done");
@@ -350,6 +379,7 @@ export function VodPage({
matchesRef.current = [];
setMatches([]);
setResultsSend(null);
+ setEventsOpen(false);
setFileName(null);
setStatus("idle");
setError(null);
@@ -418,9 +448,10 @@ export function VodPage({
` · ${progress.rate.toFixed(0)}× realtime`}
)}
- {matches.length > 0 && (
+ {builtMatches.length > 0 && (
- {matches.length} match{matches.length === 1 ? "" : "es"}
+ {builtMatches.length} match
+ {builtMatches.length === 1 ? "" : "es"}
)}
{matches.length > 0 && (
@@ -522,39 +553,79 @@ export function VodPage({
/>
- {matches.length === 0 && (
+ {matches.length === 0 ? (
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
- )}
- {/* newest detection on top; storage keeps ascending video-time order */}
- {[...matches].reverse().map((m, i) => {
- const getFrame: GetFrame | undefined = m.frame
- ? () => Promise.resolve(m.frame)
- : m.frameId !== undefined
- ? () => loadVodEventFrame(m.frameId!)
- : undefined;
+ ) : null}
+ {/* newest match on top; the builder keeps ascending video-time order */}
+ {[...builtMatches].reverse().map((built) => {
+ const ingestable = isIngestableMatch(built.match);
return (
-
+
+ {built.sources.map((e, i) => {
+ const vodMatch = vodMatchByEvent.get(e);
+ return (
+
+ );
+ })}
+
);
})}
+ {matches.length > 0 ? (
+
m.event)}
+ open={eventsOpen}
+ onToggle={() => setEventsOpen(!eventsOpen)}
+ />
+ ) : null}
+ {/* newest detection on top; storage keeps ascending video-time order */}
+ {eventsOpen
+ ? [...matches]
+ .reverse()
+ .map((m, i) => (
+
+ ))
+ : null}
);
}
+function frameLoader(m: VodMatch): GetFrame | undefined {
+ return m.frame
+ ? () => Promise.resolve(m.frame)
+ : m.frameId !== undefined
+ ? () => loadVodEventFrame(m.frameId!)
+ : undefined;
+}
+
function drawPreview(
canvas: HTMLCanvasElement | null,
frame: ImageBitmap | VideoFrame,
diff --git a/app/features/scanner/components/sendou-ingest.ts b/app/features/scanner/components/sendou-ingest.ts
index 255423916..7ea6b737b 100644
--- a/app/features/scanner/components/sendou-ingest.ts
+++ b/app/features/scanner/components/sendou-ingest.ts
@@ -15,7 +15,11 @@ import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
-import { type StoredEvent, updateEventsSend } from "../store/events";
+import {
+ type SendStatus,
+ type StoredEvent,
+ updateEventsSend,
+} from "../store/events";
const INGEST_URL = "/ingest";
@@ -126,6 +130,26 @@ export function matchContaining(
return (built) => built.sources.some((e) => e.id === id);
}
+/**
+ * The single send status a match displays, folded from its source events:
+ * an in-flight send wins, then a failure, then success, then queued. Within
+ * a state the most recent change is shown.
+ */
+export function aggregateSendStatus(
+ sources: readonly StoredEvent[],
+): SendStatus | undefined {
+ const statuses = sources
+ .map((e) => e.send)
+ .filter((status) => status !== undefined);
+ for (const state of ["sending", "failed", "sent", "queued"] as const) {
+ const ofState = statuses.filter((status) => status.state === state);
+ if (ofState.length > 0) {
+ return ofState.reduce((a, b) => (a.at >= b.at ? a : b));
+ }
+ }
+ return undefined;
+}
+
/** Match selector: matches not yet sent (nor currently sending). */
export function unsentMatches(built: BuiltMatch): boolean {
return !built.sources.some(
diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css
index 991ca1076..e480c8e96 100644
--- a/app/features/scanner/components/styles.css
+++ b/app/features/scanner/components/styles.css
@@ -435,3 +435,341 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
.scanner-app .link-button:focus-visible {
outline: var(--focus-ring);
}
+
+/* ── ingested matches feed ─────────────────────────────────────────── */
+
+.scanner-app .match-card {
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-high);
+ animation: scanner-card-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
+
+ /* one overlay element all send-state effects draw on */
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ }
+
+ &.sending {
+ box-shadow: inset 0 0 0 1px var(--color-info-low);
+ }
+
+ &.sending::after {
+ background-image: linear-gradient(
+ 105deg,
+ transparent 40%,
+ rgb(255 255 255 / 0.09) 50%,
+ transparent 60%
+ );
+ background-size: 250% 100%;
+ animation: scanner-sheen 1.4s linear infinite;
+ }
+
+ &.flash-sent::after {
+ animation: scanner-sent-pop 0.8s ease-out both;
+ }
+
+ &.live {
+ box-shadow: inset 0 0 0 1px var(--color-info-low);
+ }
+}
+
+@keyframes scanner-card-in {
+ from {
+ opacity: 0;
+ transform: translateY(10px) scale(0.98);
+ }
+}
+
+@keyframes scanner-sheen {
+ from {
+ background-position: 130% 0;
+ }
+ to {
+ background-position: -130% 0;
+ }
+}
+
+@keyframes scanner-sent-pop {
+ 0% {
+ opacity: 1;
+ box-shadow:
+ inset 0 0 0 2px var(--color-success),
+ inset 0 0 32px var(--color-success-low);
+ }
+ 100% {
+ opacity: 0;
+ box-shadow:
+ inset 0 0 0 2px var(--color-success),
+ inset 0 0 32px var(--color-success-low);
+ }
+}
+
+.scanner-app .match-card-main {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ padding: 12px 16px;
+ min-height: 66px;
+}
+
+.scanner-app .match-card.flash-failed .match-card-main {
+ animation: scanner-shake 0.35s ease;
+}
+
+@keyframes scanner-shake {
+ 20% {
+ transform: translateX(-4px);
+ }
+ 50% {
+ transform: translateX(4px);
+ }
+ 80% {
+ transform: translateX(-2px);
+ }
+}
+
+.scanner-app .match-mode {
+ flex-shrink: 0;
+ filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
+}
+
+.scanner-app .match-headline {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ /* claims row space so the score/chips can never crush the stage name
+ out of view in a narrow feed column; wraps instead */
+ flex: 1 1 0;
+ min-width: 150px;
+}
+
+.scanner-app .match-stage {
+ font-size: var(--font-sm);
+ font-weight: var(--weight-extra);
+ line-height: 1.1;
+}
+
+.scanner-app .match-meta {
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-semi);
+ color: var(--color-text-high);
+}
+
+.scanner-app .match-weapons {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+
+ & .vs {
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-semi);
+ color: var(--color-text-high);
+ margin: 0 5px;
+ }
+
+ & img.weapon-icon {
+ width: 22px;
+ height: 22px;
+
+ &.pov {
+ outline: 2px solid var(--color-text-accent);
+ outline-offset: 1px;
+ }
+ }
+}
+
+.scanner-app .match-side {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 6px;
+ margin-inline-start: auto;
+ flex-shrink: 0;
+}
+
+/* frosted-glass plate: keeps the score readable over vivid banner art
+ without dimming the image itself */
+.scanner-app .match-score {
+ font-size: var(--font-lg);
+ font-weight: var(--weight-extra);
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+ padding: 5px 12px;
+ border-radius: var(--radius-full);
+ background: color-mix(in oklab, var(--color-bg) 72%, transparent);
+ backdrop-filter: blur(8px);
+
+ & .win {
+ color: var(--color-error-high);
+ }
+
+ & .lose {
+ color: var(--color-info-high);
+ }
+}
+
+.scanner-app .match-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px 10px;
+ border-radius: var(--radius-full);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+ border: var(--border-width) solid var(--color-border);
+ background-color: var(--color-bg);
+ color: var(--color-text-high);
+ white-space: nowrap;
+
+ & .dot {
+ width: 7px;
+ height: 7px;
+ border-radius: var(--radius-full);
+ background: currentColor;
+ }
+
+ &.live {
+ color: var(--color-error);
+ border-color: var(--color-error-low);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+
+ & .dot {
+ animation: scanner-pulse 1.4s ease-in-out infinite;
+ }
+ }
+
+ &.queued,
+ &.sending {
+ color: var(--color-info-high);
+ border-color: var(--color-info-low);
+ background-color: var(--color-info-low);
+
+ & .dot {
+ animation: scanner-pulse 1.4s ease-in-out infinite;
+ }
+ }
+
+ &.sent {
+ color: var(--color-success-high);
+ border-color: var(--color-success-low);
+ background-color: var(--color-success-low);
+ }
+
+ &.failed {
+ color: var(--color-error-high);
+ border-color: var(--color-error-low);
+ background-color: var(--color-error-low);
+ }
+}
+
+@keyframes scanner-pulse {
+ 50% {
+ opacity: 0.25;
+ transform: scale(0.8);
+ }
+}
+
+/* undo the scanner-wide solid button look for the banner icon button */
+.scanner-app button.match-expand {
+ flex-shrink: 0;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: var(--radius-full);
+ background: color-mix(in oklab, var(--color-bg) 55%, transparent);
+ backdrop-filter: blur(8px);
+ color: var(--color-text-high);
+ transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
+
+ & svg {
+ width: 18px;
+ height: 18px;
+ }
+
+ &:hover {
+ color: var(--color-text);
+ background: color-mix(in oklab, var(--color-bg) 78%, transparent);
+ }
+
+ &.expanded {
+ transform: rotate(180deg);
+ }
+}
+
+.scanner-app .match-error {
+ padding: 0 16px 10px;
+ font-size: var(--font-2xs);
+ color: var(--color-error);
+}
+
+.scanner-app .match-card-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 0 12px 12px;
+ animation: scanner-detail-in 0.25s ease both;
+
+ & .card {
+ background-color: var(--color-bg);
+ }
+}
+
+@keyframes scanner-detail-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+}
+
+.scanner-app .events-summary {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ padding: 2px 4px;
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-semi);
+ color: var(--color-text-high);
+}
+
+.scanner-app button.events-toggle {
+ border: none;
+ background: transparent;
+ color: var(--color-text-accent);
+ height: auto;
+ padding: 0;
+ font-size: var(--font-2xs);
+}
+
+.scanner-app .status.watching::before {
+ content: "";
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: var(--radius-full);
+ background: currentColor;
+ margin-right: 6px;
+ animation: scanner-pulse 1.6s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .scanner-app .match-card,
+ .scanner-app .match-card.sending::after,
+ .scanner-app .match-card.flash-sent::after,
+ .scanner-app .match-card.flash-failed .match-card-main,
+ .scanner-app .match-card-detail,
+ .scanner-app .match-chip.live .dot,
+ .scanner-app .match-chip.queued .dot,
+ .scanner-app .match-chip.sending .dot,
+ .scanner-app .status.watching::before {
+ animation: none;
+ }
+}