+
{dbg?.weapon?.top.map((c) => (
-
+
{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 ? (
-
+
Add VoD
) : null}
{upload?.problem ? (
-
+
upload unavailable: {upload.problem}
) : null}
@@ -634,8 +639,8 @@ export function VodPage({
) : null}
{resultsSend ? (
@@ -655,21 +660,21 @@ export function VodPage({
>
) : null}
- {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;
+ }
+}
|