feat: show an excerpt and make rows respond to hover and press

The whole row is now one click target, which gives a generous tap area and
a single unambiguous hover region.

Hover transitions are fast and eased out (110-160ms) so a row responds on
the way in and settles on the way out — a slow symmetric fade is what makes
an interface feel laggy even at full framerate. The meter is the element
that visibly wakes up, since it is the signature. Press feedback lands on
touch as well as mouse, and every hover rule sits behind `hover: hover` so a
tap never leaves a row stuck looking hovered.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:39:42 +02:00
co-authored by Claude Opus 5
parent 6fffd283a5
commit e0ba53f369
2 changed files with 185 additions and 66 deletions
+38 -23
View File
@@ -28,47 +28,58 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
return ( return (
<li <li
className={`group relative flex gap-3 border-b border-hairline/70 px-4 py-3.5 transition-opacity ${ className={`event-row relative border-b border-hairline/70 ${
completed ? "opacity-40" : "" completed ? "is-complete" : ""
}`} }`}
style={{ ["--hue" as string]: game.hue }}
> >
{/* Game identity: a hue stripe, never an urgency colour. */} {/* The whole row opens the event. A single full-bleed target gives a
<span generous tap area on mobile and one unambiguous hover region — the
aria-hidden content above it is pointer-transparent so clicks fall through. */}
className="mt-1 w-[3px] shrink-0 rounded-full"
style={{ background: game.hue }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<button <button
type="button" type="button"
onClick={() => onOpen(event.id)} onClick={() => onOpen(event.id)}
className="min-w-0 text-left" className="absolute inset-0 z-0 cursor-pointer"
>
<span
className="eyebrow block truncate"
style={{ color: game.hue }}
> >
<span className="sr-only">View {event.title} details</span>
</button>
<div className="pointer-events-none relative z-10 flex gap-3 px-4 py-3.5">
{/* Game identity rail. Grows on hover — the cheapest possible signal
that this row is live under the cursor. */}
<span aria-hidden className="row-rail mt-0.5 w-[3px] shrink-0 rounded-full" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<div className="min-w-0">
<span className="eyebrow block truncate" style={{ color: game.hue }}>
{game.short} {game.short}
</span> </span>
<span <span
className={`block truncate text-[0.9375rem] font-medium leading-snug ${ className={`row-title block truncate text-[0.9375rem] font-medium leading-snug ${
completed ? "line-through decoration-faint" : "" completed ? "line-through decoration-faint" : ""
}`} }`}
> >
{event.title} {event.title}
</span> </span>
</button> </div>
<span <span
className="tnum shrink-0 font-display text-sm font-semibold tabular-nums" className="tnum row-count shrink-0 font-display text-sm font-semibold"
style={{ color: clock.msRemaining === null ? "var(--color-faint)" : heat }} style={{
color: clock.msRemaining === null ? "var(--color-faint)" : heat,
}}
> >
{countdown} {countdown}
</span> </span>
</div> </div>
{event.summary !== null && (
<p className="row-summary mt-1 line-clamp-2 text-[0.8125rem] leading-snug text-muted">
{event.summary}
</p>
)}
<div className="mt-2.5"> <div className="mt-2.5">
<Meter <Meter
progress={clock.upcoming ? 1 : clock.progress} progress={clock.upcoming ? 1 : clock.progress}
@@ -78,15 +89,18 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
</div> </div>
</div> </div>
{/* Sits above the row target so ticking done never opens the sheet. */}
<button <button
type="button" type="button"
onClick={() => onToggle(event.id)} onClick={() => onToggle(event.id)}
aria-pressed={completed} aria-pressed={completed}
aria-label={completed ? `Mark ${event.title} not done` : `Mark ${event.title} done`} aria-label={
className={`mt-0.5 grid size-7 shrink-0 place-items-center self-center rounded-md border transition-colors ${ completed ? `Mark ${event.title} not done` : `Mark ${event.title} done`
}
className={`row-check pointer-events-auto relative z-20 grid size-7 shrink-0 cursor-pointer place-items-center self-center rounded-md border ${
completed completed
? "border-transparent bg-near/20 text-near" ? "border-transparent bg-near/20 text-near"
: "border-hairline text-faint hover:border-faint hover:text-muted" : "border-hairline text-faint"
}`} }`}
> >
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden> <svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
@@ -100,6 +114,7 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
/> />
</svg> </svg>
</button> </button>
</div>
</li> </li>
); );
} }
+104
View File
@@ -113,6 +113,110 @@ body {
} }
} }
/* ---- Event rows -------------------------------------------------------- */
/*
* Hover should feel instant. Everything here is fast (110160ms) and eased out,
* so the row responds on the way in and settles on the way out — a slow
* symmetric fade is what makes an interface feel laggy even at 60fps.
*
* All hover states are behind `hover: hover` so a tap on a touch device does
* not leave a row stuck in its hover appearance.
*/
.event-row {
--row-bg: transparent;
background: var(--row-bg);
transition: background-color 130ms ease-out;
}
.event-row.is-complete {
opacity: 0.4;
}
.row-rail {
background: var(--hue);
opacity: 0.55;
transform: scaleY(0.82);
transform-origin: center;
transition:
transform 160ms cubic-bezier(0.2, 0.7, 0.3, 1),
opacity 130ms ease-out,
box-shadow 160ms ease-out;
}
.row-title,
.row-count,
.row-summary {
transition: color 130ms ease-out;
}
.row-check {
transition:
border-color 130ms ease-out,
color 130ms ease-out,
transform 120ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@media (hover: hover) {
.event-row:hover {
--row-bg: color-mix(in srgb, var(--color-raised) 62%, transparent);
}
.event-row:hover .row-rail {
opacity: 1;
transform: scaleY(1);
box-shadow: 0 0 10px -2px var(--hue);
}
/* The meter is the signature element, so it is the thing that visibly
wakes up: live ticks gain a little light under the cursor. */
.event-row:hover .meter-tick[data-live="true"] {
filter: brightness(1.28) saturate(1.08);
}
.event-row:hover .row-summary {
color: var(--color-ink);
}
.event-row:hover .row-check {
border-color: var(--color-faint);
color: var(--color-muted);
}
.row-check:hover {
transform: scale(1.08);
border-color: var(--color-muted);
color: var(--color-ink);
}
}
/* Press feedback lands on both mouse and touch — the tactile half of
"responsive". */
.event-row:active {
--row-bg: color-mix(in srgb, var(--color-raised) 82%, transparent);
transition-duration: 40ms;
}
.row-check:active {
transform: scale(0.9);
transition-duration: 60ms;
}
.meter-tick {
transition:
background-color 240ms ease,
filter 130ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.event-row,
.row-rail,
.row-title,
.row-count,
.row-summary,
.row-check,
.meter-tick {
transition: none;
}
.row-check:hover,
.row-check:active,
.event-row:hover .row-rail {
transform: none;
}
}
/* Focus ring: visible, and never removed. */ /* Focus ring: visible, and never removed. */
:where(a, button, [tabindex]):focus-visible { :where(a, button, [tabindex]):focus-visible {
outline: 2px solid var(--color-near); outline: 2px solid var(--color-near);