polish(phase9): sparkline + badge motion and a11y

Sparkline: friendlier aria-label ("3 completed today. 14 total over
the last 7 days." rather than a numeric list), per-bar <title>
tooltips with absolute date + count, 30 ms stagger entrance via
scaleY animation. Badge: 180 ms opacity + translateY entrance on
mount; conditional render means each new badge re-fires the
animation. Both animations respect prefers-reduced-motion.

The earlier draft tabindex=0 on the SVG was correctly flagged by
svelte-check as noninteractive_tabindex; SVG role="img" + aria-label
is sufficient for SR navigation without putting it in the keyboard
tab order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:17:53 +01:00
parent 3bc34d2873
commit dd45f10cd4
2 changed files with 61 additions and 4 deletions

View File

@@ -3,6 +3,13 @@
// Zero-days render as a 1 px baseline stub (never a gap, never zero
// height). The component self-hides when all bars are zero. Caller
// still guards on the user's settings toggle.
//
// Phase 9 polish: friendlier aria-label (sentence-form summary, not a
// numeric list), per-bar <title> tooltips with absolute date + count,
// and a 30 ms-stagger entrance animation that respects
// prefers-reduced-motion. Tabindex deliberately omitted: SVGs with
// role="img" are reachable by screen-reader graphic navigation
// without being part of the keyboard-tab order.
import type { DailyCompletionCount } from "$lib/types/app";
interface Props {
@@ -22,10 +29,12 @@
(width - BAR_GAP * Math.max(0, data.length - 1)) / Math.max(1, data.length),
);
let total = $derived(data.reduce((sum, d) => sum + d.count, 0));
let today = $derived(data.at(-1)?.count ?? 0);
let ariaLabel = $derived.by(() => {
if (data.length === 0) return "";
const nums = data.map((d) => d.count).join(", ");
return `Tasks completed over the last ${data.length} days: ${nums}`;
return `${today} completed today. ${total} total over the last ${data.length} days.`;
});
let hasAnyCompletion = $derived(data.some((d) => d.count > 0));
@@ -53,7 +62,31 @@
fill="currentColor"
opacity={d.count === 0 ? 0.35 : 0.85}
rx="1"
/>
class="bar"
style={`--bar-delay: ${i * 30}ms`}
>
<title>{d.day}: {d.count} {d.count === 1 ? "task" : "tasks"}</title>
</rect>
{/each}
</svg>
{/if}
<style>
.bar {
transform-origin: bottom;
animation: bar-in 240ms ease var(--bar-delay, 0ms) both;
}
@media (prefers-reduced-motion: reduce) {
.bar {
animation: none;
}
}
@keyframes bar-in {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
</style>

View File

@@ -298,7 +298,7 @@
>
{#if todayCount() > 0}
<span
class="text-[11px] text-text-tertiary"
class="text-[11px] text-text-tertiary badge-today"
aria-label={`${todayCount()} ${todayCount() === 1 ? "task" : "tasks"} completed today`}
>
{todayCount()} today
@@ -689,3 +689,27 @@
</div>
</div>
</div>
<style>
/* Phase 9 polish: gentle entrance animation when the today-count
badge mounts (it is conditionally rendered, so each new render
re-fires the animation). prefers-reduced-motion disables. */
:global(.badge-today) {
animation: badge-pop 180ms ease both;
}
@media (prefers-reduced-motion: reduce) {
:global(.badge-today) {
animation: none;
}
}
@keyframes badge-pop {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>