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
13 changes: 8 additions & 5 deletions apps/web/src/components/library/Recent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -84,11 +84,14 @@ export function Recent({
>
{entries.map((entry) => (
<motion.li key={entry.id} variants={listItem}>
<motion.button
{/* A plain button. The lift used to be `whileHover={{ y: -2 }}`, which is Motion running a
spring on one hover — a rAF loop and an inline transform per card, to move something
two pixels. `.lift` is the same movement as a CSS transition, it is the same rule every
other card on every other screen uses, and it costs nothing while the pointer is
elsewhere. */}
<button
type="button"
onClick={() => 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
Expand Down Expand Up @@ -129,7 +132,7 @@ export function Recent({
))}
</span>
)}
</motion.button>
</button>
</motion.li>
))}
</motion.ul>
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/components/ui/Ticker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useI18n } from "../../i18n/context";
*/
export function Ticker({ value, className }: { value: string; className?: string }) {
const node = useRef<HTMLSpanElement>(null);
const counted = useRef(false);
const still = useReducedMotion();
const { locale } = useI18n();

Expand All @@ -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]);
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/screens/AgentsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)}
>
Expand Down
16 changes: 12 additions & 4 deletions apps/web/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/styles/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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"],
]);
Expand All @@ -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);

Expand Down Expand Up @@ -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(
/(?<![\w-])(?:text|bg|border|ring)-([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(?:\/\d+)?/g,
/(?<![\w-])(?:text|bg|border|ring|outline)-([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(?:\/\d+)?/g,
)) {
if (resolves(name!)) continue;
if (!missing.has(name!)) missing.set(name!, file);
Expand Down
Loading