Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/Library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<span
className="tabular text-2xl font-semibold tracking-tight text-balance break-keep"
className="nums text-2xl font-semibold tracking-tight text-balance break-keep"
data-testid="tile-value"
>
{value}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ export function StatusBar({
<span className="flex-1" />
{disconnected && (
<span
className="border-line tabular border-rec text-rec text-micro inline-flex items-center rounded-full border px-2 py-0.5"
className="border-line nums border-rec text-rec text-micro inline-flex items-center rounded-full border px-2 py-0.5"
data-testid="connection"
>
{connection === "reconnecting" ? t("status.reconnecting") : t("status.connecting")}
</span>
)}
{device && (
<span className="border-line tabular text-micro inline-flex items-center rounded-full border px-2 py-0.5">
<span className="border-line nums text-micro inline-flex items-center rounded-full border px-2 py-0.5">
{device}
</span>
)}
{speakers.length > 0 && (
<span className="border-line tabular text-micro inline-flex items-center rounded-full border px-2 py-0.5">
<span className="border-line nums text-micro inline-flex items-center rounded-full border px-2 py-0.5">
{n("status.speakers", speakers.length)}
</span>
)}
Expand All @@ -63,7 +63,7 @@ export function StatusBar({
{stat.rss_mb} MB
</span>
{behind && (
<span className="border-line tabular border-rec text-rec text-micro inline-flex items-center rounded-full border px-2 py-0.5">
<span className="border-line nums border-rec text-rec text-micro inline-flex items-center rounded-full border px-2 py-0.5">
{t("status.behind")}
</span>
)}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/library/Finder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function Finder({ view, folder, tags, colour, onFolder, onTags, onColour
)}
>
#{each.name}
<span className="tabular text-fg-faint ms-1">{each.count}</span>
<span className="nums text-fg-faint ms-1">{each.count}</span>
</button>
);
})}
Expand Down Expand Up @@ -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}`)}
<span className="tabular text-fg-faint">{each.count}</span>
<span className="nums text-fg-faint">{each.count}</span>
</button>
);
})}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/library/Recent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/shell/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)}
>
Expand All @@ -387,7 +391,7 @@ function NavButton({
<item.icon aria-hidden="true" className="size-4 shrink-0 stroke-[1.75]" />
{item.label}
{item.badge ? (
<span className="tabular bg-rec text-micro ml-auto rounded-full px-1.5 py-0.5 font-semibold text-white">
<span className="nums bg-rec text-micro ml-auto rounded-full px-1.5 py-0.5 font-semibold text-white">
{item.badge}
</span>
) : null}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ui/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function Chip({
const inner = (
<>
{children}
{count !== undefined && <span className="tabular text-fg-faint">{count}</span>}
{count !== undefined && <span className="nums text-fg-faint">{count}</span>}
</>
);

Expand Down Expand Up @@ -95,7 +95,7 @@ export function SectionTitle({
className={cn("text-fg-faint text-micro font-semibold tracking-wider uppercase", className)}
>
{children}
{count !== undefined && <span className="tabular ms-1.5 font-normal">{count}</span>}
{count !== undefined && <span className="nums ms-1.5 font-normal">{count}</span>}
</h3>
);
}
79 changes: 79 additions & 0 deletions apps/web/src/components/ui/Ticker.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>(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 (
<>
<span ref={node} aria-hidden="true" className={className}>
{target === null || still ? value : "0"}
</span>
<span className="sr-only">{value}</span>
</>
);
}

/**
* 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;
}
19 changes: 18 additions & 1 deletion apps/web/src/components/ui/Wave.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 (
<span
Expand All @@ -52,11 +66,14 @@ export function Wave({
// any width, and `justify-between` spreads them.
"w-[3px] shrink-0 rounded-[2px] bg-current opacity-70",
live && "animate-[wave_1.1s_ease-in-out_infinite]",
breathe && !live && "animate-[breathe_4s_ease-in-out_infinite]",
)}
style={{
height: `${Math.max(8, height * 100)}%`,
// Neighbouring bars must not rise together or the whole thing pulses like a heartbeat.
animationDelay: live ? `${(at % 7) * 90}ms` : undefined,
// Idle, the offsets are spread much wider so the movement travels along the bar rather
// than flickering in place.
animationDelay: moving ? `${(at % (live ? 7 : bars)) * (live ? 90 : 70)}ms` : undefined,
}}
/>
))}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
9 changes: 4 additions & 5 deletions apps/web/src/screens/AgentsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -216,12 +215,12 @@ export function AgentsScreen() {
</p>

<p className="text-fg-faint text-micro mt-2.5 flex flex-wrap items-center gap-1.5">
<span className="tabular">{n("agents.tool_count", each.tools.length)}</span>
<span className="nums">{n("agents.tool_count", each.tools.length)}</span>
{each.memory.length > 0 && (
<span className="tabular">· {n("agents.memory_count", each.memory.length)}</span>
<span className="nums">· {n("agents.memory_count", each.memory.length)}</span>
)}
{each.open_tasks > 0 && (
<span className="tabular">· {n("agents.task_count", each.open_tasks)}</span>
<span className="nums">· {n("agents.task_count", each.open_tasks)}</span>
)}
{each.model && <span className="tabular">· {each.model}</span>}
</p>
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/screens/AnalyticsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Page,
PageGlow,
SegmentedControl,
Ticker,
} from "../components/ui";
import { GENTLE } from "../lib/motion";
import { useLoad } from "../lib/use-load";
Expand Down Expand Up @@ -117,7 +118,7 @@ export function AnalyticsScreen() {
}}
/>
</span>
<span className="tabular text-fg-dim text-meta w-28 shrink-0 text-right text-balance">
<span className="nums text-fg-dim text-meta w-28 shrink-0 text-right text-balance">
{formatDuration(person.seconds, locale, "short")}
</span>
</div>
Expand Down Expand Up @@ -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. */}
<p className="tabular mt-1 text-xl font-semibold text-balance">{value}</p>
<p className="nums mt-1 text-xl font-semibold text-balance">
<Ticker value={value} />
</p>
</Card>
);
}
29 changes: 24 additions & 5 deletions apps/web/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */}
<Wave seed="capture" bars={40} live={session.recording} />
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. */}
<Wave
seed="capture"
bars={40}
live={session.recording}
breathe={!session.recording}
/>
</div>

<div className="flex flex-wrap items-center gap-2">
Expand Down Expand Up @@ -330,7 +347,9 @@ function Stat({ value, label }: { value: string; label: string }) {
return (
<div className="text-end">
<dt className="text-fg-faint text-micro">{label}</dt>
<dd className="tabular text-title mt-0.5 font-semibold">{value}</dd>
<dd className="nums text-title mt-0.5 font-semibold">
<Ticker value={value} />
</dd>
</div>
);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/screens/ModelsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function ModelsScreen() {
<PageGlow />
{/* What they cost, which is why somebody opens this screen a second time. */}
{installedBytes(models) > 0 && (
<p className="text-fg-dim tabular text-micro -mt-4">
<p className="text-fg-dim nums text-micro -mt-4">
{t("models.on_disk", { size: size(installedBytes(models)) })}
</p>
)}
Expand Down Expand Up @@ -329,7 +329,7 @@ function Card({
style={{ width: `${done ?? 0}%` }}
/>
</div>
<p className="text-fg-faint tabular text-micro mt-1">
<p className="text-fg-faint nums text-micro mt-1">
{done === null ? t("models.starting") : `${done}%`}
</p>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/screens/TasksScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ function Column({
>
<h2 className="text-fg-faint text-micro px-1 pb-2 font-semibold tracking-wider uppercase">
{label}
<span className="tabular ml-1.5 font-normal">{count}</span>
<span className="nums ml-1.5 font-normal">{count}</span>
</h2>
{/* 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
Expand Down Expand Up @@ -338,7 +338,7 @@ function PersonCard({
</span>
)}
{task.due && (
<span className={cn("tabular", overdue ? "text-rec" : "text-fg-faint")}>
<span className={cn("nums", overdue ? "text-rec" : "text-fg-faint")}>
{((d) => t(d.key, d.params))(dueLabel(task.due, now))}
</span>
)}
Expand Down
Loading
Loading