From 866369f6cde3ee8cc7f3f756caf4b0b5692e9a95 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:03:36 +0300 Subject: [PATCH] UI work --- app/components/StageBannerBox.module.css | 13 + app/components/StageBannerBox.tsx | 33 ++ .../SeasonSummaryGraphic.module.css | 8 +- .../components/SeasonSummaryGraphic.tsx | 13 +- .../scanner/components/EventsSummary.tsx | 51 +++ app/features/scanner/components/LivePage.tsx | 106 ++++-- app/features/scanner/components/MatchCard.tsx | 230 ++++++++++++ app/features/scanner/components/VodPage.tsx | 115 ++++-- .../scanner/components/sendou-ingest.ts | 26 +- app/features/scanner/components/styles.css | 338 ++++++++++++++++++ 10 files changed, 870 insertions(+), 63 deletions(-) create mode 100644 app/components/StageBannerBox.module.css create mode 100644 app/components/StageBannerBox.tsx create mode 100644 app/features/scanner/components/EventsSummary.tsx create mode 100644 app/features/scanner/components/MatchCard.tsx 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({
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; + } +}