Files
gacha-event-tracker/src/client/components/GameFocus.tsx
T
Lucas WintherandClaude Opus 5 d3066aefdc feat(focus): the game chips answer the pointer, as the dailies do
They are the same object twice — a pill carrying a game's colour, pressed
or not, that you tap — so they get the same hover rather than a second
one: a ring in the game's hue, a one-pixel lift, and the label coming up
to full ink while the chip is not the pressed one.

Sharing it meant `.tick-chip` becoming `.hue-chip`, and dropping the
`data-done` attribute it keyed off. Both chips were already saying the
same thing in `aria-pressed`, and a second attribute repeating it is one
more thing to keep in step — a rule that reads the announced state cannot
disagree with it.

The focus strip needed a pixel of headroom for that lift. `scroll-x` sets
`overflow-x: auto`, which computes the vertical axis to `auto` as well,
so a chip that rises loses that pixel and its ring to the scroller's
edge; `pt-1` is the room, and `mt` gives the same total gap back.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 06:13:48 +02:00

148 lines
5.0 KiB
TypeScript

import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
/**
* One game at a time.
*
* The settings panel already lets a reader switch games on and off, but that is
* a different job: it says which games they play, and it is set once. This is
* the thing a reader does *while reading* — clear the deck down to one game,
* finish it, move to the next. Doing that with the on/off switches means two
* taps per game and a settings panel that no longer describes what they play.
*
* So focus is a lens, not a setting: it never changes which games are switched
* on, "All" is always one tap away, and the rotation ends by returning to All
* rather than looping forever.
*/
export function GameFocus({
games,
focus,
counts,
total,
next,
onFocus,
onAdvance,
}: {
/** Games the reader has switched on, in feed order. */
games: LaneId[];
focus: LaneId | null;
/** Outstanding rows per game, so a chip says whether it is worth a visit. */
counts: Partial<Record<LaneId, number>>;
total: number;
/** Where "next" goes — null means back to all games. */
next: LaneId | null;
onFocus: (game: LaneId | null) => void;
onAdvance: () => void;
}) {
const gameMeta = useGameMeta();
// With one game there is nothing to focus down to, and the bar would just be
// a chip that does nothing.
if (games.length < 2) return null;
return (
<section className="border-b border-hairline px-4 py-3">
<div className="flex items-baseline justify-between gap-3">
<p className="eyebrow">Focus</p>
<button
type="button"
onClick={onAdvance}
className="shrink-0 text-[0.6875rem] font-medium text-faint transition-colors hover:text-ink"
>
{next === null ? "Show all games" : `Next: ${gameMeta(next).short}`}
<span aria-hidden> →</span>
</button>
</div>
{/* A scrolling strip where the bar is as wide as the page, wrapped rows
where it is a 20rem rail: a thirteen-game reader would otherwise get
four chips and a hairline scrollbar, and a game you cannot see is a
game you will not focus. The rail is short and the list beside it is
long, so the rows cost nothing that column was using. */}
<div
role="group"
aria-label="Focus on one game"
// `pt-1` is the lift's headroom, not spacing: `scroll-x` sets
// `overflow-x: auto`, which computes the vertical axis to `auto` as
// well, so a chip that rises a pixel under the cursor loses that pixel
// and its ring to the scroller's edge. `mt` gives the same total gap
// back.
className="scroll-x -mx-4 mt-1 flex gap-1.5 px-4 pb-1 pt-1 lg:flex-wrap"
>
<Chip
label="All"
count={total}
on={focus === null}
hue="var(--color-ink)"
onClick={() => onFocus(null)}
/>
{games.map((id) => {
const game = gameMeta(id);
return (
<Chip
key={id}
label={game.short}
ariaLabel={game.name}
count={counts[id] ?? 0}
on={focus === id}
hue={game.hue}
// Tapping the focused game backs out to all of them, so the chip
// that got you here is also the way back.
onClick={() => onFocus(focus === id ? null : id)}
/>
);
})}
</div>
</section>
);
}
/**
* A game chip.
*
* Carries its hue whether or not it is selected — dimmer when it is not — so
* the row reads as a set of games at a glance rather than as one coloured chip
* among a row of grey ones. The count is what makes it worth tapping: a game
* with nothing outstanding says so before you visit it.
*/
function Chip({
label,
ariaLabel,
count,
on,
hue,
onClick,
}: {
label: string;
ariaLabel?: string | undefined;
count: number;
on: boolean;
hue: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={on}
aria-label={`${ariaLabel ?? label}, ${count} outstanding`}
// The dailies strip's hover, and for the same reason: these colours are a
// game's hue, so they arrive inline where no rule can reach them, and
// `.hue-chip` works off `--hue` and the `aria-pressed` above instead.
className="hue-chip flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium"
style={{
["--hue" as string]: hue,
borderColor: on ? hue : `color-mix(in srgb, ${hue} 30%, transparent)`,
color: on ? hue : "var(--color-muted)",
background: on
? `color-mix(in srgb, ${hue} 14%, transparent)`
: "transparent",
// Nothing to do here — still reachable, just not competing for the eye.
opacity: count === 0 && !on ? 0.5 : 1,
}}
>
<span className="hue-chip-label">{label}</span>
<span className="tnum text-[0.625rem] opacity-70">{count}</span>
</button>
);
}