This commit is contained in:
Kalle
2026-08-05 15:03:36 +03:00
parent c2bb3fb3c1
commit 866369f6cd
10 changed files with 870 additions and 63 deletions

View File

@@ -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;
}

View File

@@ -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 (
<div
className={clsx(styles.banner, className)}
style={
{
"--stage-banner": `url(${stageBannerImageUrl(stageId)})`,
} as React.CSSProperties
}
>
{children}
</div>
);
}

View File

@@ -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 {

View File

@@ -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({
</div>
) : null}
{bestStage ? (
<div
<StageBannerBox
stageId={bestStage.stageId}
className={clsx(graphicStyles.box, styles.bestStageRow)}
style={
{
"--best-stage-banner": `url(${stageBannerImageUrl(bestStage.stageId)})`,
} as React.CSSProperties
}
>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.bestStage")}
@@ -281,7 +278,7 @@ export function SeasonSummaryGraphic({
{Math.round(bestStage.winratePercentage)}%
</span>
</div>
</div>
</StageBannerBox>
) : null}
<div className={styles.middleGrid}>
<div className={clsx(graphicStyles.box, styles.activityBox)}>

View File

@@ -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<string, string> = {
[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<string, number>();
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 (
<div className="events-summary">
<span>{parts.join(" · ")}</span>
<button type="button" className="events-toggle" onClick={onToggle}>
{open ? "hide events" : "show events"}
</button>
</div>
);
}

View File

@@ -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<StoredEvent[]>([]);
const [running, setRunning] = useState(false);
const [sendouError, setSendouError] = useState<string | null>(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({
<div className="live-layout">
<video ref={videoRef} className="preview" muted playsInline />
<div className="feed">
{feed.length === 0 && <p className="score">No detections yet.</p>}
{feed.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => 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 ? (
<p className="score">No detections yet.</p>
) : null}
{[...buildScannerMatches(feed)]
.reverse()
.map((built, reverseIndex) => {
const id = built.sources[0]!.id!;
const ingestable = isIngestableMatch(built.match);
return (
<MatchCard
key={id}
match={built.match}
live={
running && reverseIndex === 0 && built.match.winner === null
}
ingestable={ingestable}
send={aggregateSendStatus(built.sources)}
onSend={
SENDOU_UPLOAD_ENABLED && sendouUser && ingestable
? () => void send(matchContaining(id), { manual: true })
: undefined
}
>
{built.sources.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
/>
))}
</MatchCard>
);
})}
{feed.length > 0 ? (
<EventsSummary
events={feed}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
))}
) : null}
{eventsOpen
? feed.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => 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}
</div>
</div>
</div>

View File

@@ -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<SendStatus["state"], string> = {
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 = (
<>
<div className="match-card-main">
{match.mode !== null ? (
<ModeImage mode={match.mode} size={30} className="match-mode" />
) : null}
<div className="match-headline">
<div className="match-stage">
{stageLabel(match.stage) ?? "Unknown stage"}
</div>
{meta ? <div className="match-meta">{meta}</div> : null}
<TeamWeapons match={match} />
</div>
<div className="match-side">
{live ? (
<span className="match-chip live">
<span className="dot" />
live
</span>
) : (
<Score match={match} />
)}
<StatusChip send={send} ingestable={ingestable} live={live} />
{onSend && send?.state !== "sent" && send?.state !== "sending" ? (
<button type="button" onClick={onSend}>
{send?.state === "failed" ? "Retry" : "Send"}
</button>
) : null}
</div>
{children ? (
<SendouButton
variant="minimal"
size="small"
shape="circle"
icon={<ChevronDown />}
className={clsx("match-expand", { expanded })}
aria-expanded={expanded}
aria-label={expanded ? "Hide events" : "Show events"}
onPress={() => setExpanded(!expanded)}
/>
) : null}
</div>
{send?.state === "failed" && send.error ? (
<div className="match-error">{send.error}</div>
) : null}
{expanded && children ? (
<div className="match-card-detail">{children}</div>
) : null}
</>
);
const className = clsx("match-card", send?.state, {
live,
"flash-sent": flash === "sent",
"flash-failed": flash === "failed",
});
return match.stage !== null ? (
<StageBannerBox stageId={match.stage} className={className}>
{inner}
</StageBannerBox>
) : (
<div className={className}>{inner}</div>
);
}
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 (
<div className="match-score">
<span className={winnerKnown ? "win" : undefined}>
{alpha.score ?? "?"}
</span>
<span className="sep"> </span>
<span className={winnerKnown ? "lose" : undefined}>
{bravo.score ?? "?"}
</span>
</div>
);
}
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 (
<div className="match-weapons">
{alpha.map((weapon, i) => (
<WeaponImage
key={i}
weaponSplId={weapon.weaponId!}
variant="build"
size={22}
className={clsx("weapon-icon", { pov: weapon.pov })}
/>
))}
{alpha.length > 0 && bravo.length > 0 ? (
<span className="vs">vs</span>
) : null}
{bravo.map((weapon, i) => (
<WeaponImage
key={i}
weaponSplId={weapon.weaponId!}
variant="build"
size={22}
className={clsx("weapon-icon", { pov: weapon.pov })}
/>
))}
</div>
);
}
function StatusChip({
send,
ingestable,
live,
}: {
send?: SendStatus;
ingestable: boolean;
live: boolean;
}) {
if (!ingestable) return <span className="match-chip">not ingested</span>;
if (send) {
return (
<span className={clsx("match-chip", send.state)} title={send.error}>
{send.state === "queued" || send.state === "sending" ? (
<span className="dot" />
) : null}
{send.state === "sent" ? "✓ " : null}
{SEND_CHIP_LABELS[send.state]}
{send.state === "sent"
? ` ${new Date(send.at).toLocaleTimeString()}`
: null}
</span>
);
}
if (live) return null;
return <span className="match-chip">not sent</span>;
}

