diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index c29a5d526..f1451fb92 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -10,9 +10,8 @@ detected game per object, every field nullable — which feed `/ingest` emberz repo; see `MIGRATION.md` there. Deliberate convention exceptions (dev tool, ported wholesale): the UI is -English-only (no i18next) and styled by one global `components/styles.css` -instead of per-component CSS modules; `tests/node-test-compat.ts` uses a -default export to stay a `node:test` drop-in. +English-only (no i18next) and `tests/node-test-compat.ts` uses a default +export to stay a `node:test` drop-in. ## Commands diff --git a/app/features/scanner/components/AbilityGrid.module.css b/app/features/scanner/components/AbilityGrid.module.css new file mode 100644 index 000000000..d8de40d18 --- /dev/null +++ b/app/features/scanner/components/AbilityGrid.module.css @@ -0,0 +1,13 @@ +button.abilityTrigger { + height: auto; + padding: var(--s-0-5); + background: var(--color-bg-higher); + border: 1px solid var(--color-border-high); + border-radius: var(--radius-selector); + + & img { + display: block; + width: var(--field-size-icon); + height: var(--field-size-icon); + } +} diff --git a/app/features/scanner/components/AbilityGrid.tsx b/app/features/scanner/components/AbilityGrid.tsx index 5134145e3..b669a2a39 100644 --- a/app/features/scanner/components/AbilityGrid.tsx +++ b/app/features/scanner/components/AbilityGrid.tsx @@ -7,6 +7,8 @@ import { Button } from "react-aria-components"; import { Ability } from "~/components/Ability"; import { SendouPopover } from "~/components/elements/Popover"; import type { AbilityWithUnknown } from "~/modules/in-game-lists/types"; +import styles from "./AbilityGrid.module.css"; +import eventCardStyles from "./EventCard.module.css"; const ROW_LABELS = ["head", "clothes", "shoes"] as const; @@ -16,7 +18,7 @@ export function AbilityGrid({ abilities: AbilityWithUnknown[][]; }) { return ( - +
{abilities.map((row, i) => ( @@ -48,7 +50,7 @@ export function AbilityPopover({ diff --git a/app/features/scanner/components/App.module.css b/app/features/scanner/components/App.module.css new file mode 100644 index 000000000..01e0bed08 --- /dev/null +++ b/app/features/scanner/components/App.module.css @@ -0,0 +1,36 @@ +.topbar { + display: flex; + align-items: center; + gap: var(--s-6); + padding-bottom: var(--s-3); + border-bottom: var(--border-width) solid var(--color-bg-high); + margin-bottom: var(--s-4); + + & nav { + display: flex; + gap: var(--s-2); + + & a { + color: var(--color-text-high); + font-size: var(--font-xs); + font-weight: var(--weight-bold); + text-decoration: none; + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-field); + + &:hover { + color: var(--color-text); + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } + + &.active { + color: var(--color-text-accent); + background: var(--color-bg-high); + } + } + } +} diff --git a/app/features/scanner/components/App.tsx b/app/features/scanner/components/App.tsx index 01126b1ae..d99092d15 100644 --- a/app/features/scanner/components/App.tsx +++ b/app/features/scanner/components/App.tsx @@ -1,17 +1,19 @@ +import clsx from "clsx"; import { Link } from "react-router"; import { useUser } from "~/features/auth/core/user"; import { useSearchParam } from "~/modules/search-params/hooks"; import { SCANNER_PAGE } from "~/utils/urls"; +import scannerStyles from "../scanner.module.css"; import { SCANNER_TABS, type ScannerTab, scannerSearchParams, } from "../scanner-search-params"; +import styles from "./App.module.css"; import { LivePage } from "./LivePage"; import { ScreenshotPage } from "./ScreenshotPage"; import type { SendouUser } from "./sendou-ingest"; import { VodPage } from "./VodPage"; -import "./styles.css"; const TAB_LABELS: Record = { live: "Live", @@ -36,14 +38,14 @@ export function App() { ); return ( -
-
+
+
{players.map((p, i) => ( - - + @@ -82,8 +85,8 @@ export function ScoreboardCard(props: { const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE; const formatDetectedAt = useEventTimeFormatter(); return ( -
-
+
+
{data.timestamp} : null} {data.replayCode ? ( - {data.replayCode} + {data.replayCode} ) : null} {detectedAt ? {formatDetectedAt(detectedAt)} : null}
-
-
+
+

{teamHeading("Victory", data, 0)}

-
+

{teamHeading("Defeat", data, 1)}

-
+
+
-
-
+
+
{data.weaponId !== null ? ( ) : null} diff --git a/app/features/scanner/components/ScreenshotPage.module.css b/app/features/scanner/components/ScreenshotPage.module.css new file mode 100644 index 000000000..8ef78af63 --- /dev/null +++ b/app/features/scanner/components/ScreenshotPage.module.css @@ -0,0 +1,233 @@ +.frame { + position: relative; + margin-bottom: var(--s-4); + + & canvas { + width: 100%; + border-radius: var(--radius-box); + border: var(--border-width) solid var(--color-bg-high); + } +} + +/* ---- per-detector gate results ---- */ + +.gateList { + display: grid; + grid-template-columns: repeat(3, max-content) 1fr; + column-gap: var(--s-4); + row-gap: var(--s-1-5); + align-items: baseline; + font-size: var(--font-xs); + margin-block: var(--s-4); +} + +.gateRow { + display: contents; +} + +.gateBadge { + justify-self: start; + align-self: center; + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background: var(--color-bg-high); + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + text-transform: uppercase; + white-space: nowrap; +} + +.gateRow:not(.fired) .gateName { + color: var(--color-text-high); +} + +.gateRow.fired { + & .gateBadge { + background: var(--color-success-low); + color: var(--color-success-high); + } + + & .gateName { + font-weight: var(--weight-bold); + } +} + +.gateScore { + font-variant-numeric: tabular-nums; + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.gateNote { + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +/* ---- fired detector's parse detail ---- */ + +.detail { + display: flex; + flex-direction: column; + gap: var(--s-3); + margin-block: var(--s-4); +} + +.detailStats { + display: flex; + flex-wrap: wrap; + gap: var(--s-1-5); +} + +.stat { + display: inline-flex; + align-items: baseline; + gap: var(--s-1-5); + padding: var(--s-1) var(--s-2); + border-radius: var(--radius-field); + background: var(--color-bg-high); + font-size: var(--font-xs); +} + +.statLabel { + color: var(--color-text-high); + font-size: var(--font-2xs); + white-space: nowrap; +} + +.statValue { + font-weight: var(--weight-bold); +} + +.statRaw { + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.detailCrops { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); + align-items: end; + + & figure { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--s-1); + min-width: 0; + } + + & canvas { + display: block; + max-width: 100%; + background: #000; + border-radius: var(--radius-selector); + border: var(--border-width) solid var(--color-bg-high); + } + + & figcaption { + color: var(--color-text-high); + font-size: var(--font-2xs); + } +} + +/* one pill per player slot */ +.statusSlots { + display: inline-flex; + gap: var(--s-2); + align-self: center; +} + +.statusSide { + display: inline-flex; + gap: var(--s-0-5); +} + +.statusSlot { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.25rem; + block-size: 1.25rem; + border-radius: var(--radius-selector); + background: var(--color-bg-higher); + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + + &.dead { + background: var(--color-error-low); + color: var(--color-error-high); + } + + &.special { + background: var(--color-warning-low); + color: var(--color-warning-high); + } +} + +/* ---- per-row parse inspector ---- */ + +.inspector { + width: 100%; + border-collapse: collapse; + font-size: var(--font-xs); + + & th { + text-align: left; + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + padding: var(--s-1-5); + border-bottom: var(--border-style); + } + + & td { + padding: var(--s-1-5); + border-bottom: 1px solid var(--color-bg-high); + vertical-align: middle; + } + + & canvas { + display: block; + background: #000; + border-radius: var(--radius-selector); + } +} + +.candidates { + display: flex; + gap: var(--s-2); + align-items: center; +} + +.candidate { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-0-5); + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.weaponCandidates .candidate:first-child { + color: var(--color-success); +} + +.weaponIcon { + width: 28px; + height: 28px; + vertical-align: middle; + background: var(--color-bg-badge); + border-radius: var(--radius-full); + padding: var(--s-0-5); +} + +/* the ROI crops set the width; scroll them instead of the whole page */ +@container scanner (width < 700px) { + .inspector { + display: block; + overflow-x: auto; + } +} diff --git a/app/features/scanner/components/ScreenshotPage.tsx b/app/features/scanner/components/ScreenshotPage.tsx index fafff522b..6ae6cd8b3 100644 --- a/app/features/scanner/components/ScreenshotPage.tsx +++ b/app/features/scanner/components/ScreenshotPage.tsx @@ -30,6 +30,7 @@ import * as replay from "../core/detectors/scoreboard-battle-log-replay/rois"; import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index"; import * as own from "../core/detectors/scoreboard-own/rois"; import type { DetectedEvent } from "../core/detectors/types"; +import scannerStyles from "../scanner.module.css"; import { scannerSearchParams } from "../scanner-search-params"; import { claimInspectFrame } from "../store/inspect"; import { AnalyzerClient } from "../worker/client"; @@ -43,6 +44,7 @@ import { stageLabel, weaponLabel, } from "./labels"; +import styles from "./ScreenshotPage.module.css"; type Result = Extract; @@ -77,11 +79,11 @@ function RoiCrop(props: { function Stat(props: { label: string; raw?: unknown; children: ReactNode }) { return ( - - {props.label} - {props.children} + + {props.label} + {props.children} {props.raw != null && props.raw !== "" ? ( - raw: {String(props.raw)} + raw: {String(props.raw)} ) : null} ); @@ -104,15 +106,18 @@ function LabeledCrop(props: { /** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */ function StatusSlots(props: { data: PlayerStatusData }) { return ( - + {([0, 1] as const).map((side) => ( - + {props.data.dead[side].map((dead, slot) => { const special = !dead && props.data.special[side][slot]; return ( {dead ? "✗" : special ? "★" : slot + 1} @@ -511,7 +516,9 @@ export function ScreenshotPage() {
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
{ e.preventDefault(); setOver(true); @@ -540,10 +547,10 @@ export function ScreenshotPage() { {busy ? " — analyzing…" : null}
- {error ?

{error}

: null} + {error ?

{error}

: null}
@@ -575,26 +582,30 @@ export function ScreenshotPage() { ) : null} {Object.keys(results).length > 0 ? ( -
+
{Object.values(results).map((result) => (
- + {result.gate.pass ? "fired" : "no fire"} - {result.detector} - {result.gate.score.toFixed(3)} - {gateSummary(result)} + {result.detector} + + {result.gate.score.toFixed(3)} + + {gateSummary(result)}
))}
) : null} {frame && event && isReplay ? ( -
-
+
+
{event.data.timestamp ?? "?"} {event.data.replayCode ?? "?"} @@ -604,7 +615,7 @@ export function ScreenshotPage() { {winnerSide}
-
+
-
+
+
{event.data.timestamp ?? "?"} {JSON.stringify(event.data.matchScores)} {winnerSide}
-
+
+
{(() => { const data = event.data as unknown as DeathData; return ( <> -
+
{weaponLabel(data.weaponType, data.weaponId) ?? "?"} @@ -660,7 +671,7 @@ export function ScreenshotPage() { {data.abilities.map((row) => row.join(" ")).join(" | ")}
-
+
+
{(() => { const data = event.data as unknown as MapStartData; return ( <> -
+
{modeLabel(data.mode) ?? "?"} @@ -692,7 +703,7 @@ export function ScreenshotPage() { {stageLabel(data.stage) ?? "?"}
-
+
+
{(() => { const data = event.data as unknown as ScoreboardOwnData; return ( <> -
+
{mainWeaponLabel(data.weaponId) ?? "?"} @@ -725,7 +736,7 @@ export function ScreenshotPage() { {data.abilities.map((row) => row.join(" ")).join(" | ")}
-
+
+
{(() => { const data = event.data as unknown as MinimapData; return ( <> -
+
{stageLabel(data.stage) ?? "?"} {data.spectator ? "spectator map" : "POV overlay"} @@ -782,7 +793,7 @@ export function ScreenshotPage() { ) : null}
{!data.spectator ? ( -
+
{minimap.CARD_LAYOUTS.map((card) => ( +
{(() => { const data = event.data as unknown as ObjectiveData; const status = active?.events.find( @@ -808,7 +819,7 @@ export function ScreenshotPage() { ) as DetectedEvent | undefined; return ( <> -
+
{formatTimer(data.time)} {data.score[0] ?? "?"}–{data.score[1] ?? "?"} @@ -832,7 +843,7 @@ export function ScreenshotPage() { ) : null}
-
+
+
- + {p.weaponId !== null ? ( ) : null} {abilities?.has(offset + i) ? ( @@ -44,8 +47,8 @@ function PlayerRows({ {p.name || "?"}{p.paint ?? "?"}p + {p.paint ?? "?"}p {p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
@@ -895,16 +906,23 @@ export function ScreenshotPage() {
row -
+
{dbg?.weapon?.top.map((c) => ( - + {c.id} {c.id} - {c.score.toFixed(3)} + + {c.score.toFixed(3)} + ))}
@@ -912,20 +930,24 @@ export function ScreenshotPage() {
{player?.name || "—"}{" "} - {dbg?.nameScore.toFixed(3)} + + {dbg?.nameScore.toFixed(3)} + {player?.paint ?? "—"}{" "} - {dbg?.paintScore.toFixed(3)} + + {dbg?.paintScore.toFixed(3)} + -
+
{([0, 1, 2] as const).map((s) => ( - + {[player?.ka, player?.d, player?.s][s] ?? "—"} - + {dbg?.statScores[s].toFixed(2)} diff --git a/app/features/scanner/components/VodPage.module.css b/app/features/scanner/components/VodPage.module.css new file mode 100644 index 000000000..226c0a7a3 --- /dev/null +++ b/app/features/scanner/components/VodPage.module.css @@ -0,0 +1,99 @@ +/* internal navigation styled like the sibling action buttons */ +.linkButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-1-5); + padding: 0 var(--field-padding); + height: var(--field-size-sm); + border: var(--border-style-accent); + border-radius: var(--radius-field); + background: var(--color-text-accent); + color: var(--color-text-inverse); + font-size: var(--font-xs); + font-weight: var(--weight-bold); + white-space: nowrap; + text-decoration: none; + + & > svg { + width: 16px; + height: 16px; + } + + &:active { + transform: translateY(1px); + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } +} + +.vodList { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.vodItem { + display: flex; + align-items: center; + gap: var(--s-3); + background: var(--color-bg-high); + border-radius: var(--radius-box); + padding: var(--s-3) var(--s-4); +} + +.vodName { + font-size: var(--font-sm); + font-weight: var(--weight-bold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vodMeta { + flex: 1; +} + +/* the shared destructive variant loses to the scanner's own button look */ +button.vodDelete { + width: var(--field-size-sm); + padding: 0; + border-color: var(--color-error); + background: var(--color-error); + outline-color: var(--color-error); +} + +.telemetry { + margin: var(--s-2) 0; + font-size: var(--font-2xs); + color: var(--color-text-high); + + & summary { + cursor: pointer; + } + + & table { + margin-top: var(--s-1-5); + border-collapse: collapse; + + & th, + & td { + padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0; + text-align: right; + font-variant-numeric: tabular-nums; + + &:first-child { + text-align: left; + } + } + } +} + +@container scanner (width < 700px) { + .vodItem { + flex-wrap: wrap; + } +} diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx index eaa590d2c..7d50b3464 100644 --- a/app/features/scanner/components/VodPage.tsx +++ b/app/features/scanner/components/VodPage.tsx @@ -39,6 +39,7 @@ import { invalidObjectiveEvents, } from "../core/match-builder"; import { TimelineBuilder } from "../core/timeline/index"; +import scannerStyles from "../scanner.module.css"; import { scannerSearchParams } from "../scanner-search-params"; import type { SendStatus } from "../store/events"; import { @@ -72,6 +73,7 @@ import { } from "./sendou-ingest"; import { sendouUpload } from "./sendou-upload"; import { thumbnailFromBlob } from "./thumbnail"; +import styles from "./VodPage.module.css"; /** The scan knows the on-screen sides only, not who is playing. */ const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const; @@ -551,7 +553,9 @@ export function VodPage({
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
{ e.preventDefault(); setOver(true); @@ -579,17 +583,18 @@ export function VodPage({ />
-
+
{showVodView ? ( <> {source === "stored" ? "saved" : status} @@ -600,7 +605,7 @@ export function VodPage({ : null} {progress ? ( - + {formatTime(progress.t)} / {formatTime(progress.duration)} {progress.duration > 0 ? ` (${Math.round((progress.t / progress.duration) * 100)}%)` @@ -611,13 +616,13 @@ export function VodPage({ ) : null} {upload?.url ? ( - +
- {error ?

{error}

: null} + {error ?

{error}

: null} {showVodView && telemetry ? ( ) : null} {!showVodView ? ( -
+
{vods.length === 0 ? ( -

+

No saved VoDs yet — scan one and it will show up here.

) : null} {vods.map((vod) => ( -
- {vod.name} - +
+ {vod.name} + {vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "} {formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)} @@ -684,7 +689,7 @@ export function VodPage({ variant="destructive" size="small" shape="square" - className="vod-delete" + className={styles.vodDelete} icon={} aria-label="Delete" /> @@ -694,7 +699,7 @@ export function VodPage({
) : null}
-
+
{matches.length === 0 ? ( -

+

{status === "scanning" ? "Scanning — matches appear here as scoreboards are detected." : "No matches found in this VoD."} @@ -840,7 +845,7 @@ function ExportMenu({ trigger={ } - className="icon-menu" + className={scannerStyles.iconMenu} aria-label="Export" /> } @@ -884,7 +889,7 @@ function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) { ); const coveredS = telemetry.activeVideoS + telemetry.skimVideoS; return ( -

+
telemetry · analyzed {telemetry.analyzedFrames}/ {telemetry.decodedFrames} decoded frames diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css deleted file mode 100644 index e6ac4db3b..000000000 --- a/app/features/scanner/components/styles.css +++ /dev/null @@ -1,1396 +0,0 @@ -/* -Scanner feature styles, ported from emberz. Scoped under .scanner-app so nothing -leaks into the host app; design tokens come from sendou.ink's own -app/styles/vars.css (the emberz copies of those tokens were dropped). -*/ -.scanner-app { - container: scanner / inline-size; -} - -.scanner-app .topbar { - display: flex; - align-items: center; - gap: var(--s-6); - padding-bottom: var(--s-3); - border-bottom: var(--border-width) solid var(--color-bg-high); - margin-bottom: var(--s-4); -} - -.scanner-app .topbar nav { - display: flex; - gap: var(--s-2); -} - -.scanner-app .topbar nav a { - color: var(--color-text-high); - font-size: var(--font-xs); - font-weight: var(--weight-bold); - text-decoration: none; - padding: var(--s-1) var(--s-2-5); - border-radius: var(--radius-field); -} - -.scanner-app .topbar nav a:hover { - color: var(--color-text); -} - -.scanner-app .topbar nav a:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; -} - -.scanner-app .topbar nav a.active { - color: var(--color-text-accent); - background: var(--color-bg-high); -} - -.scanner-app button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--s-1-5); - border: var(--border-style-accent); - border-radius: var(--radius-field); - appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); - cursor: pointer; - font-family: inherit; - font-size: var(--font-xs); - font-weight: var(--weight-bold); - padding: 0 var(--field-padding); - height: var(--field-size-sm); - white-space: nowrap; - user-select: none; - - & > svg { - width: 16px; - height: 16px; - } -} - -.scanner-app button.outlined { - background: transparent; - color: var(--color-text-accent); -} - -.scanner-app button:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; -} - -.scanner-app button:active { - transform: translateY(1px); -} - -.scanner-app button:disabled { - cursor: not-allowed; - opacity: 0.5; - transform: initial; -} - -/* the global select is full width, which would break the controls row */ -.scanner-app select { - appearance: none; - width: auto; - max-width: 20rem; - border: var(--border-style); - border-radius: var(--radius-field); - background-color: var(--color-bg); - background-image: var(--field-icon-chevron); - background-position: center right var(--field-padding); - background-size: var(--field-size-icon) auto; - background-repeat: no-repeat; - color: var(--color-text); - cursor: pointer; - font-family: inherit; - font-size: var(--font-xs); - height: var(--field-size-sm); - padding: 0 calc(var(--field-size-icon) + var(--field-padding) + var(--s-2)) 0 - var(--field-padding); - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - outline: none; -} - -.scanner-app select:focus-within { - outline: var(--focus-ring); - outline-offset: 1px; -} - -.scanner-app select:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.scanner-app .controls { - display: flex; - gap: var(--s-2); - align-items: center; - margin-bottom: var(--s-3); - flex-wrap: wrap; -} - -.scanner-app .status { - padding: var(--s-0-5) var(--s-3); - border-radius: var(--radius-full); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - border: var(--border-style); -} - -.scanner-app .status.idle { - color: var(--color-text-high); -} - -.scanner-app .status.watching { - color: var(--color-info-high); - border-color: var(--color-info-low); - background: var(--color-info-low); -} - -.scanner-app .status.detected { - display: inline-flex; - align-items: center; - gap: 5px; - color: var(--color-success-high); - border-color: var(--color-success-low); - background: var(--color-success-low); -} - -.scanner-app .live-layout { - display: grid; - grid-template-columns: minmax(320px, 640px) minmax(0, 1fr); - gap: var(--s-4); - align-items: start; -} - -.scanner-app video.preview, -.scanner-app canvas.preview { - width: 100%; - background: #000; - border-radius: var(--radius-box); - border: var(--border-width) solid var(--color-bg-high); -} - -.scanner-app .feed { - display: flex; - flex-direction: column; - gap: var(--s-3); -} - -.scanner-app .match-list { - display: flex; - flex-direction: column; - gap: var(--s-3); -} - -.scanner-app .card { - background: var(--color-bg-high); - border-radius: var(--radius-box); - padding: var(--s-4); -} - -.scanner-app .card .meta { - display: flex; - flex-wrap: wrap; - gap: var(--s-2) var(--s-4); - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - font-variant-numeric: tabular-nums; - margin-bottom: var(--s-2); - align-items: center; - - /* time + confidence + event type read as one unit, the detail text after it - sits further away */ - & .meta-pills { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--s-1); - } - - /* event-type chip: neutral like the match chips, the icon carries the accent */ - & .status.detected { - gap: 5px; - padding: var(--s-0-5) var(--s-2-5); - color: var(--color-text); - border-color: var(--color-border); - background: var(--color-bg); - - & .event-type-icon { - color: var(--color-text-accent); - } - } - - /* time + confidence chips share the event-type chip look; icon documents - the value, full label on hover via title */ - & .meta-chip { - display: inline-flex; - align-items: center; - gap: 5px; - padding: var(--s-0-5) var(--s-2-5); - border-radius: var(--radius-full); - border: var(--border-style); - background: var(--color-bg); - color: var(--color-text); - font-weight: var(--weight-bold); - - & .meta-chip-icon { - color: var(--color-text-accent); - } - } -} - -.scanner-app .card img.thumb { - display: block; - height: 40px; - width: auto; - border-radius: var(--radius-field); -} - -.scanner-app .teams { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); - gap: var(--s-2); - - /* single hug-content box (own-results cards) */ - &.solo { - grid-template-columns: max-content; - } - - /* single full-width box (death cards) */ - &.death { - grid-template-columns: 1fr; - } -} - -/* sendou.ink send status strip under a feed card */ -.scanner-app .send-wrap { - display: flex; - flex-direction: column; - gap: var(--s-0-5); -} - -.scanner-app .send-strip { - display: flex; - gap: var(--s-3); - align-items: center; - padding: var(--s-1) var(--s-4); - border-radius: var(--radius-box); - background: var(--color-bg-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); -} - -.scanner-app .send-strip.sent { - color: var(--color-success-high); - background: var(--color-success-low); -} - -.scanner-app .send-strip.failed { - color: var(--color-error-high); - background: var(--color-error-low); -} - -.scanner-app .send-strip.queued, -.scanner-app .send-strip.sending { - color: var(--color-info-high); - background: var(--color-info-low); -} - -.scanner-app .score { - color: var(--color-text-high); - font-size: var(--font-2xs); -} - -.scanner-app .error { - color: var(--color-error); -} - -.scanner-app .send-strip .error { - color: inherit; - font-weight: var(--weight-body); -} - -/* lighter than a solid slab: translucent fill + hairline so the boxes read - as grouping, not chrome */ -.scanner-app .team { - border-radius: var(--radius-field); - padding: var(--s-2) var(--s-2-5); - background: color-mix(in oklab, var(--color-bg) 45%, transparent); - border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent); - min-width: 0; - overflow-x: auto; -} - -.scanner-app .minimap-player { - display: flex; - align-items: center; - gap: var(--s-2); - padding-block: 3px; - font-size: var(--font-xs); - - & .weapon-missing { - flex-shrink: 0; - width: 24px; - text-align: center; - color: var(--color-text-high); - } - - & .name { - flex: 1 1 0; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - & .abilities { - display: flex; - align-items: center; - gap: var(--s-0-5); - flex-shrink: 0; - } - - & .status-chip { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border-radius: var(--radius-full); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - line-height: 1; - - &.dead { - background: var(--color-error-low); - color: var(--color-error-high); - } - - &.special { - background: var(--color-warning-low); - color: var(--color-warning-high); - } - } -} - -/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */ -.scanner-app .slot-marker { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: var(--radius-full); - background: var(--color-bg-badge); - color: #fff; - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - line-height: 1; - - & svg { - width: 13px; - height: 13px; - } - - &.right svg { - transform: rotate(90deg); - } - - &.down svg { - transform: rotate(180deg); - } - - &.left svg { - transform: rotate(-90deg); - } -} - -.scanner-app .death-body { - display: flex; - align-items: center; - gap: var(--s-3); - padding-block: var(--s-0-5); - - & .death-info { - display: flex; - flex-direction: column; - gap: var(--s-0-5); - min-width: 0; - - & .death-name { - font-size: var(--font-xs); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - & .death-weapon { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); - } - } - - & .death-abilities { - display: flex; - align-items: center; - gap: var(--s-2-5); - margin-inline-start: auto; - flex-wrap: wrap; - - & .gear { - display: flex; - align-items: center; - gap: var(--s-0-5); - } - } -} - -.scanner-app .team h3 { - margin: 0 0 var(--s-1-5); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-high); -} - -.scanner-app .team.win h3 { - color: var(--color-error-high); -} - -.scanner-app .team.lose h3 { - color: var(--color-info-high); -} - -.scanner-app table.players { - width: 100%; - border-collapse: collapse; - font-size: var(--font-xs); -} - -.scanner-app table.players td { - padding: var(--s-0-5) var(--s-1-5); - white-space: nowrap; -} - -.scanner-app table.players td.num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.scanner-app .teams.solo table.players { - width: auto; -} - -/* round black pucks, same language as the match header weapon row */ -.scanner-app img.weapon-icon { - width: 28px; - height: 28px; - vertical-align: middle; - background: var(--color-bg-badge); - border-radius: var(--radius-full); - padding: var(--s-0-5); -} - -.scanner-app .minimap-player img.weapon-icon { - flex-shrink: 0; - width: 24px; - height: 24px; -} - -.scanner-app .weapon-cell { - display: inline-flex; - align-items: center; - gap: var(--s-1); -} - -.scanner-app button.ability-trigger { - height: auto; - padding: var(--s-0-5); - background: var(--color-bg-higher); - border: 1px solid var(--color-border-high); - border-radius: var(--radius-selector); -} - -.scanner-app button.ability-trigger img { - display: block; - width: var(--field-size-icon); - height: var(--field-size-icon); -} - -.scanner-app .vod-list { - display: flex; - flex-direction: column; - gap: var(--s-2); -} - -.scanner-app .vod-item { - display: flex; - align-items: center; - gap: var(--s-3); - background: var(--color-bg-high); - border-radius: var(--radius-box); - padding: var(--s-3) var(--s-4); -} - -.scanner-app .vod-item .name { - font-size: var(--font-sm); - font-weight: var(--weight-bold); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.scanner-app .vod-item .score { - flex: 1; -} - -/* the shared destructive variant loses to `.scanner-app button` on specificity */ -.scanner-app button.vod-delete { - width: var(--field-size-sm); - padding: 0; - border-color: var(--color-error); - background: var(--color-error); - outline-color: var(--color-error); -} - -.scanner-app .dropzone { - border: var(--border-width) dashed var(--color-border-high); - border-radius: var(--radius-box); - padding: var(--s-8); - text-align: center; - color: var(--color-text-high); - font-size: var(--font-sm); - font-weight: var(--weight-semi); - margin-bottom: var(--s-4); -} - -.scanner-app .dropzone label { - display: inline; - font-size: inherit; - font-weight: inherit; - margin-block-end: 0; - text-decoration: underline; - cursor: pointer; -} - -.scanner-app .dropzone.over { - border-color: var(--color-text-accent); - color: var(--color-text-accent); -} - -.scanner-app .screenshot-frame { - position: relative; - margin-bottom: var(--s-4); -} - -.scanner-app .screenshot-frame canvas { - width: 100%; - border-radius: var(--radius-box); - border: var(--border-width) solid var(--color-bg-high); -} - -.scanner-app table.inspector { - width: 100%; - border-collapse: collapse; - font-size: var(--font-xs); -} - -.scanner-app table.inspector th { - text-align: left; - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - padding: var(--s-1-5); - border-bottom: var(--border-style); -} - -.scanner-app table.inspector td { - padding: var(--s-1-5); - border-bottom: 1px solid var(--color-bg-high); - vertical-align: middle; -} - -.scanner-app table.inspector canvas { - display: block; - background: #000; - border-radius: var(--radius-selector); -} - -.scanner-app .candidates { - display: flex; - gap: var(--s-2); - align-items: center; -} - -.scanner-app .candidates .cand { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--s-0-5); - font-size: var(--font-2xs); - color: var(--color-text-high); -} - -.scanner-app .weapon-candidates .cand:first-child { - color: var(--color-success); -} - -/* ---- screenshot tab: per-detector gate results ---- */ - -.scanner-app .gate-list { - display: grid; - grid-template-columns: repeat(3, max-content) 1fr; - column-gap: var(--s-4); - row-gap: var(--s-1-5); - align-items: baseline; - font-size: var(--font-xs); - margin-block: var(--s-4); -} - -.scanner-app .gate-row { - display: contents; -} - -.scanner-app .gate-badge { - justify-self: start; - align-self: center; - padding: 0 var(--s-1-5); - border-radius: var(--radius-selector); - background: var(--color-bg-high); - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - text-transform: uppercase; - white-space: nowrap; -} - -.scanner-app .gate-row.fired .gate-badge { - background: var(--color-success-low); - color: var(--color-success-high); -} - -.scanner-app .gate-row:not(.fired) .gate-name { - color: var(--color-text-high); -} - -.scanner-app .gate-row.fired .gate-name { - font-weight: var(--weight-bold); -} - -.scanner-app .gate-score { - font-variant-numeric: tabular-nums; - color: var(--color-text-high); - font-size: var(--font-2xs); -} - -.scanner-app .gate-note { - color: var(--color-text-high); - font-size: var(--font-2xs); -} - -/* ---- screenshot tab: fired detector's parse detail ---- */ - -.scanner-app .detail { - display: flex; - flex-direction: column; - gap: var(--s-3); - margin-block: var(--s-4); -} - -.scanner-app .detail-stats { - display: flex; - flex-wrap: wrap; - gap: var(--s-1-5); -} - -.scanner-app .stat { - display: inline-flex; - align-items: baseline; - gap: var(--s-1-5); - padding: var(--s-1) var(--s-2); - border-radius: var(--radius-field); - background: var(--color-bg-high); - font-size: var(--font-xs); -} - -.scanner-app .stat-label { - color: var(--color-text-high); - font-size: var(--font-2xs); - white-space: nowrap; -} - -.scanner-app .stat-value { - font-weight: var(--weight-bold); -} - -.scanner-app .stat-raw { - color: var(--color-text-high); - font-size: var(--font-2xs); -} - -.scanner-app .detail-crops { - display: flex; - flex-wrap: wrap; - gap: var(--s-3); - align-items: end; -} - -.scanner-app .detail-crops figure { - margin: 0; - display: flex; - flex-direction: column; - gap: var(--s-1); - min-width: 0; -} - -.scanner-app .detail-crops canvas { - display: block; - max-width: 100%; - background: #000; - border-radius: var(--radius-selector); - border: var(--border-width) solid var(--color-bg-high); -} - -.scanner-app .detail-crops figcaption { - color: var(--color-text-high); - font-size: var(--font-2xs); -} - -.scanner-app .status-slots { - display: inline-flex; - gap: var(--s-2); - align-self: center; -} - -.scanner-app .status-side { - display: inline-flex; - gap: var(--s-0-5); -} - -.scanner-app .status-slot { - display: inline-flex; - align-items: center; - justify-content: center; - inline-size: 1.25rem; - block-size: 1.25rem; - border-radius: var(--radius-selector); - background: var(--color-bg-higher); - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); -} - -.scanner-app .status-slot.dead { - background: var(--color-error-low); - color: var(--color-error-high); -} - -.scanner-app .status-slot.special { - background: var(--color-warning-low); - color: var(--color-warning-high); -} - -/* internal navigation styled like the sibling action buttons */ -.scanner-app .link-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--s-1-5); - padding: 0 var(--field-padding); - height: var(--field-size-sm); - border: var(--border-style-accent); - border-radius: var(--radius-field); - background: var(--color-text-accent); - color: var(--color-text-inverse); - font-size: var(--font-xs); - font-weight: var(--weight-bold); - white-space: nowrap; - text-decoration: none; - - & > svg { - width: 16px; - height: 16px; - } - - &:active { - transform: translateY(1px); - } - - &:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; - } -} - -/* secondary actions live behind this icon-only trigger, pushed to the row's end */ -.scanner-app button.icon-menu { - margin-inline-start: auto; - width: var(--field-size-sm); - padding: 0; - border: var(--border-style); - background: var(--color-bg-higher); - color: var(--color-text); - - &:hover { - color: var(--color-text-accent); - } - - & > svg { - width: var(--field-size-icon); - height: var(--field-size-icon); - margin: 0; - } -} - -/* ── ingested matches feed ─────────────────────────────────────────── */ - -.scanner-app .set-divider { - display: flex; - align-items: center; - gap: var(--s-2-5); - margin-top: var(--s-1-5); - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - text-transform: uppercase; - letter-spacing: 0.08em; - - &::before, - &::after { - content: ""; - flex: 1; - height: var(--border-width); - background-color: var(--color-border); - } -} - -.scanner-app .match-card { - container: match-card / inline-size; - position: sticky; - top: var(--layout-sticky-top); - z-index: 1; - overflow: hidden; - border-radius: var(--radius-box); - background-color: var(--color-bg-high); - - /* only cards the scan just formed animate in — see MatchCard's `enter` */ - &.enter { - 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: var(--s-2) var(--s-3); - padding: var(--s-3) var(--s-4); - 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: var(--s-1); - /* 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-title { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--s-1) var(--s-2); -} - -.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; - flex-wrap: wrap; - gap: var(--s-0-5); - - /* a team is one wrapping unit: the eight never break up mid-team */ - & .weapon-row { - display: flex; - align-items: center; - gap: var(--s-0-5); - } - - & .vs { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); - margin: 0 5px; - } - - & img.weapon-icon { - flex-shrink: 0; - width: 32px; - height: 32px; - border-radius: var(--radius-full); - padding: var(--s-0-5); - - &.pov { - outline: var(--border-style-high); - background-color: var(--color-bg-high); - outline-offset: 1px; - } - } -} - -.scanner-app .match-side { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: var(--s-1-5); - margin-inline-start: auto; - align-self: stretch; - flex-shrink: 0; -} - -.scanner-app .match-score { - font-weight: var(--weight-extra); - font-variant-numeric: tabular-nums; - line-height: 1; - padding: 5px var(--s-3); - 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: var(--s-1-5); - padding: var(--s-0-5) var(--s-2-5); - border-radius: var(--radius-full); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - border: var(--border-style); - 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; - } - } - - &.in-progress .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); - - & a { - color: inherit; - text-decoration: underline; - text-underline-offset: 2px; - - &:hover { - color: var(--color-text-high); - } - } - } - - &.unlinked { - color: var(--color-warning-high); - border-color: var(--color-warning-low); - background-color: var(--color-warning-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; - margin-block-start: auto; - 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: var(--field-size-icon); - height: var(--field-size-icon); - } - - &:hover { - color: var(--color-text); - background: color-mix(in oklab, var(--color-bg) 78%, transparent); - } - - &.expanded { - transform: rotate(180deg); - } -} - -/* the frame preview: an unstyled button so the image itself is the target */ -.scanner-app .card .meta button.thumb-button { - height: auto; - padding: 0; - border: none; - background: none; - margin-inline-start: auto; - border-radius: var(--radius-field); - - &:hover { - filter: brightness(1.2); - } -} - -.scanner-app .match-error { - padding: 0 var(--s-4) var(--s-2-5); - font-size: var(--font-2xs); - color: var(--color-error); -} - -.scanner-app .match-card-group { - display: flex; - flex-direction: column; - gap: var(--s-2); -} - -/* source event cards, nested under their match card via an indent rail */ -.scanner-app .match-events { - display: flex; - flex-direction: column; - gap: var(--s-2); - margin-inline-start: var(--s-2-5); - padding-inline-start: var(--s-3); - border-inline-start: var(--border-width) solid var(--color-border); - animation: scanner-detail-in 0.25s ease both; - - & .card { - padding: var(--s-2-5) 14px; - min-width: 0; - } -} - -@keyframes scanner-detail-in { - from { - opacity: 0; - transform: translateY(-4px); - } -} - -.scanner-app .events-summary { - display: flex; - align-items: center; - gap: var(--s-2-5); - flex-wrap: wrap; - padding: var(--s-0-5) var(--s-1); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); -} - -.scanner-app .events-summary-type { - display: inline-flex; - align-items: center; - gap: 5px; - font-variant-numeric: tabular-nums; -} - -.scanner-app .events-summary-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: var(--radius-full); - background: var(--color-bg-higher); - 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-inline-end: var(--s-1-5); - animation: scanner-pulse 1.6s ease-in-out infinite; -} - -/* the frame dialog portals outside .scanner-app, so these stay unscoped */ -.scanner-frame-dialog { - width: max-content; - max-width: min(96vw, 1400px); -} - -.scanner-frame-dialog img.frame-full { - display: block; - max-width: 100%; - max-height: calc(80dvh - 9rem); - border-radius: var(--radius-field); -} - -.scanner-frame-dialog .frame-actions { - display: flex; - justify-content: center; - gap: var(--s-3); - margin-block-start: var(--s-3); -} - -.scanner-app .telemetry { - margin: var(--s-2) 0; - font-size: var(--font-2xs); - color: var(--color-text-high); -} - -.scanner-app .telemetry summary { - cursor: pointer; -} - -.scanner-app .telemetry table { - margin-top: var(--s-1-5); - border-collapse: collapse; -} - -.scanner-app .telemetry table th, -.scanner-app .telemetry table td { - padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0; - text-align: right; - font-variant-numeric: tabular-nums; -} - -.scanner-app .telemetry table th:first-child, -.scanner-app .telemetry table td:first-child { - text-align: left; -} - -/* ── narrow viewports ──────────────────────────────────────────────── */ - -/* a 640px wide preview would starve the feed next to it: split evenly instead */ -@container scanner (width < 1000px) { - .scanner-app .live-layout { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - } - - /* the controls wrap here, where a trigger pushed to the end reads as stray */ - .scanner-app button.icon-menu { - margin-inline-start: 0; - } -} - -/* the two teams' weapons no longer fit beside each other: one row each */ -@container match-card (width < 460px) { - .scanner-app .match-weapons { - flex-direction: column; - align-items: flex-start; - row-gap: var(--s-1); - - & .vs { - display: none; - } - } -} - -@container scanner (width < 700px) { - .scanner-app .live-layout { - grid-template-columns: minmax(0, 1fr); - } - - /* stacked, the preview would otherwise push the feed off the screen */ - .scanner-app video.preview, - .scanner-app canvas.preview { - max-height: 45vh; - object-fit: contain; - } - - .scanner-app .vod-item { - flex-wrap: wrap; - } - - /* the ROI crops set the width; scroll them instead of the whole page */ - .scanner-app table.inspector { - display: block; - overflow-x: auto; - } -} - -@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-events, - .scanner-app .match-chip.live .dot, - .scanner-app .match-chip.in-progress .dot, - .scanner-app .match-chip.queued .dot, - .scanner-app .match-chip.sending .dot, - .scanner-app .status.watching::before { - animation: none; - } -} diff --git a/app/features/scanner/scanner.module.css b/app/features/scanner/scanner.module.css new file mode 100644 index 000000000..77da46026 --- /dev/null +++ b/app/features/scanner/scanner.module.css @@ -0,0 +1,216 @@ +/* +Chrome shared by the scanner's pages and cards. Everything is scoped under +`.app`, whose container name the pages' container queries key off; design +tokens come from sendou.ink's app/styles/vars.css. +*/ + +.app { + container: scanner / inline-size; + + /* the app's own buttons, kept at :where() specificity so any component + class overrides it without having to win an ordering race */ + & :where(button) { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-1-5); + border: var(--border-style-accent); + border-radius: var(--radius-field); + appearance: none; + background: var(--color-text-accent); + color: var(--color-text-inverse); + cursor: pointer; + font-family: inherit; + font-size: var(--font-xs); + font-weight: var(--weight-bold); + padding: 0 var(--field-padding); + height: var(--field-size-sm); + white-space: nowrap; + user-select: none; + + & > svg { + width: 16px; + height: 16px; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } + + &:active { + transform: translateY(1px); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + transform: initial; + } + } +} + +button.outlined { + background: transparent; + color: var(--color-text-accent); +} + +/* secondary actions live behind this icon-only trigger, pushed to the row's end. + Sized explicitly: it is a SendouButton, which brings a competing base look */ +button.iconMenu { + margin-inline-start: auto; + width: var(--field-size-sm); + height: var(--field-size-sm); + border-radius: var(--radius-field); + padding: 0; + border: var(--border-style); + background: var(--color-bg-higher); + color: var(--color-text); + + &:hover { + color: var(--color-text-accent); + } + + & > svg { + width: var(--field-size-icon); + height: var(--field-size-icon); + margin: 0; + } +} + +.controls { + display: flex; + gap: var(--s-2); + align-items: center; + margin-bottom: var(--s-3); + flex-wrap: wrap; +} + +.status { + padding: var(--s-0-5) var(--s-3); + border-radius: var(--radius-full); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + border: var(--border-style); +} + +.idle { + color: var(--color-text-high); +} + +.watching { + color: var(--color-info-high); + border-color: var(--color-info-low); + background: var(--color-info-low); + + &::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + border-radius: var(--radius-full); + background: currentColor; + margin-inline-end: var(--s-1-5); + animation: scanner-pulse 1.6s ease-in-out infinite; + } +} + +.detected { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--color-success-high); + border-color: var(--color-success-low); + background: var(--color-success-low); +} + +@keyframes scanner-pulse { + 50% { + opacity: 0.25; + transform: scale(0.8); + } +} + +.liveLayout { + display: grid; + grid-template-columns: minmax(320px, 640px) minmax(0, 1fr); + gap: var(--s-4); + align-items: start; +} + +.preview { + width: 100%; + background: #000; + border-radius: var(--radius-box); + border: var(--border-width) solid var(--color-bg-high); +} + +.feed { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.score { + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.error { + color: var(--color-error); +} + +.dropzone { + border: var(--border-width) dashed var(--color-border-high); + border-radius: var(--radius-box); + padding: var(--s-8); + text-align: center; + color: var(--color-text-high); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + margin-bottom: var(--s-4); + + & label { + display: inline; + font-size: inherit; + font-weight: inherit; + margin-block-end: 0; + text-decoration: underline; + cursor: pointer; + } +} + +.over { + border-color: var(--color-text-accent); + color: var(--color-text-accent); +} + +/* a 640px wide preview would starve the feed next to it: split evenly instead */ +@container scanner (width < 1000px) { + .liveLayout { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + /* the controls wrap here, where a trigger pushed to the end reads as stray */ + button.iconMenu { + margin-inline-start: 0; + } +} + +@container scanner (width < 700px) { + .liveLayout { + grid-template-columns: minmax(0, 1fr); + } + + /* stacked, the preview would otherwise push the feed off the screen */ + .preview { + max-height: 45vh; + object-fit: contain; + } +} + +@media (prefers-reduced-motion: reduce) { + .watching::before { + animation: none; + } +}