Split styles

This commit is contained in:
Kalle
2026-08-09 14:35:51 +03:00
parent 1b3f9c84b6
commit 52c65412c5
35 changed files with 1689 additions and 1596 deletions

View File

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

View File

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

View File

@@ -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 (
<table className="players">
<table className={eventCardStyles.players}>
<tbody>
{abilities.map((row, i) => (
<tr key={i}>
@@ -48,7 +50,7 @@ export function AbilityPopover({
<SendouPopover
trigger={
<Button
className="ability-trigger"
className={styles.abilityTrigger}
aria-label="Show abilities (from death events)"
>
<Ability ability={trigger} size="TINY" />

View File

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

View File

@@ -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<ScannerTab, string> = {
live: "Live",
@@ -36,14 +38,14 @@ export function App() {
);
return (
<div className="scanner-app">
<header className="topbar">
<div className={scannerStyles.app}>
<header className={styles.topbar}>
<nav>
{SCANNER_TABS.map((tabOption) => (
<Link
key={tabOption}
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
className={tab === tabOption ? "active" : ""}
className={clsx({ [styles.active]: tab === tabOption })}
>
{TAB_LABELS[tabOption]}
</Link>

View File

@@ -0,0 +1,40 @@
.body {
display: flex;
align-items: center;
gap: var(--s-3);
padding-block: var(--s-0-5);
}
.info {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
min-width: 0;
}
.name {
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weapon {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.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);
}

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
@@ -5,6 +6,8 @@ import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import styles from "./DeathCard.module.css";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { weaponLabel } from "./labels";
@@ -25,8 +28,8 @@ export function DeathCard(props: {
const weaponName = weaponLabel(data.weaponType, data.weaponId);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -41,26 +44,26 @@ export function DeathCard(props: {
fixture={{ data, type: "Death" }}
/>
</div>
<div className="teams death">
<div className="team">
<div className="death-body">
<div className={clsx(eventCardStyles.teams, eventCardStyles.death)}>
<div className={eventCardStyles.team}>
<div className={styles.body}>
{data.weaponId !== null && data.weaponType === "MAIN" ? (
<WeaponImage
weaponSplId={data.weaponId as MainWeaponId}
variant="build"
size={28}
className="weapon-icon"
className={eventCardStyles.weaponIcon}
/>
) : null}
<div className="death-info">
<span className="death-name">
<div className={styles.info}>
<span className={styles.name}>
splatted by <b>{data.name ?? "?"}</b>
</span>
<span className="death-weapon">{weaponName ?? "?"}</span>
<span className={styles.weapon}>{weaponName ?? "?"}</span>
</div>
<div className="death-abilities">
<div className={styles.abilities}>
{data.abilities.map((row, i) => (
<div key={i} className="gear">
<div key={i} className={styles.gear}>
{row.map((id, j) => (
<Ability
key={j}

View File

@@ -0,0 +1,142 @@
/* card chrome shared by every detected-event card, plus the sendou.ink send
status strip the feed pages hang under one */
.card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
/* MatchCard's nested event list tightens this */
padding: var(--scanner-card-padding, var(--s-4));
min-width: 0;
}
.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;
}
.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;
}
/* lighter than a solid slab: translucent fill + hairline so the boxes read
as grouping, not chrome */
.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;
& 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);
}
&.win h3 {
color: var(--color-error-high);
}
&.lose h3 {
color: var(--color-info-high);
}
}
.players {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& td {
padding: var(--s-0-5) var(--s-1-5);
white-space: nowrap;
&.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.solo .players {
width: auto;
}
.weaponCell {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
/* round black pucks, same language as the match card's weapon row */
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* sendou.ink send status strip under a feed card */
.sendWrap {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.sendStrip {
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);
}
.sent {
color: var(--color-success-high);
background: var(--color-success-low);
}
.failed {
color: var(--color-error-high);
background: var(--color-error-low);
}
.queued,
.sending {
color: var(--color-info-high);
background: var(--color-info-low);
}
.sendError {
color: inherit;
font-weight: var(--weight-body);
}

View File

@@ -39,6 +39,7 @@ import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import { newInspectKey, putInspectFrame } from "../store/inspect";
import { DeathCard } from "./DeathCard";
import styles from "./EventCard.module.css";
import type { FixtureData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { MapStartCard } from "./MapStartCard";
@@ -89,7 +90,7 @@ export function EventCard(props: {
const card = renderCard(type, data, shared, props.abilities);
if (!props.send && !props.onSend) return card;
return (
<div className="send-wrap">
<div className={styles.sendWrap}>
{card}
<SendStrip send={props.send} onSend={props.onSend} />
</div>
@@ -114,12 +115,14 @@ function SendStrip({
const state = send?.state;
const formatSentAt = useEventTimeFormatter();
return (
<div className={clsx("send-strip", state ?? "unsent")}>
<div className={clsx(styles.sendStrip, state ? styles[state] : null)}>
<span>
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}
</span>
{send?.error ? <span className="error">{send.error}</span> : null}
{send?.error ? (
<span className={styles.sendError}>{send.error}</span>
) : null}
{onSend && state !== "sent" && state !== "sending" ? (
<button type="button" onClick={onSend}>
{state === "failed" ? "Retry" : "Send"}

View File

@@ -43,5 +43,5 @@ export function EventTypeIcon({
size?: number;
}) {
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
return <Icon size={size} aria-hidden className="event-type-icon" />;
return <Icon size={size} aria-hidden />;
}

View File

@@ -0,0 +1,37 @@
.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);
}
.type {
display: inline-flex;
align-items: center;
gap: 5px;
font-variant-numeric: tabular-nums;
}
.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);
}
button.toggle {
border: none;
background: transparent;
color: var(--color-text-accent);
height: auto;
padding: 0;
font-size: var(--font-2xs);
}

View File

@@ -14,6 +14,7 @@ import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
import styles from "./EventsSummary.module.css";
import { EventTypeIcon } from "./EventTypeIcon";
const EVENT_TYPE_LABELS: Record<string, string> = {
@@ -40,23 +41,23 @@ export function EventsSummary({
const sorted = Object.entries(counts).toSorted((a, b) => b[1] - a[1]);
return (
<div className="events-summary">
<div className={styles.summary}>
{sorted.map(([type, count]) => {
const label = EVENT_TYPE_LABELS[type] ?? type;
return (
<span
key={type}
className="events-summary-type"
className={styles.type}
title={`${count} ${label}${count === 1 ? "" : "s"}`}
>
<span className="events-summary-icon">
<span className={styles.icon}>
<EventTypeIcon type={type} />
</span>
×{count}
</span>
);
})}
<button type="button" className="events-toggle" onClick={onToggle}>
<button type="button" className={styles.toggle} onClick={onToggle}>
{open ? "Hide events" : "Show events"}
</button>
</div>

View File

@@ -0,0 +1,39 @@
/* an unstyled button so the image itself is the target */
button.thumbButton {
height: auto;
padding: 0;
border: none;
background: none;
margin-inline-start: auto;
border-radius: var(--radius-field);
&:hover {
filter: brightness(1.2);
}
}
.thumb {
display: block;
height: 40px;
width: auto;
border-radius: var(--radius-field);
}
.dialog {
width: max-content;
max-width: min(96vw, 1400px);
}
.frameFull {
display: block;
max-width: 100%;
max-height: calc(80dvh - 9rem);
border-radius: var(--radius-field);
}
.frameActions {
display: flex;
justify-content: center;
gap: var(--s-3);
margin-block-start: var(--s-3);
}

View File

@@ -9,6 +9,7 @@ import { ExternalLink, FlaskConical } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import styles from "./FrameThumb.module.css";
import { type FixtureData, saveFixtureFromEvent } from "./fixture-export";
export function FrameThumb({
@@ -60,26 +61,26 @@ export function FrameThumb({
<>
<button
type="button"
className="thumb-button"
className={styles.thumbButton}
title="View frame"
onClick={show}
>
<img className="thumb" src={thumbnail} alt="analyzed frame" />
<img className={styles.thumb} src={thumbnail} alt="analyzed frame" />
</button>
{open ? (
<SendouDialog
isDismissable
aria-label="Analyzed frame"
className="scanner-frame-dialog"
className={styles.dialog}
onClose={() => setOpen(false)}
>
<img
className="frame-full"
className={styles.frameFull}
src={frameUrl ?? thumbnail}
alt="analyzed frame"
/>
{onInspect || onSaveFixture ? (
<div className="frame-actions">
<div className={styles.frameActions}>
{onInspect ? (
<SendouButton
size="small"

View File

@@ -0,0 +1,34 @@
/* the global select is full width, which would break the controls row */
.deviceSelect {
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;
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}

View File

@@ -28,6 +28,7 @@ import {
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import {
clearEvents,
deleteEvents,
@@ -43,6 +44,7 @@ import { EventCard } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import styles from "./LivePage.module.css";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
@@ -305,7 +307,7 @@ export function LivePage({
return (
<div>
<div className="controls">
<div className={scannerStyles.controls}>
{!running ? (
<>
<button
@@ -319,7 +321,7 @@ export function LivePage({
</button>
<button
type="button"
className="outlined"
className={scannerStyles.outlined}
onClick={() => void start(false)}
>
Start capture (no sending)
@@ -332,7 +334,7 @@ export function LivePage({
</button>
<button
type="button"
className="outlined"
className={scannerStyles.outlined}
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => {
@@ -345,7 +347,11 @@ export function LivePage({
</button>
</>
)}
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)}>
<select
className={styles.deviceSelect}
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
>
<option value="">Default camera (OBS Virtual Camera)</option>
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
@@ -354,17 +360,20 @@ export function LivePage({
))}
</select>
<span
className={clsx("status", {
detected: status === "detected",
watching: status === "watching",
idle: status !== "detected" && status !== "watching",
className={clsx(scannerStyles.status, {
[scannerStyles.detected]: status === "detected",
[scannerStyles.watching]: status === "watching",
[scannerStyles.idle]:
status !== "detected" && status !== "watching",
})}
>
{status}
{gateScore !== null ? ` · gate ${gateScore.toFixed(2)}` : null}
</span>
{liveSend ? (
<span className="status watching">sending matches live</span>
<span className={clsx(scannerStyles.status, scannerStyles.watching)}>
sending matches live
</span>
) : null}
<LiveMenu
canSaveFixture={running}
@@ -390,13 +399,20 @@ export function LivePage({
}}
/>
</div>
{error ? <p className="error">{error}</p> : null}
{sendouError ? <p className="error">{sendouError}</p> : null}
<div className="live-layout">
<video ref={videoRef} className="preview" muted playsInline />
<div className="feed">
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{sendouError ? (
<p className={scannerStyles.error}>{sendouError}</p>
) : null}
<div className={scannerStyles.liveLayout}>
<video
ref={videoRef}
className={scannerStyles.preview}
muted
playsInline
/>
<div className={scannerStyles.feed}>
{feed.length === 0 ? (
<p className="score">No detections yet.</p>
<p className={scannerStyles.score}>No detections yet.</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
@@ -533,7 +549,7 @@ function LiveMenu({
trigger={
<SendouButton
icon={<Ellipsis />}
className="icon-menu"
className={scannerStyles.iconMenu}
aria-label="More actions"
/>
}

View File

@@ -2,6 +2,7 @@ import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { modeLabel, stageLabel } from "./labels";
@@ -21,8 +22,8 @@ export function MapStartCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}

View File

@@ -0,0 +1,391 @@
.group {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.matchCard {
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);
&::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;
}
}
&.flashSent::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);
}
}
@keyframes scanner-shake {
20% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
80% {
transform: translateX(-2px);
}
}
.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;
}
.matchCard.flashFailed .main {
animation: scanner-shake 0.35s ease;
}
.mode {
flex-shrink: 0;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
}
.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;
}
.title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1) var(--s-2);
}
.stage {
font-size: var(--font-sm);
font-weight: var(--weight-extra);
line-height: 1.1;
}
.meta {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.weapons {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-0-5);
& .vs {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
margin: 0 5px;
}
}
/* a team is one wrapping unit: the eight never break up mid-team */
.weaponRow {
display: flex;
align-items: center;
gap: var(--s-0-5);
}
.weapon {
flex-shrink: 0;
width: 32px;
height: 32px;
vertical-align: middle;
background: var(--color-bg-badge);
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;
}
}
.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;
}
.matchScore {
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);
}
}
/* undo the scanner-wide solid button look for the banner icon button */
button.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);
}
}
.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;
}
}
&.inProgress .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);
}
}
.error {
padding: 0 var(--s-4) var(--s-2-5);
font-size: var(--font-2xs);
color: var(--color-error);
}
/* source event cards, nested under their match card via an indent rail */
.events {
--scanner-card-padding: var(--s-2-5) 14px;
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;
}
@keyframes scanner-detail-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.setDivider {
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);
}
}
/* the two teams' weapons no longer fit beside each other: one row each */
@container match-card (width < 460px) {
.weapons {
flex-direction: column;
align-items: flex-start;
row-gap: var(--s-1);
& .vs {
display: none;
}
}
}
@media (prefers-reduced-motion: reduce) {
.matchCard,
.matchCard.sending::after,
.matchCard.flashSent::after,
.matchCard.flashFailed .main,
.events,
.chip.live .dot,
.chip.inProgress .dot,
.chip.queued .dot,
.chip.sending .dot {
animation: none;
}
}

View File

@@ -21,6 +21,7 @@ import type { ScannerMatch } from "../core/scanner-match";
import type { SendStatus } from "../store/events";
import { formatTime, useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import styles from "./MatchCard.module.css";
/** the game score a knockout wins at */
const KO_MATCH_SCORE = 100;
@@ -89,24 +90,24 @@ export function MatchCard({
const inner = (
<>
<div className="match-card-main">
<div className={styles.main}>
{match.mode !== null ? (
<ModeImage mode={match.mode} size={30} className="match-mode" />
<ModeImage mode={match.mode} size={30} className={styles.mode} />
) : null}
<div className="match-headline">
<div className="match-title">
<div className="match-stage">
<div className={styles.headline}>
<div className={styles.title}>
<div className={styles.stage}>
{stageLabel(match.stage) ?? "Unknown stage"}
</div>
<StatusChip send={send} skipReason={skipReason} live={live} />
</div>
{meta ? <div className="match-meta">{meta}</div> : null}
{meta ? <div className={styles.meta}>{meta}</div> : null}
<TeamWeapons match={match} />
</div>
<div className="match-side">
<div className={styles.side}>
{live ? (
<span className="match-chip live">
<span className="dot" />
<span className={clsx(styles.chip, styles.live)}>
<span className={styles.dot} />
live
</span>
) : (
@@ -125,7 +126,7 @@ export function MatchCard({
size="small"
shape="circle"
icon={<ChevronDown />}
className={clsx("match-expand", { expanded })}
className={clsx(styles.expand, { [styles.expanded]: expanded })}
aria-expanded={expanded}
aria-label={expanded ? "Hide events" : "Show events"}
onPress={() => setExpanded(!expanded)}
@@ -134,17 +135,21 @@ export function MatchCard({
</div>
</div>
{send?.state === "failed" && send.error ? (
<div className="match-error">{send.error}</div>
<div className={styles.error}>{send.error}</div>
) : null}
</>
);
const className = clsx("match-card", send?.state, {
enter,
live,
"flash-sent": flash === "sent",
"flash-failed": flash === "failed",
});
const className = clsx(
styles.matchCard,
send?.state ? styles[send.state] : null,
{
[styles.enter]: enter,
[styles.live]: live,
[styles.flashSent]: flash === "sent",
[styles.flashFailed]: flash === "failed",
},
);
const card =
match.stage !== null ? (
@@ -157,16 +162,16 @@ export function MatchCard({
if (!children) return card;
return (
<div className="match-card-group">
<div className={styles.group}>
{card}
{expanded ? <div className="match-events">{children}</div> : null}
{expanded ? <div className={styles.events}>{children}</div> : null}
</div>
);
}
/** Labeled rule above the newest card of each set in the feed. */
export function SetDivider({ number }: { number: number }) {
return <div className="set-divider">Set {number}</div>;
return <div className={styles.setDivider}>Set {number}</div>;
}
function timeRangeLabel(match: ScannerMatch): string {
@@ -186,8 +191,8 @@ function Score({
if (match.matchScores === null) {
if (!inProgress) return null;
return (
<span className="match-chip in-progress">
<span className="dot" />
<span className={clsx(styles.chip, styles.inProgress)}>
<span className={styles.dot} />
in progress
</span>
);
@@ -200,12 +205,12 @@ function Score({
// scoreboard-sourced matches list the winners first
const winnerKnown = match.winner !== null;
return (
<div className="match-score">
<span className={winnerKnown ? "win" : undefined}>
<div className={styles.matchScore}>
<span className={winnerKnown ? styles.win : undefined}>
{scoreLabel(alpha, objectiveScores[0])}
</span>
<span className="sep"> – </span>
<span className={winnerKnown ? "lose" : undefined}>
<span> – </span>
<span className={winnerKnown ? styles.lose : undefined}>
{scoreLabel(bravo, objectiveScores[1])}
</span>
</div>
@@ -251,10 +256,10 @@ function TeamWeapons({ match }: { match: ScannerMatch }) {
if (alpha.length + bravo.length === 0) return null;
return (
<div className="match-weapons">
<div className={styles.weapons}>
{alpha.length > 0 ? <WeaponRow weapons={alpha} /> : null}
{alpha.length > 0 && bravo.length > 0 ? (
<span className="vs">vs</span>
<span className={styles.vs}>vs</span>
) : null}
{bravo.length > 0 ? <WeaponRow weapons={bravo} /> : null}
</div>
@@ -264,14 +269,14 @@ function TeamWeapons({ match }: { match: ScannerMatch }) {
/** One team's weapons, kept together when the card is too narrow for both. */
function WeaponRow({ weapons }: { weapons: TeamWeapon[] }) {
return (
<div className="weapon-row">
<div className={styles.weaponRow}>
{weapons.map((weapon, i) => (
<WeaponImage
key={i}
weaponSplId={weapon.weaponId}
variant="build"
size={22}
className={clsx("weapon-icon", { pov: weapon.pov })}
className={clsx(styles.weapon, { [styles.pov]: weapon.pov })}
/>
))}
</div>
@@ -290,7 +295,7 @@ function StatusChip({
const formatSentAt = useEventTimeFormatter();
if (skipReason) {
return (
<span className="match-chip">
<span className={styles.chip}>
{skipReason === "disconnect" ? "disconnect" : "not ingested"}
</span>
);
@@ -298,7 +303,7 @@ function StatusChip({
if (send?.state === "sent") {
return (
<span
className="match-chip sent"
className={clsx(styles.chip, styles.sent)}
title={`ingested ${formatSentAt(send.at)}`}
>
✓
@@ -316,16 +321,19 @@ function StatusChip({
}
if (send) {
return (
<span className={clsx("match-chip", send.state)} title={send.error}>
<span
className={clsx(styles.chip, styles[send.state])}
title={send.error}
>
{send.state === "queued" || send.state === "sending" ? (
<span className="dot" />
<span className={styles.dot} />
) : null}
{SEND_CHIP_LABELS[send.state]}
</span>
);
}
if (live) return null;
return <span className="match-chip">not sent</span>;
return <span className={styles.chip}>not sent</span>;
}
function ingestedMatchUrl(link: IngestedMatchLink): string {

View File

@@ -0,0 +1,5 @@
.matchList {
display: flex;
flex-direction: column;
gap: var(--s-3);
}

View File

@@ -18,6 +18,7 @@ import type { BuiltMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerLobby } from "../scanner-types";
import { SetDivider } from "./MatchCard";
import styles from "./MatchLobbyTabs.module.css";
type LobbyGroup = "private" | "x" | "other";
@@ -62,7 +63,7 @@ export function MatchLobbyTabs<E extends DetectedEvent>({
))}
</SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTabPanel key={group} id={group} className="match-list">
<SendouTabPanel key={group} id={group} className={styles.matchList}>
<MatchList
matches={groupMatches}
sets={group === "private"}

View File

@@ -0,0 +1,27 @@
/* time + confidence + event type read as one unit, the detail text after it
sits further away */
.pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1);
}
/* neutral chip, the icon carries the accent and documents the value — full
label on hover via title */
.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-size: var(--font-2xs);
font-weight: var(--weight-bold);
& svg {
color: var(--color-text-accent);
}
}

View File

@@ -7,6 +7,7 @@
import { Clock, Gauge } from "lucide-react";
import { EventTypeIcon } from "./EventTypeIcon";
import { formatTime } from "./format";
import styles from "./MetaChips.module.css";
export function MetaPills({
t,
@@ -20,16 +21,16 @@ export function MetaPills({
label: string;
}) {
return (
<div className="meta-pills">
<span className="meta-chip" title="Video timestamp">
<Clock size={12} aria-hidden className="meta-chip-icon" />
<div className={styles.pills}>
<span className={styles.chip} title="Video timestamp">
<Clock size={12} aria-hidden />
{formatTime(t)}
</span>
<span className="meta-chip" title="Detection confidence">
<Gauge size={12} aria-hidden className="meta-chip-icon" />
<span className={styles.chip} title="Detection confidence">
<Gauge size={12} aria-hidden />
{(confidence * 100).toFixed(0)}%
</span>
<span className="status detected">
<span className={styles.chip}>
<EventTypeIcon type={type} />
{label}
</span>

View File

@@ -0,0 +1,96 @@
.player {
display: flex;
align-items: center;
gap: var(--s-2);
padding-block: 3px;
font-size: var(--font-xs);
}
.weaponMissing {
flex-shrink: 0;
width: 24px;
text-align: center;
color: var(--color-text-high);
}
/* the minimap cards are tighter than the scanner's default weapon puck */
.weapon {
flex-shrink: 0;
width: 24px;
height: 24px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
.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;
}
.statusChip {
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 */
.slotMarker {
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);
}
}

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { ChevronUp } from "lucide-react";
import type { ReactNode } from "react";
import { Ability } from "~/components/Ability";
@@ -10,10 +11,12 @@ import {
type MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { CardSlot } from "../core/detectors/minimap/rois";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
import styles from "./MinimapCard.module.css";
const ENEMY_SLOT_LETTERS = ["A", "B", "X", "Y"] as const;
@@ -39,10 +42,10 @@ function AbilityRow({
function TeammateMarker({ slot }: { slot: CardSlot }) {
if (slot === "self") {
return <span className="slot-marker">●</span>;
return <span className={styles.slotMarker}>●</span>;
}
return (
<span className={`slot-marker ${slot}`}>
<span className={clsx(styles.slotMarker, styles[slot])}>
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
</span>
);
@@ -56,30 +59,36 @@ function PlayerRow({
player: MinimapTeammate | MinimapEnemy;
}) {
return (
<div className="minimap-player">
<div className={styles.player}>
{marker}
{player.weaponId !== null ? (
<WeaponImage
weaponSplId={player.weaponId}
variant="build"
size={24}
className="weapon-icon"
className={styles.weapon}
/>
) : (
<span className="weapon-missing">?</span>
<span className={styles.weaponMissing}>?</span>
)}
<span className="name">{player.name ?? ""}</span>
<span className={styles.name}>{player.name ?? ""}</span>
{player.dead ? (
<span className="status-chip dead" title="respawning (struck out)">
<span
className={clsx(styles.statusChip, styles.dead)}
title="respawning (struck out)"
>
✕
</span>
) : null}
{player.specialReady ? (
<span className="status-chip special" title="special ready (camo)">
<span
className={clsx(styles.statusChip, styles.special)}
title="special ready (camo)"
>
★
</span>
) : null}
<span className="abilities">
<span className={styles.abilities}>
<AbilityRow abilities={player.abilities} />
</span>
</div>
@@ -100,8 +109,8 @@ export function MinimapCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -117,8 +126,8 @@ export function MinimapCard(props: {
fixture={{ data, type: "Minimap" }}
/>
</div>
<div className="teams">
<div className="team">
<div className={eventCardStyles.teams}>
<div className={eventCardStyles.team}>
<h3>Team</h3>
{data.teammates.map((p) => (
<PlayerRow
@@ -129,13 +138,13 @@ export function MinimapCard(props: {
))}
</div>
{data.enemies.length > 0 ? (
<div className="team">
<div className={eventCardStyles.team}>
<h3>Enemies</h3>
{data.enemies.map((p, i) => (
<PlayerRow
key={i}
marker={
<span className="slot-marker">
<span className={styles.slotMarker}>
{ENEMY_SLOT_LETTERS[i] ?? i + 1}
</span>
}

View File

@@ -2,6 +2,7 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
@@ -27,8 +28,8 @@ export function ObjectiveCard(props: {
const holder = data.control.findIndex(Boolean);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}

View File

@@ -2,6 +2,7 @@ import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
@@ -24,8 +25,8 @@ export function PlayerStatusCard(props: {
.join("");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import type {
@@ -6,7 +7,9 @@ import type {
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import scannerStyles from "../scanner.module.css";
import { AbilityPopover } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import type { CardData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
@@ -24,18 +27,18 @@ function PlayerRows({
abilities?: PlayerAbilityMap;
}) {
return (
<table className="players">
<table className={styles.players}>
<tbody>
{players.map((p, i) => (
<tr key={i}>
<td>
<span className="weapon-cell">
<span className={styles.weaponCell}>
{p.weaponId !== null ? (
<WeaponImage
weaponSplId={p.weaponId}
variant="build"
size={28}
className="weapon-icon"
className={styles.weaponIcon}
/>
) : null}
{abilities?.has(offset + i) ? (
@@ -44,8 +47,8 @@ function PlayerRows({
</span>
</td>
<td>{p.name || "?"}</td>
<td className="num">{p.paint ?? "?"}p</td>
<td className="num">
<td className={styles.num}>{p.paint ?? "?"}p</td>
<td className={styles.num}>
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
</td>
</tr>
@@ -82,8 +85,8 @@ export function ScoreboardCard(props: {
const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -109,7 +112,7 @@ export function ScoreboardCard(props: {
) : null}
{data.timestamp ? <span>{data.timestamp}</span> : null}
{data.replayCode ? (
<span className="score">{data.replayCode}</span>
<span className={scannerStyles.score}>{data.replayCode}</span>
) : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
@@ -119,8 +122,8 @@ export function ScoreboardCard(props: {
fixture={{ data, type: eventType }}
/>
</div>
<div className="teams">
<div className="team win">
<div className={styles.teams}>
<div className={clsx(styles.team, styles.win)}>
<h3>{teamHeading("Victory", data, 0)}</h3>
<PlayerRows
players={data.players.slice(0, 4)}
@@ -128,7 +131,7 @@ export function ScoreboardCard(props: {
abilities={props.abilities}
/>
</div>
<div className="team lose">
<div className={clsx(styles.team, styles.lose)}>
<h3>{teamHeading("Defeat", data, 1)}</h3>
<PlayerRows
players={data.players.slice(4, 8)}

View File

@@ -1,9 +1,11 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { AbilityGrid } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
@@ -23,8 +25,8 @@ export function ScoreboardOwnCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -51,14 +53,14 @@ export function ScoreboardOwnCard(props: {
fixture={{ data, type: "ScoreboardOwn" }}
/>
</div>
<div className="teams solo">
<div className="team">
<div className={clsx(styles.teams, styles.solo)}>
<div className={styles.team}>
{data.weaponId !== null ? (
<WeaponImage
weaponSplId={data.weaponId}
variant="build"
size={28}
className="weapon-icon"
className={styles.weaponIcon}
/>
) : null}
<AbilityGrid abilities={data.abilities} />

View File

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

View File

@@ -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<WorkerResponse, { kind: "result" }>;
@@ -77,11 +79,11 @@ function RoiCrop(props: {
function Stat(props: { label: string; raw?: unknown; children: ReactNode }) {
return (
<span className="stat">
<span className="stat-label">{props.label}</span>
<span className="stat-value">{props.children}</span>
<span className={styles.stat}>
<span className={styles.statLabel}>{props.label}</span>
<span className={styles.statValue}>{props.children}</span>
{props.raw != null && props.raw !== "" ? (
<span className="stat-raw">raw: {String(props.raw)}</span>
<span className={styles.statRaw}>raw: {String(props.raw)}</span>
) : null}
</span>
);
@@ -104,15 +106,18 @@ function LabeledCrop(props: {
/** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */
function StatusSlots(props: { data: PlayerStatusData }) {
return (
<span className="status-slots">
<span className={styles.statusSlots}>
{([0, 1] as const).map((side) => (
<span key={side} className="status-side">
<span key={side} className={styles.statusSide}>
{props.data.dead[side].map((dead, slot) => {
const special = !dead && props.data.special[side][slot];
return (
<span
key={slot}
className={clsx("status-slot", { dead, special })}
className={clsx(styles.statusSlot, {
[styles.dead]: dead,
[styles.special]: special,
})}
>
{dead ? "✗" : special ? "★" : slot + 1}
</span>
@@ -511,7 +516,9 @@ export function ScreenshotPage() {
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx("dropzone", { over })}
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
@@ -540,10 +547,10 @@ export function ScreenshotPage() {
</label>
{busy ? " — analyzing…" : null}
</div>
{error ? <p className="error">{error}</p> : null}
{error ? <p className={scannerStyles.error}>{error}</p> : null}
<div
className="screenshot-frame"
className={styles.frame}
style={{ display: frame ? "block" : "none" }}
>
<canvas ref={displayRef} />
@@ -575,26 +582,30 @@ export function ScreenshotPage() {
) : null}
{Object.keys(results).length > 0 ? (
<div className="gate-list">
<div className={styles.gateList}>
{Object.values(results).map((result) => (
<div
key={result.detector}
className={clsx("gate-row", { fired: result.gate.pass })}
className={clsx(styles.gateRow, {
[styles.fired]: result.gate.pass,
})}
>
<span className="gate-badge">
<span className={styles.gateBadge}>
{result.gate.pass ? "fired" : "no fire"}
</span>
<span className="gate-name">{result.detector}</span>
<span className="gate-score">{result.gate.score.toFixed(3)}</span>
<span className="gate-note">{gateSummary(result)}</span>
<span className={styles.gateName}>{result.detector}</span>
<span className={styles.gateScore}>
{result.gate.score.toFixed(3)}
</span>
<span className={styles.gateNote}>{gateSummary(result)}</span>
</div>
))}
</div>
) : null}
{frame && event && isReplay ? (
<div className="detail">
<div className="detail-stats">
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="code" raw={event.debug?.codeRaw}>
{event.data.replayCode ?? "?"}
@@ -604,7 +615,7 @@ export function ScreenshotPage() {
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className="detail-crops">
<div className={styles.detailCrops}>
<LabeledCrop
label="header"
frame={frame}
@@ -620,15 +631,15 @@ export function ScreenshotPage() {
) : null}
{frame && event && isScoreboardBattleLog ? (
<div className="detail">
<div className="detail-stats">
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="match scores">
{JSON.stringify(event.data.matchScores)}
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className="detail-crops">
<div className={styles.detailCrops}>
<LabeledCrop
label="header top"
frame={frame}
@@ -644,12 +655,12 @@ export function ScreenshotPage() {
) : null}
{frame && event && isDeath ? (
<div className="detail">
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as DeathData;
return (
<>
<div className="detail-stats">
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponRaw}>
{weaponLabel(data.weaponType, data.weaponId) ?? "?"}
</Stat>
@@ -660,7 +671,7 @@ export function ScreenshotPage() {
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className="detail-crops">
<div className={styles.detailCrops}>
<LabeledCrop
label="weapon line"
frame={frame}
@@ -679,12 +690,12 @@ export function ScreenshotPage() {
) : null}
{frame && event && isMapStart ? (
<div className="detail">
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MapStartData;
return (
<>
<div className="detail-stats">
<div className={styles.detailStats}>
<Stat label="mode" raw={event.debug?.modeReading}>
{modeLabel(data.mode) ?? "?"}
</Stat>
@@ -692,7 +703,7 @@ export function ScreenshotPage() {
{stageLabel(data.stage) ?? "?"}
</Stat>
</div>
<div className="detail-crops">
<div className={styles.detailCrops}>
<LabeledCrop
label="mode block"
frame={frame}
@@ -712,12 +723,12 @@ export function ScreenshotPage() {
) : null}
{frame && event && isOwn ? (
<div className="detail">
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ScoreboardOwnData;
return (
<>
<div className="detail-stats">
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponReading}>
{mainWeaponLabel(data.weaponId) ?? "?"}
</Stat>
@@ -725,7 +736,7 @@ export function ScreenshotPage() {
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className="detail-crops">
<div className={styles.detailCrops}>
<LabeledCrop
label="title band"
frame={frame}
@@ -752,12 +763,12 @@ export function ScreenshotPage() {
) : null}
{frame && event && isMinimap ? (
<div className="detail">
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MinimapData;
return (
<>
<div className="detail-stats">
<div className={styles.detailStats}>
<Stat label="stage">{stageLabel(data.stage) ?? "?"}</Stat>
<Stat label="view">
{data.spectator ? "spectator map" : "POV overlay"}
@@ -782,7 +793,7 @@ export function ScreenshotPage() {
) : null}
</div>
{!data.spectator ? (
<div className="detail-crops">
<div className={styles.detailCrops}>
{minimap.CARD_LAYOUTS.map((card) => (
<LabeledCrop
key={card.slot}
@@ -800,7 +811,7 @@ export function ScreenshotPage() {
) : null}
{frame && event && isObjective ? (
<div className="detail">
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ObjectiveData;
const status = active?.events.find(
@@ -808,7 +819,7 @@ export function ScreenshotPage() {
) as DetectedEvent<PlayerStatusData> | undefined;
return (
<>
<div className="detail-stats">
<div className={styles.detailStats}>
<Stat label="timer">{formatTimer(data.time)}</Stat>
<Stat label="score">
{data.score[0] ?? "?"}–{data.score[1] ?? "?"}
@@ -832,7 +843,7 @@ export function ScreenshotPage() {
</>
) : null}
</div>
<div className="detail-crops">
<div className={styles.detailCrops}>
<LabeledCrop
label="left count"
frame={frame}
@@ -873,7 +884,7 @@ export function ScreenshotPage() {
!isOwn &&
!isMinimap &&
!isObjective ? (
<table className="inspector">
<table className={styles.inspector}>
<thead>
<tr>
<th>row</th>
@@ -895,16 +906,23 @@ export function ScreenshotPage() {
<RoiCrop frame={frame} roi={roi.weapon} />
</td>
<td>
<div className="candidates weapon-candidates">
<div
className={clsx(
styles.candidates,
styles.weaponCandidates,
)}
>
{dbg?.weapon?.top.map((c) => (
<span className="cand" key={c.id}>
<span className={styles.candidate} key={c.id}>
<img
className="weapon-icon"
className={styles.weaponIcon}
src={`${mainWeaponImageUrl(Number(c.id) as MainWeaponId)}.avif`}
alt={c.id}
/>
{c.id}
<span className="score">{c.score.toFixed(3)}</span>
<span className={scannerStyles.score}>
{c.score.toFixed(3)}
</span>
</span>
))}
</div>
@@ -912,20 +930,24 @@ export function ScreenshotPage() {
<td>
<RoiCrop frame={frame} roi={roi.name} />
<b>{player?.name || "—"}</b>{" "}
<span className="score">{dbg?.nameScore.toFixed(3)}</span>
<span className={scannerStyles.score}>
{dbg?.nameScore.toFixed(3)}
</span>
</td>
<td>
<RoiCrop frame={frame} roi={roi.paint} />
<b>{player?.paint ?? "—"}</b>{" "}
<span className="score">{dbg?.paintScore.toFixed(3)}</span>
<span className={scannerStyles.score}>
{dbg?.paintScore.toFixed(3)}
</span>
</td>
<td>
<div className="candidates">
<div className={styles.candidates}>
{([0, 1, 2] as const).map((s) => (
<span className="cand" key={s}>
<span className={styles.candidate} key={s}>
<RoiCrop frame={frame} roi={roi.stats[s]} scale={2} />
<b>{[player?.ka, player?.d, player?.s][s] ?? "—"}</b>
<span className="score">
<span className={scannerStyles.score}>
{dbg?.statScores[s].toFixed(2)}
</span>
</span>

View File

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

View File

@@ -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({
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx("dropzone", { over })}
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
@@ -579,17 +583,18 @@ export function VodPage({
/>
</label>
</div>
<div className="controls">
<div className={scannerStyles.controls}>
{showVodView ? (
<>
<button type="button" onClick={backToList}>
← All VoDs
</button>
<span
className={clsx("status", {
watching: status === "scanning",
detected: status === "done",
idle: status !== "scanning" && status !== "done",
className={clsx(scannerStyles.status, {
[scannerStyles.watching]: status === "scanning",
[scannerStyles.detected]: status === "done",
[scannerStyles.idle]:
status !== "scanning" && status !== "done",
})}
>
{source === "stored" ? "saved" : status}
@@ -600,7 +605,7 @@ export function VodPage({
: null}
</span>
{progress ? (
<span className="score">
<span className={scannerStyles.score}>
{formatTime(progress.t)} / {formatTime(progress.duration)}
{progress.duration > 0
? ` (${Math.round((progress.t / progress.duration) * 100)}%)`
@@ -611,13 +616,13 @@ export function VodPage({
</span>
) : null}
{upload?.url ? (
<Link to={upload.url} className="link-button">
<Link to={upload.url} className={styles.linkButton}>
<Video aria-hidden />
Add VoD
</Link>
) : null}
{upload?.problem ? (
<span className="score">
<span className={scannerStyles.score}>
upload unavailable: {upload.problem}
</span>
) : null}
@@ -634,8 +639,8 @@ export function VodPage({
) : null}
{resultsSend ? (
<span
className={clsx("score", {
error:
className={clsx(scannerStyles.score, {
[scannerStyles.error]:
resultsSend.state === "done" && Boolean(resultsSend.error),
})}
>
@@ -655,21 +660,21 @@ export function VodPage({
</>
) : null}
</div>
{error ? <p className="error">{error}</p> : null}
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{showVodView && telemetry ? (
<TelemetryPanel telemetry={telemetry} />
) : null}
{!showVodView ? (
<div className="vod-list">
<div className={styles.vodList}>
{vods.length === 0 ? (
<p className="score">
<p className={scannerStyles.score}>
No saved VoDs yet — scan one and it will show up here.
</p>
) : null}
{vods.map((vod) => (
<div key={vod.name} className="vod-item">
<span className="name">{vod.name}</span>
<span className="score">
<div key={vod.name} className={styles.vodItem}>
<span className={styles.vodName}>{vod.name}</span>
<span className={clsx(scannerStyles.score, styles.vodMeta)}>
{vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "}
{formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)}
</span>
@@ -684,7 +689,7 @@ export function VodPage({
variant="destructive"
size="small"
shape="square"
className="vod-delete"
className={styles.vodDelete}
icon={<Trash2 />}
aria-label="Delete"
/>
@@ -694,7 +699,7 @@ export function VodPage({
</div>
) : null}
<div
className="live-layout"
className={scannerStyles.liveLayout}
style={{
display: showVodView ? undefined : "none",
// a reopened saved VoD has no video to review — give the feed the full width
@@ -704,12 +709,12 @@ export function VodPage({
<div style={{ display: source === "scan" ? undefined : "none" }}>
<canvas
ref={previewRef}
className="preview"
className={scannerStyles.preview}
style={{ display: status === "scanning" ? "block" : "none" }}
/>
<video
ref={videoRef}
className="preview"
className={scannerStyles.preview}
muted
playsInline
controls
@@ -718,9 +723,9 @@ export function VodPage({
}}
/>
</div>
<div className="feed">
<div className={scannerStyles.feed}>
{matches.length === 0 ? (
<p className="score">
<p className={scannerStyles.score}>
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
@@ -840,7 +845,7 @@ function ExportMenu({
trigger={
<SendouButton
icon={<Download />}
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 (
<details className="telemetry">
<details className={styles.telemetry}>
<summary>
telemetry · analyzed {telemetry.analyzedFrames}/
{telemetry.decodedFrames} decoded frames

File diff suppressed because it is too large Load Diff

View File

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