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%);