From d524d906041772f0321bd4439a084ff48cabb3e2 Mon Sep 17 00:00:00 2001
From: Viet Nguyen
Date: Fri, 14 Aug 2026 15:15:37 +0000
Subject: [PATCH] fix(ui): the wrong typeface on half the numbers, and a
highlight nobody ever saw
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four defects, all of them things you can only find by looking at the app rather than at
the tests, which passed on every one of them.
**Vietnamese set in a monospaced face.** `.tabular` set `font-family: var(--font-mono)`
as well as `font-variant-numeric`, and it had been applied to every string that merely
*contained* a number: "1 giờ 12 phút" on the home screen and the analytics cards, "hạn
2026-08-20" on a task, "3 công cụ" under an agent, the counts in the finder and on every
chip. Those rendered in JetBrains Mono beside Inter everywhere else — tone marks sit badly
in it and the words read as a fallback font that had failed to load. Split in two:
`.tabular` keeps the mono face for a column of digits and nothing else (a clock beside a
transcript line, a playhead, a date on its own), and `.nums` gives the same steady figures
without changing the typeface under the words around them. Twenty sites moved.
**The selected sidebar row had no highlight.** There is a `layoutId` pill that is supposed
to travel between rows; it is `-z-10` so the label paints over it, but the button created
no stacking context, so that negative index escaped and the pill painted behind the
sidebar's own background. Invisible since the component was written — the selected row was
green text and nothing else. One `isolate`.
**Recording red failed AA against its own tint.** `#f2585b` on `rgba(242,88,91,0.1)`
measures 4.35:1, which the screenshot audit had been reporting on every run for the
refused-permission chip. Raised to `#fb6b70`: 5.1:1, and in the same pastel family as the
accent and the assistant violet rather than beside them.
**The capture card was a picture of a dead meter.** Forty motionless grey bars across a
third of the home screen. They now breathe — a tenth of the amplitude of the live waveform
and four times the period, findable only if you look for it, and stopped by
`prefers-reduced-motion` like everything else.
Beyond the fixes: one `.lift` class replaces the two hand-rolled hover treatments so a card
that can be clicked says so before it is, and `Ticker` counts a plain integer up on arrival
so a figure on the home or analytics screen reads as measured rather than as placeholder
text. It refuses anything that is not a bare integer — rolling "1 giờ 12 phút" through
intermediate states would spell out nonsense on the way.
Verified by re-shooting all 44 screenshots in both schemes: no overflow, contrast AA
everywhere, and the previously-reported failure gone. 297 unit tests and all 18 browser
suites green.
---
apps/web/src/components/Library.tsx | 2 +-
apps/web/src/components/StatusBar.tsx | 8 +--
apps/web/src/components/library/Finder.tsx | 4 +-
apps/web/src/components/library/Recent.tsx | 2 +-
apps/web/src/components/shell/Sidebar.tsx | 8 ++-
apps/web/src/components/ui/Chip.tsx | 4 +-
apps/web/src/components/ui/Ticker.tsx | 79 ++++++++++++++++++++++
apps/web/src/components/ui/Wave.tsx | 19 +++++-
apps/web/src/components/ui/index.ts | 1 +
apps/web/src/screens/AgentsScreen.tsx | 9 ++-
apps/web/src/screens/AnalyticsScreen.tsx | 7 +-
apps/web/src/screens/HomeScreen.tsx | 29 ++++++--
apps/web/src/screens/ModelsScreen.tsx | 4 +-
apps/web/src/screens/TasksScreen.tsx | 4 +-
apps/web/src/styles/theme.css | 73 ++++++++++++++++++--
15 files changed, 220 insertions(+), 33 deletions(-)
create mode 100644 apps/web/src/components/ui/Ticker.tsx
diff --git a/apps/web/src/components/Library.tsx b/apps/web/src/components/Library.tsx
index 3160b2f..f356ba5 100644
--- a/apps/web/src/components/Library.tsx
+++ b/apps/web/src/components/Library.tsx
@@ -665,7 +665,7 @@ function Tile({
than as a number. `word-break: keep-all` keeps a word whole; the wrap still happens, at
the space between the hours and the minutes, where a reader expects it. */}
{value}
diff --git a/apps/web/src/components/StatusBar.tsx b/apps/web/src/components/StatusBar.tsx
index d1a065a..38985ad 100644
--- a/apps/web/src/components/StatusBar.tsx
+++ b/apps/web/src/components/StatusBar.tsx
@@ -33,19 +33,19 @@ export function StatusBar({
{disconnected && (
{connection === "reconnecting" ? t("status.reconnecting") : t("status.connecting")}
)}
{device && (
-
+
{device}
)}
{speakers.length > 0 && (
-
+
{n("status.speakers", speakers.length)}
)}
@@ -63,7 +63,7 @@ export function StatusBar({
{stat.rss_mb} MB
{behind && (
-
+
{t("status.behind")}
)}
diff --git a/apps/web/src/components/library/Finder.tsx b/apps/web/src/components/library/Finder.tsx
index e48c420..40aaac4 100644
--- a/apps/web/src/components/library/Finder.tsx
+++ b/apps/web/src/components/library/Finder.tsx
@@ -121,7 +121,7 @@ export function Finder({ view, folder, tags, colour, onFolder, onTags, onColour
)}
>
#{each.name}
- {each.count}
+ {each.count}
);
})}
@@ -151,7 +151,7 @@ export function Finder({ view, folder, tags, colour, onFolder, onTags, onColour
degrades to for anyone who cannot tell two of these colours apart, which is
around one man in twelve. */}
{t(`colour.${each.name}`)}
- {each.count}
+ {each.count}
);
})}
diff --git a/apps/web/src/components/library/Recent.tsx b/apps/web/src/components/library/Recent.tsx
index 2cd662c..f5a25e0 100644
--- a/apps/web/src/components/library/Recent.tsx
+++ b/apps/web/src/components/library/Recent.tsx
@@ -89,7 +89,7 @@ export function Recent({
onClick={() => onOpen(entry)}
whileHover={{ y: -2 }}
transition={GENTLE}
- className="border-line bg-bg-soft hover:border-line-strong flex w-full items-center gap-3 rounded-[var(--radius-card)] border p-2.5 text-left transition-colors"
+ className="border-line bg-bg-soft lift flex w-full items-center gap-3 rounded-[var(--radius-card)] border p-2.5 text-left"
>
{/* A note has no waveform because it was typed; it gets the pen instead, at the same
size, so the rows still line up. */}
diff --git a/apps/web/src/components/shell/Sidebar.tsx b/apps/web/src/components/shell/Sidebar.tsx
index e606ceb..5b99e8b 100644
--- a/apps/web/src/components/shell/Sidebar.tsx
+++ b/apps/web/src/components/shell/Sidebar.tsx
@@ -365,7 +365,11 @@ function NavButton({
onClick={() => onNavigate(item.key)}
aria-current={active ? "page" : undefined}
className={cn(
- "relative flex w-full items-center gap-2.5 rounded-[var(--radius-pill)] px-2.5 py-2 text-sm transition-colors",
+ // `isolate` is load-bearing. The highlight below is `-z-10` so the label paints over it,
+ // but without a stacking context here that negative index escapes the button entirely and
+ // the pill painted *behind the sidebar's own background* — invisible on every screen. The
+ // selected row had been nothing but green text for as long as this component has existed.
+ "relative isolate flex w-full items-center gap-2.5 rounded-[var(--radius-pill)] px-2.5 py-2 text-sm transition-colors",
active ? "text-accent font-medium" : "text-fg-dim hover:bg-bg-raised hover:text-fg",
)}
>
@@ -387,7 +391,7 @@ function NavButton({
{item.label}
{item.badge ? (
-
+
{item.badge}
) : null}
diff --git a/apps/web/src/components/ui/Chip.tsx b/apps/web/src/components/ui/Chip.tsx
index aee6191..66f0b4b 100644
--- a/apps/web/src/components/ui/Chip.tsx
+++ b/apps/web/src/components/ui/Chip.tsx
@@ -48,7 +48,7 @@ export function Chip({
const inner = (
<>
{children}
- {count !== undefined && {count}}
+ {count !== undefined && {count}}
>
);
@@ -95,7 +95,7 @@ export function SectionTitle({
className={cn("text-fg-faint text-micro font-semibold tracking-wider uppercase", className)}
>
{children}
- {count !== undefined && {count}}
+ {count !== undefined && {count}}
);
}
diff --git a/apps/web/src/components/ui/Ticker.tsx b/apps/web/src/components/ui/Ticker.tsx
new file mode 100644
index 0000000..c46b5de
--- /dev/null
+++ b/apps/web/src/components/ui/Ticker.tsx
@@ -0,0 +1,79 @@
+import { animate, useReducedMotion } from "motion/react";
+import { useEffect, useMemo, useRef } from "react";
+
+import { useI18n } from "../../i18n/context";
+
+/**
+ * A number that arrives by counting rather than by appearing.
+ *
+ * The home screen and the analytics cards are mostly large numerals, and a numeral that is simply
+ * *there* on first paint reads as a placeholder — the same three digits sat in the same box whether
+ * the report had loaded or not. Counting up says the figure was measured, and it says it in the
+ * half second the eye is already travelling across the row.
+ *
+ * ## Why this is not a general-purpose animation
+ *
+ * It only runs when the value is a plain integer, and it only runs on the way *in*. Durations here
+ * are strings like "1 giờ 12 phút" and rolling those through intermediate states would spell out
+ * nonsense on the way; a number that changes because the user filtered the screen should snap,
+ * because they are comparing it against what was there a moment ago.
+ *
+ * ## Accessibility
+ *
+ * The text content is written directly to the DOM node, so a screen reader that reaches the element
+ * mid-count would read an intermediate figure. It is `aria-hidden` with the true value beside it in
+ * a visually-hidden span, which is also what makes the count invisible to the screenshot audit's
+ * text extraction. Under `prefers-reduced-motion` nothing counts: the final value is written on the
+ * first frame.
+ */
+export function Ticker({ value, className }: { value: string; className?: string }) {
+ const node = useRef(null);
+ const still = useReducedMotion();
+ const { locale } = useI18n();
+
+ // Localised digits and separators, so a count that ends at "1.234" does not pass through "1234".
+ const format = useMemo(() => new Intl.NumberFormat(locale), [locale]);
+
+ const target = countable(value);
+
+ useEffect(() => {
+ const element = node.current;
+ if (!element) return;
+ if (target === null || still) {
+ element.textContent = value;
+ return;
+ }
+ // From zero, not from the previous number: this only ever runs on first arrival, and "0 → 12"
+ // is the shape of something being counted.
+ const running = animate(0, target, {
+ duration: Math.min(0.9, 0.25 + target * 0.02),
+ ease: [0.16, 1, 0.3, 1],
+ onUpdate: (at) => {
+ element.textContent = format.format(Math.round(at));
+ },
+ });
+ return () => running.stop();
+ }, [target, value, still, format]);
+
+ return (
+ <>
+
+ {target === null || still ? value : "0"}
+
+ {value}
+ >
+ );
+}
+
+/**
+ * The number to count to, or `null` for anything that is not a bare integer.
+ *
+ * Deliberately strict. "12" counts; "1 giờ 12 phút", "1.2 GB" and "—" do not, and each of those is
+ * a real value one of these cards holds.
+ */
+function countable(value: string): number | null {
+ if (!/^\d{1,6}$/.test(value)) return null;
+ const parsed = Number(value);
+ // Nothing to watch, and a zero that counts to zero is a flicker.
+ return parsed > 0 ? parsed : null;
+}
diff --git a/apps/web/src/components/ui/Wave.tsx b/apps/web/src/components/ui/Wave.tsx
index b30b915..b0ac4a4 100644
--- a/apps/web/src/components/ui/Wave.tsx
+++ b/apps/web/src/components/ui/Wave.tsx
@@ -22,6 +22,7 @@ export function Wave({
bars = 28,
className,
live = false,
+ breathe = false,
levels,
}: {
/** Anything stable per recording — the id. Same seed, same silhouette. */
@@ -30,10 +31,23 @@ export function Wave({
className?: string;
/** Animate, for something being recorded right now. */
live?: boolean;
+ /**
+ * Breathe slowly while nothing is happening.
+ *
+ * Only for the one large waveform on the capture card. Idle, it was forty static grey bars filling
+ * a third of the home screen, which reads as a picture of a broken meter rather than as an
+ * invitation. Four seconds and a shallow amplitude is slow enough that it is never the thing you
+ * are looking at, and alive enough that the card is not a dead rectangle.
+ *
+ * Never set on a list: a page of library rows each animating forty bars is forty rows of
+ * compositor work for an ornament nobody is watching.
+ */
+ breathe?: boolean;
/** Real levels in [0,1], when there are some. Overrides the seeded shape. */
levels?: number[];
}) {
const heights = levels ?? shape(seed, bars);
+ const moving = live || breathe;
return (
))}
diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts
index 77f4334..7d55714 100644
--- a/apps/web/src/components/ui/index.ts
+++ b/apps/web/src/components/ui/index.ts
@@ -11,3 +11,4 @@ export { Page, PageGlow } from "./Page";
export { Skeleton } from "./Skeleton";
export { Input, TextArea, Labelled } from "./Field";
export { Chip, SectionTitle } from "./Chip";
+export { Ticker } from "./Ticker";
diff --git a/apps/web/src/screens/AgentsScreen.tsx b/apps/web/src/screens/AgentsScreen.tsx
index 0f2bef9..3a4755b 100644
--- a/apps/web/src/screens/AgentsScreen.tsx
+++ b/apps/web/src/screens/AgentsScreen.tsx
@@ -193,8 +193,7 @@ export function AgentsScreen() {
type="button"
onClick={() => void open(each.slug)}
className={cn(
- "rounded-card bg-bg-soft border p-4 text-left",
- "transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-card)]",
+ "rounded-card bg-bg-soft lift border p-4 text-left",
each.slug === chosen
? "border-accent shadow-[0_0_0_1px_var(--color-accent)]"
: "border-line hover:border-fg-faint",
@@ -216,12 +215,12 @@ export function AgentsScreen() {
- {n("agents.tool_count", each.tools.length)}
+ {n("agents.tool_count", each.tools.length)}
{each.memory.length > 0 && (
- · {n("agents.memory_count", each.memory.length)}
+ · {n("agents.memory_count", each.memory.length)}
)}
{each.open_tasks > 0 && (
- · {n("agents.task_count", each.open_tasks)}
+ · {n("agents.task_count", each.open_tasks)}
)}
{each.model && · {each.model}}
diff --git a/apps/web/src/screens/AnalyticsScreen.tsx b/apps/web/src/screens/AnalyticsScreen.tsx
index a415df7..306703b 100644
--- a/apps/web/src/screens/AnalyticsScreen.tsx
+++ b/apps/web/src/screens/AnalyticsScreen.tsx
@@ -11,6 +11,7 @@ import {
Page,
PageGlow,
SegmentedControl,
+ Ticker,
} from "../components/ui";
import { GENTLE } from "../lib/motion";
import { useLoad } from "../lib/use-load";
@@ -117,7 +118,7 @@ export function AnalyticsScreen() {
}}
/>
-
+
{formatDuration(person.seconds, locale, "short")}
@@ -172,7 +173,9 @@ function Metric({ label, value }: { label: string; value: string }) {
"1 giờ 27 phút" broke as "1 giờ 27" / "phút", which reads as two facts. Vietnamese has no
abbreviation for `giờ` or `phút`, so `Intl`'s short form does nothing here and the fix has
to be the wrap rather than the wording. */}
- {value}
+
+
+
);
}
diff --git a/apps/web/src/screens/HomeScreen.tsx b/apps/web/src/screens/HomeScreen.tsx
index bb792af..2dfa872 100644
--- a/apps/web/src/screens/HomeScreen.tsx
+++ b/apps/web/src/screens/HomeScreen.tsx
@@ -4,7 +4,17 @@ import { ArrowRight, CircleAlert, FileUp, PencilLine, Sparkles } from "lucide-re
import { useCallback, useEffect, useMemo, useState } from "react";
import { Recent } from "../components/library/Recent";
-import { Avatar, Button, Card, CardBody, CardHeader, Page, PageGlow, Wave } from "../components/ui";
+import {
+ Avatar,
+ Button,
+ Card,
+ CardBody,
+ CardHeader,
+ Page,
+ PageGlow,
+ Ticker,
+ Wave,
+} from "../components/ui";
import { useI18n, useT } from "../i18n/context";
import { clock } from "../lib/clock";
import { cn } from "../lib/cn";
@@ -206,12 +216,19 @@ export function HomeScreen() {
className={cn(
"transition-colors duration-300",
session.recording && recent.length > 0 ? "h-10" : "min-h-16 flex-1",
- session.recording ? "text-rec" : "text-fg-faint/35",
+ session.recording ? "text-rec" : "text-accent/30",
)}
>
{/* Its own silhouette rather than a flat line: equal bars read as a dotted rule
- somebody left in, not as sound. */}
-
+ somebody left in, not as sound. Idle it breathes, because forty motionless grey
+ bars across a third of the home screen read as a meter that has stopped
+ working. */}
+
@@ -330,7 +347,9 @@ function Stat({ value, label }: { value: string; label: string }) {
return (
{label}
- {value}
+
+
+
);
}
diff --git a/apps/web/src/screens/ModelsScreen.tsx b/apps/web/src/screens/ModelsScreen.tsx
index 79e7fcd..dde45c7 100644
--- a/apps/web/src/screens/ModelsScreen.tsx
+++ b/apps/web/src/screens/ModelsScreen.tsx
@@ -141,7 +141,7 @@ export function ModelsScreen() {
{/* What they cost, which is why somebody opens this screen a second time. */}
{installedBytes(models) > 0 && (
-
+
{t("models.on_disk", { size: size(installedBytes(models)) })}
)}
@@ -329,7 +329,7 @@ function Card({
style={{ width: `${done ?? 0}%` }}
/>
-
+
{done === null ? t("models.starting") : `${done}%`}
diff --git a/apps/web/src/screens/TasksScreen.tsx b/apps/web/src/screens/TasksScreen.tsx
index be5ebf0..68a057e 100644
--- a/apps/web/src/screens/TasksScreen.tsx
+++ b/apps/web/src/screens/TasksScreen.tsx
@@ -277,7 +277,7 @@ function Column({
>
{label}
- {count}
+ {count}
{/* An empty column says so rather than being a bordered rectangle of nothing. Four of those
side by side is what made a board with no work on it look like a board that failed to
@@ -338,7 +338,7 @@ function PersonCard({
)}
{task.due && (
-
+
{((d) => t(d.key, d.params))(dueLabel(task.due, now))}
)}
diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css
index 066ff6b..3918bfb 100644
--- a/apps/web/src/styles/theme.css
+++ b/apps/web/src/styles/theme.css
@@ -57,9 +57,15 @@
--color-ai-soft: #b49bff1f;
--color-ai-fg: #120a26;
- /* Recording is the one state that must never be mistaken for anything else. */
- --color-rec: #f2585b;
- --color-rec-soft: #f2585b1a;
+ /* Recording is the one state that must never be mistaken for anything else.
+ *
+ * Lighter than the obvious red, because this hue is also *text* — on the refused-permission chip,
+ * on an overdue date, on the timer. At `#f2585b` that text measured 4.35:1 against its own
+ * `-soft` tint, which is a real failure the screenshot audit reported on every run and nobody
+ * acted on. Raised until it clears 4.5:1 with room to spare, which also puts it in the same
+ * pastel family as the accent and the assistant violet rather than beside them. */
+ --color-rec: #fb6b70;
+ --color-rec-soft: #fb6b701a;
/* Task and job states. Semantic, not decorative. */
--color-done: #64e3a1;
@@ -276,6 +282,8 @@
--color-ai: #b49bff;
--color-ai-soft: #b49bff1f;
--color-ai-fg: #120a26;
+ --color-rec: #fb6b70;
+ --color-rec-soft: #fb6b701a;
--avatar-fill-l: 0.72;
--avatar-fill-c: 0.11;
--avatar-fill-a: 0.22;
@@ -302,12 +310,56 @@ body {
-webkit-font-smoothing: antialiased;
}
-/* Timestamps, durations and counters never reflow as their digits change. */
+/* Two ways to hold a number still, and the difference between them was most of what made this
+ * interface look unfinished.
+ *
+ * `.tabular` is the monospaced one, for a *column of digits and nothing else*: a clock beside a
+ * transcript line, a playhead, a date on its own. There the mono face is doing real work — the
+ * digits are one width, so the column does not shudder as the seconds tick.
+ *
+ * `.nums` is for a number inside a sentence: "1 giờ 12 phút", "hạn 2026-08-20", "3 công cụ". These
+ * were all `.tabular`, which set the *family* as well as the figures, so every stat on the home
+ * screen, the analytics cards and the task rows rendered their Vietnamese in JetBrains Mono next to
+ * Inter everywhere else. Tone marks in a monospaced face sit badly and the words looked like a
+ * fallback font that had failed to load. Same steady digits, without changing the typeface under
+ * the words that surround them. */
.tabular {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
+.nums {
+ font-variant-numeric: tabular-nums;
+}
+
+/* A card that can be clicked, and says so before it is.
+ *
+ * The interface had elevation tokens and used exactly one of them, so every surface sat at the same
+ * height and nothing on screen looked reachable. This is the whole hover vocabulary in one place:
+ * rise a little, cast further, and let the hairline strengthen. One pixel of travel, because a card
+ * that jumps under the pointer is a card that moves out from under a click. */
+.lift {
+ transition:
+ transform 0.18s var(--ease-out),
+ box-shadow 0.18s var(--ease-out),
+ border-color 0.18s var(--ease-out);
+}
+.lift:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-pop);
+ border-color: var(--color-line-strong);
+}
+.lift:active {
+ transform: translateY(0);
+ transition-duration: 0.08s;
+}
+@media (prefers-reduced-motion: reduce) {
+ .lift:hover,
+ .lift:active {
+ transform: none;
+ }
+}
+
/* Tauri draws no title bar, so the header doubles as the drag region. */
.drag-region {
-webkit-app-region: drag;
@@ -349,6 +401,19 @@ body {
}
}
+/* The same meter, at rest. A tenth of the amplitude of `wave` and four times the period: it has to
+ * be findable only if you look for it. Stopped by `prefers-reduced-motion` along with everything
+ * else, which is why it may never be the only sign that capture is available. */
+@keyframes breathe {
+ 0%,
+ 100% {
+ transform: scaleY(0.82);
+ }
+ 50% {
+ transform: scaleY(1);
+ }
+}
+
@keyframes shimmer {
100% {
transform: translateX(100%);