View File

@@ -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<VodSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [over, setOver] = useState(false);
const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(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`}
</span>
)}
{matches.length > 0 && (
{builtMatches.length > 0 && (
<span className="score">
{matches.length} match{matches.length === 1 ? "" : "es"}
{builtMatches.length} match
{builtMatches.length === 1 ? "" : "es"}
</span>
)}
{matches.length > 0 && (
@@ -522,39 +553,79 @@ export function VodPage({
/>
</div>
<div className="feed">
{matches.length === 0 && (
{matches.length === 0 ? (
<p className="score">
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
</p>
)}
{/* 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 (
<EventCard
key={matches.length - 1 - i}
type={m.event.type}
t={m.event.t}
confidence={m.event.confidence}
data={m.event.data}
abilities={abilityMap.get(m.event)}
thumbnail={m.thumbnail}
getFrame={getFrame}
/>
<MatchCard
key={built.sources[0]!.t}
match={built.match}
ingestable={ingestable}
send={ingestable ? bulkSend : undefined}
>
{built.sources.map((e, i) => {
const vodMatch = vodMatchByEvent.get(e);
return (
<EventCard
key={i}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data}
abilities={abilityMap.get(e)}
thumbnail={vodMatch?.thumbnail}
getFrame={vodMatch ? frameLoader(vodMatch) : undefined}
/>
);
})}
</MatchCard>
);
})}
{matches.length > 0 ? (
<EventsSummary
events={matches.map((m) => m.event)}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
) : null}
{/* newest detection on top; storage keeps ascending video-time order */}
{eventsOpen
? [...matches]
.reverse()
.map((m, i) => (
<EventCard
key={matches.length - 1 - i}
type={m.event.type}
t={m.event.t}
confidence={m.event.confidence}
data={m.event.data}
abilities={abilityMap.get(m.event)}
thumbnail={m.thumbnail}
getFrame={frameLoader(m)}
/>
))
: null}
</div>
</div>
</div>
);
}
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,

View File

@@ -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<StoredEvent>): boolean {
return !built.sources.some(

View File

@@ -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;
}
}