diff --git a/apps/web/src/components/library/Recent.tsx b/apps/web/src/components/library/Recent.tsx
index f5a25e0..99629bf 100644
--- a/apps/web/src/components/library/Recent.tsx
+++ b/apps/web/src/components/library/Recent.tsx
@@ -8,7 +8,7 @@ import { cn } from "../../lib/cn";
import { formatDuration } from "../../lib/duration";
import { useEngine } from "../../lib/engine-context";
import { LibraryClient, dayLabel, localDay, type MeetingSummary } from "../../lib/library";
-import { GENTLE, listItem, stagger } from "../../lib/motion";
+import { listItem, stagger } from "../../lib/motion";
/**
* The last few things in the vault, as cards.
@@ -84,11 +84,14 @@ export function Recent({
>
{entries.map((entry) => (
- onOpen(entry)}
- whileHover={{ y: -2 }}
- transition={GENTLE}
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
@@ -129,7 +132,7 @@ export function Recent({
))}
)}
-
+
))}
diff --git a/apps/web/src/components/ui/Ticker.tsx b/apps/web/src/components/ui/Ticker.tsx
index c46b5de..83049b3 100644
--- a/apps/web/src/components/ui/Ticker.tsx
+++ b/apps/web/src/components/ui/Ticker.tsx
@@ -28,6 +28,7 @@ import { useI18n } from "../../i18n/context";
*/
export function Ticker({ value, className }: { value: string; className?: string }) {
const node = useRef(null);
+ const counted = useRef(false);
const still = useReducedMotion();
const { locale } = useI18n();
@@ -39,18 +40,27 @@ export function Ticker({ value, className }: { value: string; className?: string
useEffect(() => {
const element = node.current;
if (!element) return;
- if (target === null || still) {
+ // Counted once, then never again for the life of this element.
+ //
+ // The analytics screen switches range — today, seven days, thirty — and the figure it holds
+ // changes under a person who is comparing it against the one that was there a second ago.
+ // Rolling that from zero every time turns a comparison into a wait, and it is the animation
+ // equivalent of a page reload. Arrival is the only moment counting says anything true.
+ if (target === null || still || counted.current) {
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));
},
+ // On completion rather than on start, so an animation that was torn down before it finished
+ // — React's development double-invoke does exactly this — is allowed to run again.
+ onComplete: () => {
+ counted.current = true;
+ },
});
return () => running.stop();
}, [target, value, still, format]);
diff --git a/apps/web/src/screens/AgentsScreen.tsx b/apps/web/src/screens/AgentsScreen.tsx
index 3a4755b..9231566 100644
--- a/apps/web/src/screens/AgentsScreen.tsx
+++ b/apps/web/src/screens/AgentsScreen.tsx
@@ -194,8 +194,16 @@ export function AgentsScreen() {
onClick={() => void open(each.slug)}
className={cn(
"rounded-card bg-bg-soft lift border p-4 text-left",
+ // An outline for the chosen card, not a shadow and not a ring.
+ //
+ // `.lift:hover` sets `box-shadow`, and a class beats a utility of lower specificity,
+ // so hovering the selected agent replaced its accent edge with the ordinary hover
+ // shadow and the selection vanished under the pointer. Swapping to `ring-1` did not
+ // help — Tailwind v4 implements a ring *as* a box-shadow, which the browser confirmed
+ // by reporting the same two shadows and no accent at all. `outline` is a separate
+ // property and survives both.
each.slug === chosen
- ? "border-accent shadow-[0_0_0_1px_var(--color-accent)]"
+ ? "border-accent outline-accent outline-1"
: "border-line hover:border-fg-faint",
)}
>
diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css
index ff00adc..7b22148 100644
--- a/apps/web/src/styles/theme.css
+++ b/apps/web/src/styles/theme.css
@@ -360,23 +360,31 @@ body {
* that jumps under the pointer is a card that moves out from under a click. */
.lift {
transition:
- transform 0.18s var(--ease-out),
+ translate 0.18s var(--ease-out),
box-shadow 0.18s var(--ease-out),
border-color 0.18s var(--ease-out);
}
.lift:hover {
- transform: translateY(-2px);
+ /* `translate`, not `transform`.
+ *
+ * Both cards this is on are Motion elements, and Motion animates by writing `transform` into the
+ * inline style — which no class can outrank. A `transform: translateY(-2px)` here did nothing at
+ * all on either of them, and the `transition: transform` beside it was worse than nothing: it
+ * applied to the inline transform Motion rewrites every frame, so the library's entrance
+ * animation had a 180ms lag chasing it. `translate` is its own property, composes with whatever
+ * `transform` holds, and Motion never touches it. */
+ translate: 0 -2px;
box-shadow: var(--shadow-pop);
border-color: var(--color-line-strong);
}
.lift:active {
- transform: translateY(0);
+ translate: 0 0;
transition-duration: 0.08s;
}
@media (prefers-reduced-motion: reduce) {
.lift:hover,
.lift:active {
- transform: none;
+ translate: none;
}
}
diff --git a/apps/web/src/styles/theme.test.ts b/apps/web/src/styles/theme.test.ts
index b662a1b..876b298 100644
--- a/apps/web/src/styles/theme.test.ts
+++ b/apps/web/src/styles/theme.test.ts
@@ -91,8 +91,8 @@ describe("colour utilities", () => {
const defined = new Set([...css.matchAll(/--color-([a-z0-9-]+):/g)].map((m) => m[1]!));
/**
- * The four prefixes that are colours often enough to be worth checking, and the values they take
- * that are *not* colours.
+ * The prefixes that are colours often enough to be worth checking, and the values they take that
+ * are *not* colours.
*
* Tailwind overloads these: `text-sm` is a size, `bg-cover` is a fit, `border-b` is an edge. The
* list is static, it is Tailwind's rather than ours, and a false positive costs one line here —
@@ -109,8 +109,8 @@ describe("colour utilities", () => {
// border-
...["0", "2", "4", "8", "x", "y", "s", "e", "t", "r", "b", "l"],
...["solid", "dashed", "dotted", "double", "hidden", "collapse", "separate"],
- // ring-
- ...["1", "3", "inset", "offset"],
+ // ring- and outline-
+ ...["1", "3", "inset", "offset", "dashed-2"],
// Tailwind's own palette, used directly in a few places where a literal is honest.
...["white", "black", "transparent", "current", "inherit"],
]);
@@ -127,7 +127,7 @@ describe("colour utilities", () => {
return out;
}
- it("has a value behind every text-, bg-, border- and ring- colour the interface names", () => {
+ it("has a value behind every text-, bg-, border-, ring- and outline- colour it names", () => {
const files = sources("../");
expect(files.length, "no sources found — this test is checking nothing").toBeGreaterThan(20);
@@ -160,7 +160,7 @@ describe("colour utilities", () => {
// The name only, without the opacity suffix: `text-accent/60` is `accent`. The lookbehind is
// what stops `--avatar-text-c` and `--color-bg-elevated` reading as utilities.
for (const [, name] of source.matchAll(
- /(?