000h-cojeev
uiactivity-feedsource: ok · hit
Activity feed
An ordered, date-grouped history with a quiet connecting margin, readable actors and timestamps, and updates that keep your place.
npx shadcn add "https://000h.cojeev.com/r/activity-feed.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
https://cojeev.com/ui/r/cojeev.jsonhttps://cojeev.com/ui/r/animated-icon.jsonhttps://cojeev.com/ui/r/avatar.jsonhttps://cojeev.com/ui/r/badge.jsonhttps://cojeev.com/ui/r/button.jsonhttps://cojeev.com/ui/r/empty.jsonhttps://cojeev.com/ui/r/item.jsonhttps://cojeev.com/ui/r/presence.jsonhttps://cojeev.com/ui/r/typography.json
Files4 · source cached 10/9/2026, 1:19:01 PM
registry/cojeev/ui/activity-feed.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
ActivitySceneContext,
useActivityPresence,
useActivityScene,
type ActivityLayout,
} from "@/lib/cojeev/activity-motion";
import { MARK_PATHS } from "@/lib/cojeev/milestone-travel";
import { useChoreography } from "@/lib/cojeev-motion/choreography";
import { assignMotionRef } from "@/lib/cojeev-motion/refs";
import { useGuidanceMotion } from "@/lib/cojeev-motion/use-guidance-motion";
import { AnimatedIcon } from "@/components/ui/animated-icon";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Empty, EmptyDescription, EmptyTitle } from "@/components/ui/empty";
import { ItemContent, ItemDescription, ItemTitle } from "@/components/ui/item";
import { MotionPresence } from "@/components/ui/presence";
import { BodySecondary, Meta, Title } from "@/components/ui/typography";
export type ActivityEntry = {
id: string;
title: React.ReactNode;
description?: React.ReactNode;
actor?: { name: string; initials?: string; avatarSrc?: string };
timestamp?: React.ReactNode;
/** Machine-readable time matching the caller's timestamp label. */
dateTime?: string;
/** Optional visible group heading; shown where a consecutive group begins. */
group?: string;
badge?: { label: React.ReactNode; variant?: BadgeProps["variant"] };
/** Additional supplied details or native actions, kept in document flow. */
content?: React.ReactNode;
};
export type ActivityFeedProps = Omit<
React.ComponentProps<"section">,
"children" | "title"
> & {
/** Caller supplies the chronological order; the feed never reorders entries. */
entries: readonly ActivityEntry[];
title?: React.ReactNode;
description?: React.ReactNode;
empty?: React.ReactNode;
/** Omit to show every supplied entry, including later appends. */
initialVisible?: number;
pageSize?: number;
showMoreLabel?: React.ReactNode;
/**
* Look of the history. "thread": one row per entry on a living thread. "ledger": dense,
* one avatar per run of entries by the same actor within a group, pinned group labels.
* "bursts": one card per actor's updates made within 30 minutes of each other; two or
* more become a native disclosure titled "N updates".
*/
variant?: "thread" | "ledger" | "bursts";
};
/* ---------- grouping: the caller's order is kept; consecutive entries are only gathered ---------- */
const BURST_SPAN = 30 * 60 * 1000;
const actorOf = (entry: ActivityEntry) => entry.actor?.name.trim() ?? "";
const timeOf = (entry: ActivityEntry) => {
const t = entry.dateTime ? Date.parse(entry.dateTime) : NaN;
return Number.isFinite(t) ? t : null;
};
function joins(layout: ActivityLayout, newer: ActivityEntry, entry: ActivityEntry) {
if (layout === "thread" || actorOf(newer) !== actorOf(entry)) return false;
if (layout === "ledger") return true;
// A burst needs both times; an entry without one stands alone.
const a = timeOf(newer),
b = timeOf(entry);
return a !== null && b !== null && Math.abs(a - b) <= BURST_SPAN;
}
function runs<T>(items: readonly T[], same: (newer: T, item: T) => boolean) {
const out: T[][] = [];
for (const item of items) {
const last = out[out.length - 1];
if (last && same(last[last.length - 1], item)) last.push(item);
else out.push([item]);
}
return out;
}
type Keys = ReadonlyMap<string, string>;
/** A day keeps the key of any entry it held before, so prepends and reveals never remount its label. */
function keyGroups(
groups: readonly (readonly ActivityEntry[])[],
previous: Keys,
prefix: string,
) {
const used = new Set<string>();
return groups.map((group) => {
let key = group
.map((entry) => previous.get(entry.id))
.find((k): k is string => k !== undefined && !used.has(k));
if (key === undefined) {
key = `${prefix}${group[0].id}`;
while (used.has(key)) key += "+";
}
used.add(key);
return key;
});
}
const sameKeys = (a: Keys, b: Keys) =>
a.size === b.size && [...a].every(([id, key]) => b.get(id) === key);
/** Where a row sits in its run or card; only the first row carries the head. */
type Place = {
first: boolean;
last: boolean;
dayEnd: boolean;
/** Entries in the run or card. */
size: number;
};
function count(value: number | undefined, fallback: number) {
return value !== undefined && Number.isFinite(value)
? Math.max(1, Math.floor(value))
: fallback;
}
const initialsOf = (name: string) =>
name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => Array.from(part)[0])
.join("");
/** An actor's avatar, or the four-point star for the product's own entries. */
function Mark({ actor }: { actor?: ActivityEntry["actor"] }) {
if (!actor)
return (
<span
className="v-activity-feed__mark v-activity-feed__marker"
data-brand=""
>
<svg viewBox="-18 -18 36 36" focusable="false" aria-hidden="true">
<path d={MARK_PATHS.star} />
</svg>
</span>
);
return (
<span className="v-activity-feed__mark">
<Avatar variant="sm">
{actor.avatarSrc && <AvatarImage src={actor.avatarSrc} alt="" />}
<AvatarFallback>{actor.initials ?? initialsOf(actor.name)}</AvatarFallback>
</Avatar>
</span>
);
}
function EntryContent({ entry }: { entry: ActivityEntry }) {
return (
<ItemContent className="v-activity-feed__content">
<div className="v-activity-feed__entry-heading">
<ItemTitle className="v-activity-feed__title">{entry.title}</ItemTitle>
{entry.badge && (
<Badge
size="sm"
variant={entry.badge.variant ?? "cream"}
className="v-activity-feed__badge"
>
{entry.badge.label}
</Badge>
)}
</div>
{entry.description && (
<ItemDescription className="v-activity-feed__description">
{entry.description}
</ItemDescription>
)}
{(entry.actor || entry.timestamp) && (
<div className="v-activity-feed__meta">
{entry.actor && (
<Meta className="v-activity-feed__who">{entry.actor.name}</Meta>
)}
{entry.actor && entry.timestamp && (
<span aria-hidden="true" className="v-activity-feed__sep">
·
</span>
)}
<span aria-hidden="true" className="v-activity-feed__live" />
{entry.timestamp && (
<time dateTime={entry.dateTime}>
<Meta>{entry.timestamp}</Meta>
</time>
)}
</div>
)}
{entry.content && (
<div className="v-activity-feed__details">{entry.content}</div>
)}
</ItemContent>
);
}
/** A day's heading: its own list item, so a row arriving under it never moves it. */
function GroupLabel({ label }: { label: string }) {
const [ref, leaving] = useActivityPresence<HTMLLIElement>();
return (
<li
ref={ref}
data-leaving={leaving || undefined}
inert={leaving || undefined}
aria-hidden={leaving || undefined}
className="v-activity-feed__group"
>
<div className="v-activity-feed__group-text">{label}</div>
</li>
);
}
/*
* Every row is a direct child of the one list in every look, keyed by its entry: a run or
* card forming, growing or splitting only changes attributes, never a row's parent.
*/
function Row({
entry,
layout,
place,
}: {
entry: ActivityEntry;
layout: ActivityLayout;
place: Place;
}) {
const [ref, leaving] = useActivityPresence<HTMLLIElement>();
const scene = React.useContext(ActivitySceneContext);
const { first, last, dayEnd, size } = place;
const name = entry.actor?.name.trim();
const burst = layout === "bursts" && size > 1;
const toggle = (event: React.MouseEvent<HTMLElement>) => {
const details = event.currentTarget.parentElement;
if (details instanceof HTMLDetailsElement && scene.current?.toggle(details))
event.preventDefault();
};
let body: React.ReactNode;
if (layout === "thread")
body = (
<div className="v-activity-feed__inner">
<div className="v-activity-feed__portrait" aria-hidden="true">
<Mark actor={entry.actor} />
</div>
<EntryContent entry={entry} />
</div>
);
else if (layout === "ledger")
body = (
<div className="v-activity-feed__inner">
<div className="v-activity-feed__portrait" aria-hidden="true">
{first && <Mark actor={entry.actor} />}
</div>
<div className="v-activity-feed__body-cell">
{/* Each row keeps the actor in its own accessible text. */}
{first && name && (
<p className="v-activity-feed__run-name" aria-hidden="true">
{name}
</p>
)}
<div className="v-activity-feed__line">
<span className="v-activity-feed__wash" aria-hidden="true" />
<EntryContent entry={entry} />
</div>
</div>
</div>
);
else
body = (
<div className="v-activity-feed__card">
{first && !burst && (
<div className="v-activity-feed__card-head" aria-hidden="true">
<Mark actor={entry.actor} />
{name && (
<span className="v-activity-feed__card-who">
<span className="v-activity-feed__card-name">{name}</span>
</span>
)}
</div>
)}
{/* Each row's own disclosure: find in page and links can open a closed burst. */}
<details
open
className="v-activity-feed__fold"
onToggle={(event) => scene.current?.reveal(event.currentTarget)}
>
{first && burst ? (
<summary className="v-activity-feed__card-head" onClick={toggle}>
<span aria-hidden="true">
<Mark actor={entry.actor} />
</span>
<span className="v-activity-feed__card-who">
{name && (
<span className="v-activity-feed__card-name">{name}</span>
)}
<span className="v-activity-feed__card-count">
{size} updates
</span>
</span>
<svg
className="v-activity-feed__chevron"
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
>
<path d="M4 6l4 4 4-4" />
</svg>
</summary>
) : (
<summary hidden />
)}
<div className="v-activity-feed__inner">
<EntryContent entry={entry} />
</div>
</details>
</div>
);
return (
<li
ref={ref}
tabIndex={-1}
data-activity-entry={entry.id}
data-leaving={leaving || undefined}
inert={leaving || undefined}
aria-hidden={leaving || undefined}
data-first={(layout !== "thread" && first) || undefined}
data-last={(layout !== "thread" && last) || undefined}
data-burst={burst || undefined}
data-day-end={dayEnd || undefined}
className="v-activity-feed__entry"
>
{body}
</li>
);
}
export function ActivityFeed({
entries,
title,
description,
empty,
initialVisible,
pageSize = 3,
showMoreLabel,
variant,
className,
ref,
"aria-label": ariaLabel,
"aria-labelledby": labelledBy,
...props
}: ActivityFeedProps) {
const layout: ActivityLayout =
variant === "ledger" || variant === "bursts" ? variant : "thread";
const titleId = React.useId();
const listId = React.useId();
const section = React.useRef<HTMLElement | null>(null);
const setSection = React.useCallback(
(node: HTMLElement | null) => {
section.current = node;
return assignMotionRef(ref, node);
},
[ref],
);
const layer = React.useRef<SVGSVGElement | null>(null);
const list = React.useRef<HTMLOListElement>(null);
const focusIndex = React.useRef<number | null>(null);
const revealed = React.useRef<string[]>([]);
const [windowState, setWindowState] = React.useState(() => ({
ids: entries.map((entry) => entry.id),
limit: count(initialVisible, Infinity),
}));
const ids = entries.map((entry) => entry.id);
let limit = windowState.limit;
if (
ids.length !== windowState.ids.length ||
ids.some((id, index) => id !== windowState.ids[index])
) {
const oldFirst = windowState.ids[0],
firstIndex = ids.indexOf(oldFirst),
previous = new Set(windowState.ids);
const prepended =
firstIndex > 0
? ids.slice(0, firstIndex).filter((id) => !previous.has(id)).length
: 0;
limit += prepended;
// Reconcile before committing the new list: existing entries never unmount
// for a frame merely because a new update was prepended.
setWindowState({ ids, limit });
}
const [announcement, setAnnouncement] = React.useState("");
const visible = entries.slice(0, limit);
const remaining = entries.length - visible.length;
const nextPage = Math.min(remaining, count(pageSize, 3));
// Days keep their identity across prepends and reveals, so their labels stay mounted.
const [dayKeys, setDayKeys] = React.useState<Keys>(() => new Map());
const dayRuns = runs(visible, (a, b) => a.group === b.group);
const dayKeyList = keyGroups(dayRuns, dayKeys, "d:");
const nextDayKeys = new Map(
dayRuns.flatMap((day, i) => day.map((e) => [e.id, dayKeyList[i]] as const)),
);
if (!sameKeys(nextDayKeys, dayKeys)) setDayKeys(nextDayKeys);
const items: React.ReactNode[] = [];
dayRuns.forEach((day, i) => {
const label = day[0].group;
if (label)
items.push(
<GroupLabel key={`\u0001label:${dayKeyList[i]}`} label={label} />,
);
for (const group of runs(day, (a, b) => joins(layout, a, b)))
group.forEach((entry, j) =>
items.push(
<Row
key={entry.id}
entry={entry}
layout={layout}
place={{
first: j === 0,
last: j === group.length - 1,
dayEnd: entry === day[day.length - 1],
size: group.length,
}}
/>,
),
);
});
const { quiet: globalQuiet } = useChoreography();
const { quiet, enabled, inView } = useGuidanceMotion(section);
const live = !quiet && enabled && inView;
const scene = useActivityScene(section, {
layout,
ids: visible.map((entry) => entry.id),
brand: new Set(visible.filter((e) => !e.actor).map((e) => e.id)),
allowed: live,
breathe: live,
layer,
takeRevealed: () => {
const taken = revealed.current;
revealed.current = [];
return taken;
},
});
React.useLayoutEffect(() => {
if (focusIndex.current === null) return;
const target = list.current?.querySelectorAll<HTMLElement>(
"[data-activity-entry]:not([data-leaving])",
)[focusIndex.current];
// A revealed entry inside a closed burst opens its card, so focus lands on something visible.
if (target && layout === "bursts") scene.current?.openCard(target);
target?.focus();
focusIndex.current = null;
}, [visible.length, layout, scene]);
const reveal = () => {
focusIndex.current = visible.length;
const nextCount = visible.length + nextPage;
revealed.current = entries
.slice(visible.length, nextCount)
.map((entry) => entry.id);
setWindowState((value) => ({ ...value, limit: nextCount }));
setAnnouncement(`${nextCount} of ${entries.length} activities shown.`);
};
return (
<section
{...props}
ref={setSection}
data-slot="activity-feed"
data-variant={layout}
data-motion-quiet={globalQuiet || undefined}
aria-label={ariaLabel ?? (!title && !labelledBy ? "Activity" : undefined)}
aria-labelledby={labelledBy ?? (title ? titleId : undefined)}
className={cn("v-activity-feed", className)}
>
{(title || description) && (
<header className="v-activity-feed__header">
{title && (
<div className="v-activity-feed__heading">
<Title id={titleId}>{title}</Title>
<Badge
variant="count"
aria-label={`${entries.length} activities`}
>
{entries.length}
</Badge>
</div>
)}
{description && <BodySecondary as="div">{description}</BodySecondary>}
</header>
)}
{entries.length === 0 ? (
(empty ?? (
<Empty>
<EmptyTitle>No activity yet</EmptyTitle>
<EmptyDescription>
Updates appear here as they are added.
</EmptyDescription>
</Empty>
))
) : (
<ActivitySceneContext.Provider value={scene}>
<div className="v-activity-feed__body">
<ol
ref={list}
id={listId}
className="v-activity-feed__list"
role="list"
>
<MotionPresence>
{items}
</MotionPresence>
</ol>
{layout === "thread" && (
<svg
ref={layer}
className="v-activity-feed__thread"
aria-hidden="true"
focusable="false"
/>
)}
</div>
{remaining > 0 && (
<footer className="v-activity-feed__footer">
<Button
variant="secondary"
size="sm"
aria-controls={listId}
onClick={reveal}
>
<AnimatedIcon name="chevron-down" size="sm" preset="bounce" />
{showMoreLabel ?? `Show ${nextPage} more`}
</Button>
<Meta>
{visible.length} of {entries.length}
</Meta>
</footer>
)}
</ActivitySceneContext.Provider>
)}
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</section>
);
}
registry/cojeev/lib/activity-motion.tsx
"use client";
import * as React from "react";
import { usePresence } from "motion/react";
import { path as outline, spring, type Spring } from "@/lib/cojeev-motion/geometry";
import {
cancelMotion,
registerMotionClock,
scheduleMotion,
type MotionTimer,
} from "@/lib/cojeev-motion/clock";
import { createMotionLane } from "@/lib/cojeev-motion/choreography";
import { MARK_POINTS } from "@/lib/cojeev/milestone-travel";
export type ActivityLayout = "thread" | "ledger" | "bursts";
type Pts = readonly (readonly number[])[];
type Pair = readonly [number, number];
/* ---------- motion table: every spring is [stiffness, damping ratio] ---------- */
const SPR = {
remove: [300, 1], // a removed row or label closes to zero
restore: [260, 0.78], // a removal reversed before it finished
markIn: [320, 0.6], // a revealed thread marker pops in
markOut: [380, 0.8], // a removed thread marker folds away
thread: {
arrive: [210, 0.72], // the new row makes room; content fades in at 55 %
threadUp: [150, 0.95], // the thread grows up to meet it from 80 %
seed: [320, 0.6], // the seed appears
morph: [220, 0.6], // seed to four-point star
starGrow: [260, 0.5],
spin: [170, 0.5],
handoff: [380, 1], // the star yields to the avatar
avatarIn: [340, 0.5],
unfurl: [240, 0.85],
threadDown: [220, 1],
},
ledger: { arrive: [300, 1], wash: [150, 1], unfurl: [300, 1] },
bursts: {
arrive: [240, 0.58], // the card stretches to take the new line
gulp: [260, 0.45], // a closed burst takes a line without opening
unfurl: [240, 0.62],
open: [260, 0.62],
close: [320, 0.9],
},
} as const satisfies Record<string, unknown>;
const T = {
bloomMorphAt: 170,
unfurlStagger: 70,
unfurlMax: 3,
breathFor: 45000,
waitMax: 4,
};
const GAP = 18; // thread clearance around a 28 px marker
const MARK_SCALE = 30 / 36; // marks paint their -18..18 outline at 30 px
const PEBBLE = MARK_POINTS.pebble,
STAR = MARK_POINTS.star;
/*
* One frame loop for every feed, steppable by the shared motion clock. It only drives what
* must be measured every frame: box heights that chase their live content, and the thread
* layer drawn from those boxes. Single values run on the shared motion lanes.
*/
type Scene = { frame: (dt: number) => boolean };
const scenes = new Set<Scene>();
let clock: number | null = null,
previous: number | null = null,
raf = 0;
function stopLoop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
function tick(t: number) {
const dt = previous === null ? 0 : Math.max(0, (t - previous) / 1000);
previous = t;
let active = false;
scenes.forEach((s) => {
active = s.frame(dt) || active;
});
return active;
}
function wake() {
if (clock !== null || raf || !scenes.size || document.hidden) return;
if (previous === null) previous = performance.now();
raf = requestAnimationFrame((t) => {
raf = 0;
if (tick(t)) wake();
else previous = null;
});
}
if (typeof window !== "undefined")
registerMotionClock((t) => {
stopLoop();
if (t === null) {
clock = null;
previous = null;
wake();
} else {
if (clock === null) previous = t;
clock = t;
tick(t);
}
});
/* ---------- helpers ---------- */
const NS = "http://www.w3.org/2000/svg";
const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const f2 = (n: number) => n.toFixed(2);
const S = ([k, z]: Pair, x = 0): Spring => ({ x, v: 0, to: x, k, z });
const tune = (s: Spring, [k, z]: Pair) => {
s.k = k;
s.z = z;
return s;
};
const rest = (s: Spring) => s.x === s.to && s.v === 0;
/** A [stiffness, ratio] pair as a shared motion spring. */
const lane = ([k, z]: Pair, velocity = 0) => ({
type: "spring" as const,
stiffness: k,
damping: 2 * Math.sqrt(k) * z,
mass: 1,
velocity,
restDelta: 0.001,
restSpeed: 0.01,
});
const lerpPts = (A: Pts, B: Pts, m: number) =>
A.map((p, i) => [p[0] + (B[i][0] - p[0]) * m, p[1] + (B[i][1] - p[1]) * m]);
const hash = (s: string) => {
let x = 2166136261;
for (const c of s) x = Math.imul(x ^ c.charCodeAt(0), 16777619);
return x >>> 0;
};
/** Natural height of a box the scene is sizing, read without a visible frame. */
function natural(el: HTMLElement) {
const was = el.style.height;
if (!was) return el.getBoundingClientRect().height;
el.style.height = "";
const h = el.getBoundingClientRect().height;
el.style.height = was;
return h;
}
const ROW = "[data-activity-entry]";
const LIST = ".v-activity-feed__list";
const markOf = (row: Element) =>
row.querySelector<HTMLElement>(".v-activity-feed__mark");
/** A bursts row's own disclosure: every row sits inside one, so find-in-page can open it. */
export const foldOf = (row: Element) =>
row.querySelector<HTMLDetailsElement>(
":scope > .v-activity-feed__card > details",
);
const leaving = (el: Element) => el.hasAttribute("data-leaving");
type Side = "previousElementSibling" | "nextElementSibling";
/** The nearest box on one side that is staying. */
function beside(el: Element, side: Side) {
let n = el[side];
while (n && leaving(n)) n = n[side];
return n instanceof HTMLElement ? n : null;
}
/** The rows of the card or run that holds a row, in order. */
function groupOf(row: HTMLElement) {
let start = row;
while (!start.hasAttribute("data-first")) {
const up = beside(start, "previousElementSibling");
if (!up?.matches(ROW)) break;
start = up;
}
const out: HTMLElement[] = [];
for (let r: HTMLElement | null = start; r?.matches(ROW); ) {
out.push(r);
if (r.hasAttribute("data-last")) break;
r = beside(r, "nextElementSibling");
}
return out;
}
type Height = {
s: Spring;
start: number;
to: number | "auto";
onFrame?: (p: number) => void;
done?: () => void;
};
type Owned = { owner?: HTMLElement };
type Anim = Owned & { tick: (dt: number) => boolean; done?: () => void };
type Wait = Owned & { when: () => boolean; run: () => void; left: number };
type Glide = Owned & { finish: () => void; cancel: () => void };
type Grow = { x: number; dir: "up" | "down" };
type Blob = {
row: HTMLElement;
s: Spring;
m: Spring;
rot: Spring;
A: Pts;
B: Pts;
fill: "pink" | "olive";
el: SVGPathElement;
};
export type ActivitySync = {
layout: ActivityLayout;
/** Visible entry ids in order. */
ids: readonly string[];
/** Ids revealed by the last Show more, consumed by this sync. */
revealed: readonly string[];
/** Brand entries keep the star as their mark. */
brand: ReadonlySet<string>;
allowed: boolean;
breathe: boolean;
layer: SVGSVGElement | null;
};
function createScene(host: HTMLElement) {
let layout: ActivityLayout = "thread",
layer: SVGSVGElement | null = null,
spine: SVGGElement | null = null,
seeds: SVGGElement | null = null,
gen = 0,
ids: string[] = [],
brand: ReadonlySet<string> = new Set(),
started = false,
allowed = false,
breatheOn = false,
breathId: string | null = null,
breathTimer: MotionTimer | null = null;
const heights = new Map<HTMLElement, Height>();
const anims = new Set<Anim>();
const waits = new Set<Wait>();
const glides = new Set<Glide>();
const timers = new Set<MotionTimer>();
const grow = new Map<string, Grow>();
const blobs = new Set<Blob>();
const markScale = new Map<HTMLElement, number>();
/** Each box's height at its last rest paint, so a regroup can be absorbed without a jump. */
const restHeights = new WeakMap<Element, number>();
const known = new WeakSet<Element>();
/** Boxes that have left: nothing may write to them again. */
const gone = new WeakSet<Element>();
const segments: SVGPathElement[] = [];
const rowOf = (id: string) =>
host.querySelector<HTMLElement>(
`[data-activity-entry="${CSS.escape(id)}"]:not([data-leaving])`,
);
const boxes = () => {
const list = host.querySelector(LIST);
return list
? [...list.children].filter(
(el): el is HTMLElement => el instanceof HTMLElement && !leaving(el),
)
: [];
};
const after = (ms: number, fn: () => void, owner?: HTMLElement) => {
const g = gen;
const timer = scheduleMotion(() => {
timers.delete(timer);
if (g !== gen || (owner && gone.has(owner))) return;
fn();
wake();
}, ms);
timers.add(timer);
};
const until = (when: () => boolean, fn: () => void, owner?: HTMLElement) => {
const g = gen;
waits.add({ when, run: () => g === gen && fn(), left: T.waitMax, owner });
wake();
};
const run = (a: Anim) => {
anims.add(a);
wake();
return a;
};
/** One value on a shared motion lane: interruptible, clock-steppable, finished by settle. */
function glide(
owner: HTMLElement | undefined,
[from, to]: Pair,
k: Pair,
paint: (x: number) => void,
done?: () => void,
velocity = 0,
) {
const m = createMotionLane(from, (x) => {
paint(x);
wake();
});
let over = false;
const end = (complete: boolean) => {
if (over) return;
over = true;
glides.delete(g);
m.dispose();
if (!complete) return;
paint(to);
done?.();
wake();
};
const g: Glide = {
owner,
finish: () => end(true),
cancel: () => end(false),
};
glides.add(g);
paint(from);
m.to(to, lane(k, velocity), () => end(true));
wake();
return g;
}
/* one height owner per box: interruptible, keeps velocity on retarget, "auto" chases the content */
function animHeight(
el: HTMLElement,
o: {
from?: number;
to?: number | "auto";
k: Pair;
kick?: number;
onFrame?: (p: number) => void;
done?: () => void;
},
) {
let a = heights.get(el);
if (!a) {
const x = o.from ?? el.getBoundingClientRect().height;
a = { s: S(o.k, x), start: x, to: "auto" };
heights.set(el, a);
} else if (o.from !== undefined) a.s.x = o.from;
a.start = a.s.x;
tune(a.s, o.k).v += o.kick ?? 0;
a.to = o.to ?? "auto";
a.onFrame = o.onFrame;
a.done = o.done;
el.setAttribute("data-moving", "");
el.style.height = `${f2(Math.max(0, a.s.x))}px`;
wake();
}
function hold(el: HTMLElement, h: number) {
el.setAttribute("data-moving", "");
el.style.height = `${f2(Math.max(0, h))}px`;
}
function stepHeight(el: HTMLElement, a: Height, dt: number) {
const target = a.to === "auto" ? natural(el) : a.to;
a.s.to = target;
spring(a.s, dt);
// Pixel boxes settle at a tenth of a pixel; the snap is invisible.
if (Math.abs(a.s.to - a.s.x) < 0.1 && Math.abs(a.s.v) < 1) {
a.s.x = a.s.to;
a.s.v = 0;
}
el.style.height = `${f2(Math.max(0, a.s.x))}px`;
// progress from where this episode started, so a box opening from a kept head reads 0 → 1
const span = target - a.start;
a.onFrame?.(Math.abs(span) > 0.5 ? clamp((a.s.x - a.start) / span) : 1);
if (rest(a.s)) finishHeight(el, a);
}
function finishHeight(el: HTMLElement, a: Height) {
heights.delete(el);
a.onFrame?.(1);
// A box closing to a number keeps it: its owner removes or re-renders the box next.
if (a.to === "auto") {
el.style.height = "";
el.removeAttribute("data-moving");
}
a.done?.();
}
const release = (el: HTMLElement) => {
heights.delete(el);
el.style.height = "";
el.removeAttribute("data-moving");
};
/* ---------- regrouping: a kept neighbour's change is absorbed by the box that moves ---------- */
const restOf = (el: HTMLElement) => {
const a = heights.get(el);
return a && a.to !== "auto" ? a.to : natural(el);
};
function record() {
for (const el of boxes()) restHeights.set(el, restOf(el));
}
/**
* What a kept box lost (+) or gained (−) since its last rest paint: a run's head, a card's
* edge or a day's gap passing to the arriving or leaving box beside it. Read once.
*/
function drift(el: HTMLElement | null) {
if (!el) return 0;
const was = restHeights.get(el),
now = restOf(el);
restHeights.set(el, now);
return was === undefined || Math.abs(was - now) < 0.5 ? 0 : was - now;
}
/** New rows with any new group label that arrived above them, in order. */
function withLabels(rows: HTMLElement[]) {
const out: HTMLElement[] = [];
for (const row of rows) {
const label = beside(row, "previousElementSibling");
if (label && !label.matches(ROW) && !known.has(label) && !out.includes(label))
out.push(label);
out.push(row);
}
return out;
}
/** Where each new box starts: zero, plus whatever its kept neighbours just handed over. */
function startsOf(set: HTMLElement[]) {
const from = new Map(set.map((el) => [el, 0]));
if (!set.length) return from;
const first = set[0],
last = set[set.length - 1];
from.set(
first,
Math.max(0, drift(beside(first, "previousElementSibling"))),
);
from.set(
last,
(from.get(last) ?? 0) +
Math.max(0, drift(beside(last, "nextElementSibling"))),
);
return from;
}
/* ---------- thread layer: the spine between marks and the blooming seed ---------- */
function draw() {
if (!layer || !spine || !seeds) return;
if (layout !== "thread") {
segments.forEach((p) => p.setAttribute("display", "none"));
return;
}
const bb = layer.getBoundingClientRect();
const pts: { key: string; x: number; y: number; ms: number }[] = [];
// A leaving row has no place in the thread: its neighbours join directly.
for (const row of host.querySelectorAll<HTMLElement>(
`${ROW}:not([data-leaving])`,
)) {
const m = markOf(row);
if (!m) continue;
const rc = m.getBoundingClientRect();
if (!rc.width) continue;
pts.push({
key: row.dataset.activityEntry ?? "",
x: rc.left - bb.left + rc.width / 2,
y: rc.top - bb.top + rc.height / 2,
ms: clamp(markScale.get(row) ?? 1),
});
}
let n = 0;
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i],
b = pts[i + 1];
// the gap shrinks with a growing marker, so the thread meets the seed
const y0 = a.y + GAP * a.ms,
y1 = b.y - GAP * b.ms,
D = y1 - y0;
if (D < 3) continue;
const key = `${a.key}>${b.key}`,
gr = grow.get(key),
g = gr ? clamp(gr.x) : 1;
if (g <= 0.001) continue;
const p =
segments[n] ??
(segments[n] = spine.appendChild(document.createElementNS(NS, "path")));
n++;
p.setAttribute("class", "v-activity-feed__spine");
// two cubic halves, up to 3 px sideways, the side seeded by the pair
const w = (hash(key) & 1 ? 1 : -1) * Math.min(3, D / 14),
x = a.x,
up = gr?.dir === "up",
s = up ? y1 : y0,
d = up ? -D : D;
p.setAttribute(
"d",
`M${f2(x)} ${f2(s)}C${f2(x)} ${f2(s + 0.26 * d)} ${f2(x + w)} ${f2(s + 0.31 * d)} ${f2(x)} ${f2(s + 0.5 * d)}C${f2(x - w)} ${f2(s + 0.69 * d)} ${f2(x)} ${f2(s + 0.75 * d)} ${f2(x)} ${f2(s + d)}`,
);
if (gr) {
const L = p.getTotalLength();
p.setAttribute("stroke-dasharray", `${f2(L)} ${f2(L + 8)}`);
p.setAttribute("stroke-dashoffset", f2(L * (1 - g)));
} else {
p.removeAttribute("stroke-dasharray");
p.removeAttribute("stroke-dashoffset");
}
p.removeAttribute("display");
}
for (let i = n; i < segments.length; i++)
segments[i].setAttribute("display", "none");
for (const b of blobs) {
const m = markOf(b.row);
if (!m) continue;
const rc = m.getBoundingClientRect(),
x = rc.left - bb.left + rc.width / 2,
y = rc.top - bb.top + rc.height / 2;
b.el.setAttribute(
"transform",
`translate(${f2(x)} ${f2(y)}) scale(${(MARK_SCALE * Math.max(0, b.s.x)).toFixed(3)}) rotate(${f2(b.rot.x)})`,
);
b.el.setAttribute(
"d",
outline(lerpPts(b.A, b.B, clamp(b.m.x, -0.25, 1.25)), true),
);
b.el.setAttribute("data-fill", b.fill);
}
}
function holdThread(key: string, dir: Grow["dir"]) {
const gr: Grow = { x: 0, dir };
grow.set(key, gr);
return gr;
}
function releaseThread(key: string, gr: Grow, k: Pair) {
glide(
undefined,
[gr.x, 1],
k,
(x) => (gr.x = x),
() => {
if (grow.get(key) === gr) grow.delete(key);
},
);
}
const scaleMark = (row: HTMLElement, scale: number) => {
const m = markOf(row);
if (m) m.style.transform = scale === 1 ? "" : `scale(${scale.toFixed(3)})`;
};
const setMark = (row: HTMLElement, scale: number) => {
if (gone.has(row)) return;
markScale.set(row, scale);
scaleMark(row, scale);
};
const showMark = (row: HTMLElement) => {
row.removeAttribute("data-mark");
scaleMark(row, 1);
markScale.delete(row);
};
/** The marker blooms from a seed through the four-point star; brand rows keep the star. */
function bloom(row: HTMLElement, isBrand: boolean) {
if (!seeds) return showMark(row);
const P = SPR.thread;
const b: Blob = {
row,
s: S(P.seed),
m: S(P.morph),
rot: S(P.spin),
A: PEBBLE,
B: PEBBLE,
fill: "pink",
el: seeds.appendChild(document.createElementNS(NS, "path")),
};
b.el.setAttribute("class", "v-activity-feed__seed");
b.s.to = 0.62;
blobs.add(b);
const av = S(P.avatarIn);
const drop = () => {
blobs.delete(b);
b.el.remove();
};
run({
owner: row,
tick: (dt) => {
[b.s, b.m, b.rot, av].forEach((s) => spring(s, dt));
if (av.x || av.v) scaleMark(row, av.x);
setMark(row, Math.max(av.x, b.s.x * 0.9));
return blobs.has(b) || !rest(av);
},
done: () => {
drop();
showMark(row);
},
});
after(
T.bloomMorphAt,
() => {
b.B = STAR;
b.fill = "olive";
b.m.to = 1;
tune(b.s, P.starGrow).to = 1;
b.rot.to = 90;
until(
() => b.m.x > 0.97 && Math.abs(b.s.x - 1) < 0.05,
() => {
if (isBrand) {
until(
() => rest(b.s) && rest(b.rot) && rest(b.m),
() => {
row.removeAttribute("data-mark");
av.x = av.to = 1;
av.v = 0;
drop();
},
row,
);
return;
}
row.removeAttribute("data-mark");
av.x = 0.35;
av.to = 1;
tune(b.s, P.handoff).to = 0;
until(() => b.s.x < 0.02, drop, row);
},
row,
);
},
row,
);
}
function markIn(row: HTMLElement) {
row.removeAttribute("data-mark");
glide(
row,
[0.4, 1],
SPR.markIn,
(x) => setMark(row, x),
() => showMark(row),
);
}
/** The thread grows down from the marker above once it shows, then the marker pops in. */
function threadIn(row: HTMLElement) {
const id = row.dataset.activityEntry ?? "",
prev = ids[ids.indexOf(id) - 1],
key = `${prev}>${id}`,
gr = grow.get(key);
if (!gr) return markIn(row);
const upper = prev !== undefined ? rowOf(prev) : null;
until(
() => !upper || !upper.hasAttribute("data-mark"),
() => {
releaseThread(key, gr, SPR.thread.threadDown);
until(() => gr.x > 0.85, () => markIn(row), row);
},
row,
);
}
function wash(row: HTMLElement) {
const w = row.querySelector<HTMLElement>(".v-activity-feed__wash");
if (!w) return;
w.setAttribute("data-on", "");
glide(
row,
[0, 1],
SPR.ledger.wash,
(x) => {
w.style.clipPath = `inset(0 ${f2((1 - x) * 100)}% 0 0 round 8px)`;
},
() => {
// the fade back runs in CSS within the shared draw duration
w.style.clipPath = "";
w.removeAttribute("data-on");
},
);
}
/* ---------- bursts: every row's disclosure follows its card ---------- */
/** A row arriving in, or regrouped into, a card takes the card's open state. */
function adoptCards() {
if (layout !== "bursts") return;
let card: HTMLElement[] = [];
const settleCard = () => {
const model = card.find((r) => known.has(r));
const fold = model && foldOf(model);
if (fold) {
const open = fold.open && !fold.hasAttribute("data-closing");
for (const r of card) {
const d = foldOf(r);
if (d && !d.hasAttribute("data-closing") && d.open !== open)
d.open = open;
}
}
card = [];
};
for (const el of boxes()) {
if (!el.matches(ROW)) continue;
if (el.hasAttribute("data-first") && card.length) settleCard();
card.push(el);
if (el.hasAttribute("data-last")) settleCard();
}
settleCard();
}
function openCard(row: HTMLElement) {
const rows = groupOf(row);
for (const r of rows) {
const d = foldOf(r);
if (!d) continue;
d.removeAttribute("data-closing");
if (!d.open) d.open = true;
const a = heights.get(r);
if (a && a.to !== "auto") release(r);
}
}
/* ---------- arrival: a live prepend ---------- */
function arrive(rows: HTMLElement[]) {
const set = withLabels(rows);
const from = startsOf(set);
for (const el of set) {
const start = from.get(el) ?? 0;
if (!el.matches(ROW)) {
const k =
layout === "thread"
? SPR.thread.arrive
: layout === "ledger"
? SPR.ledger.arrive
: SPR.bursts.arrive;
animHeight(el, { from: start, k });
continue;
}
if (layout === "ledger") {
animHeight(el, { from: start, k: SPR.ledger.arrive });
wash(el);
continue;
}
if (layout === "bursts") {
const fold = foldOf(el);
if (fold && !fold.open) {
// a closed burst takes the line without opening
animHeight(el, { from: start, k: SPR.bursts.gulp, kick: 160 });
continue;
}
el.setAttribute("data-slide", "");
animHeight(el, {
from: start,
k: SPR.bursts.arrive,
onFrame: (p) => {
if (p > 0.35) el.removeAttribute("data-slide");
},
});
continue;
}
const id = el.dataset.activityEntry ?? "",
next = ids[ids.indexOf(id) + 1],
key = next !== undefined ? `${id}>${next}` : null,
gr = key ? holdThread(key, "up") : null,
isBrand = brand.has(id);
el.setAttribute("data-mark", "hidden");
el.setAttribute("data-arriving", "");
setMark(el, 0);
let shown = false,
threaded = false;
animHeight(el, {
from: start,
k: SPR.thread.arrive,
onFrame: (p) => {
if (!shown && p > 0.55) {
shown = true;
el.removeAttribute("data-arriving");
}
if (!threaded && p > 0.8) {
threaded = true;
if (!gr || !key) return bloom(el, isBrand);
releaseThread(key, gr, SPR.thread.threadUp);
until(() => gr.x > 0.85, () => bloom(el, isBrand), el);
}
},
});
}
}
/* ---------- Show more: at most three items unfurl with a short stagger ---------- */
function unfurl(revealed: HTMLElement[]) {
const set = withLabels(revealed.slice(0, T.unfurlMax));
const from = startsOf(set);
for (const el of set) {
hold(el, from.get(el) ?? 0);
if (!el.matches(ROW)) continue;
if (layout === "bursts") el.setAttribute("data-slide", "");
if (layout !== "thread") continue;
const id = el.dataset.activityEntry ?? "",
prev = ids[ids.indexOf(id) - 1];
el.setAttribute("data-mark", "hidden");
el.setAttribute("data-arriving", "");
setMark(el, 0);
if (prev !== undefined) holdThread(`${prev}>${id}`, "down");
}
const go = (el: HTMLElement) => {
const start = from.get(el) ?? 0;
if (!el.matches(ROW) || layout === "ledger")
return animHeight(el, {
from: start,
k:
layout === "thread"
? SPR.thread.unfurl
: layout === "ledger"
? SPR.ledger.unfurl
: SPR.bursts.unfurl,
});
if (layout === "bursts")
return animHeight(el, {
from: start,
k: SPR.bursts.unfurl,
onFrame: (p) => {
if (p > 0.3) el.removeAttribute("data-slide");
},
});
let shown = false,
threaded = false;
animHeight(el, {
from: start,
k: SPR.thread.unfurl,
onFrame: (p) => {
if (!shown && p > 0.5) {
shown = true;
el.removeAttribute("data-arriving");
}
if (threaded || p <= 0.6) return;
threaded = true;
threadIn(el);
},
});
};
set.forEach((el, i) =>
i ? after(i * T.unfurlStagger, () => go(el), el) : go(el),
);
}
/* ---------- breathing: only the newest live arrival, only while it can be seen ---------- */
function applyBreath() {
host
.querySelectorAll("[data-breath]")
.forEach((el) => el.removeAttribute("data-breath"));
if (breathId === null || !breatheOn || ids[0] !== breathId) return;
rowOf(breathId)?.setAttribute("data-breath", "");
}
function breathe(id: string) {
cancelMotion(breathTimer);
breathId = id;
breathTimer = scheduleMotion(() => {
breathTimer = null;
breathId = null;
applyBreath();
}, T.breathFor);
}
/** A box that left: cancel everything it owned and let it go. */
function forget(el: HTMLElement) {
gone.add(el);
heights.delete(el);
markScale.delete(el);
for (const g of [...glides]) if (g.owner === el) g.cancel();
for (const a of [...anims]) if (a.owner === el) anims.delete(a);
for (const w of [...waits]) if (w.owner === el) waits.delete(w);
for (const b of [...blobs])
if (b.row === el) {
blobs.delete(b);
b.el.remove();
}
}
/** Finish every episode at its rest paint: used for quiet, hidden tabs and layout changes. */
function settle() {
gen++;
timers.forEach(cancelMotion);
timers.clear();
waits.clear();
const sized = [...heights];
heights.clear();
for (const [el, a] of sized) {
if (a.to === "auto") {
el.style.height = "";
el.removeAttribute("data-moving");
}
a.done?.();
}
host
.querySelectorAll<HTMLElement>("[data-moving]:not([data-leaving])")
.forEach((el) => {
el.style.height = "";
el.removeAttribute("data-moving");
});
const running = [...anims];
anims.clear();
running.forEach((a) => a.done?.());
[...glides].forEach((g) => g.finish());
blobs.forEach((b) => b.el.remove());
blobs.clear();
grow.clear();
markScale.clear();
host
.querySelectorAll<HTMLElement>(
"[data-mark],[data-arriving],[data-slide]",
)
.forEach((el) => {
el.removeAttribute("data-mark");
el.removeAttribute("data-arriving");
el.removeAttribute("data-slide");
});
host
.querySelectorAll<HTMLElement>(".v-activity-feed__mark")
.forEach((m) => (m.style.transform = ""));
host
.querySelectorAll<HTMLElement>(".v-activity-feed__wash[data-on]")
.forEach((w) => {
w.style.clipPath = "";
w.removeAttribute("data-on");
});
}
const scene: Scene = {
frame(dt) {
heights.forEach((a, el) => stepHeight(el, a, dt));
anims.forEach((a) => {
if (a.tick(dt)) return;
anims.delete(a);
a.done?.();
});
draw();
waits.forEach((w) => {
w.left -= dt;
if (!w.when() && w.left > 0) return;
waits.delete(w);
w.run();
});
return (
heights.size > 0 || anims.size > 0 || waits.size > 0 || glides.size > 0
);
},
};
return {
sync(next: ActivitySync) {
const relayout = next.layout !== layout;
if (next.layer !== layer) {
layer = next.layer;
segments.length = 0;
spine = seeds = null;
if (layer) {
layer.replaceChildren();
spine = layer.appendChild(document.createElementNS(NS, "g"));
seeds = layer.appendChild(document.createElementNS(NS, "g"));
}
}
layout = next.layout;
brand = next.brand;
allowed = next.allowed;
breatheOn = next.breathe;
const before = new Set(ids);
const hadRows = ids.length > 0;
ids = [...next.ids];
if (!relayout) adoptCards();
if (!started || relayout || !allowed || document.hidden) {
settle();
if (relayout) breathId = null;
} else {
const fresh = ids.filter((id) => !before.has(id));
const firstKept = ids.findIndex((id) => before.has(id));
const top = firstKept < 0 ? ids.length : firstKept;
const arrivals = fresh.filter((id) => ids.indexOf(id) < top);
const shown = new Set(next.revealed);
const reveals = fresh.filter(
(id) => shown.has(id) && ids.indexOf(id) >= top,
);
const rows = (list: string[]) =>
list.map(rowOf).filter((r): r is HTMLElement => !!r);
// a bulk load or a first paint appears at rest; live prepends arrive
if (arrivals.length && arrivals.length <= T.unfurlMax) {
arrive(rows(arrivals));
if (hadRows || arrivals.length === 1) breathe(arrivals[0]);
}
if (reveals.length) unfurl(rows(reveals));
}
started = true;
for (const el of boxes()) known.add(el);
record();
applyBreath();
draw();
wake();
},
measure() {
// At rest, any size change (a rewrap, a toggled burst, a caller's content) is the new rest.
if (!heights.size) record();
draw();
},
/** Close a removed box on a spring, then let presence unmount it. */
leave(el: HTMLElement, done: () => void) {
const finish = () => {
forget(el);
done();
};
if (!allowed || document.hidden || !el.isConnected) return finish();
// the leaving box also takes whatever its neighbours just gained or lost
const from =
el.getBoundingClientRect().height +
drift(beside(el, "previousElementSibling")) +
drift(beside(el, "nextElementSibling"));
if (layout === "thread" && el.matches(ROW))
glide(
el,
[markScale.get(el) ?? 1, 0],
SPR.markOut,
(x) => setMark(el, Math.max(0, x)),
undefined,
2.5,
);
animHeight(el, {
from: Math.max(0, from),
to: 0,
k: SPR.remove,
done: finish,
});
draw();
},
/** A removal reversed while it was still closing. */
stay(el: HTMLElement) {
const a = heights.get(el);
if (!a || a.to !== 0) return;
a.done = undefined;
for (const g of [...glides]) if (g.owner === el) g.cancel();
animHeight(el, { k: SPR.restore });
if (el.matches(ROW)) showMark(el);
},
/** Open or close a burst on a spring; every row's disclosure follows the head. */
toggle(details: HTMLDetailsElement) {
const row = details.closest<HTMLElement>(ROW);
if (!row) return false;
const rows = groupOf(row),
folds = rows.map(foldOf);
const closing = details.open && !details.hasAttribute("data-closing");
if (!allowed || document.hidden) {
rows.forEach((r) => heights.has(r) && release(r));
folds.forEach((d) => {
if (!d) return;
d.removeAttribute("data-closing");
d.open = !closing;
});
return true;
}
const from = rows.map((r) => r.getBoundingClientRect().height);
if (closing) {
folds.forEach((d) => d && (d.open = false));
const to = rows.map(natural);
folds.forEach((d) => {
if (!d) return;
d.open = true;
d.setAttribute("data-closing", "");
});
let left = rows.length;
rows.forEach((r, i) =>
animHeight(r, {
from: from[i],
to: to[i],
k: SPR.bursts.close,
done: () => {
if (--left) return;
folds.forEach((d) => {
if (!d) return;
d.removeAttribute("data-closing");
d.open = false;
});
rows.forEach(release);
},
}),
);
} else {
folds.forEach((d) => {
if (!d) return;
d.removeAttribute("data-closing");
d.open = true;
});
rows.forEach((r, i) =>
animHeight(r, { from: from[i], k: SPR.bursts.open }),
);
}
return true;
},
/** A disclosure opened natively (find in page, a fragment link): its whole card opens. */
reveal(details: HTMLDetailsElement) {
const row = details.closest<HTMLElement>(ROW);
if (!row || !details.open || details.hasAttribute("data-closing")) return;
if (groupOf(row).some((r) => foldOf(r)?.open === false)) openCard(row);
},
openCard,
attach() {
scenes.add(scene);
},
destroy() {
settle();
cancelMotion(breathTimer);
scenes.delete(scene);
layer?.replaceChildren();
},
};
}
export type ActivityScene = ReturnType<typeof createScene>;
/** Each feed's scene, shared with its rows' presence and its bursts' toggles. */
export const ActivitySceneContext = React.createContext<
React.RefObject<ActivityScene | null>
>({ current: null });
/**
* Drives one feed's decorative motion. React renders the rest paint; the scene only
* borrows heights, marks and the thread layer while an episode plays.
*/
export function useActivityScene(
host: React.RefObject<HTMLElement | null>,
next: Omit<ActivitySync, "layer" | "revealed"> & {
layer: React.RefObject<SVGSVGElement | null>;
takeRevealed: () => readonly string[];
},
) {
const scene = React.useRef<ActivityScene | null>(null);
const sync = React.useEffectEvent(() =>
scene.current?.sync({
...next,
layer: next.layer.current,
revealed: next.takeRevealed(),
}),
);
React.useLayoutEffect(() => {
if (!host.current) return;
const s = createScene(host.current);
s.attach();
scene.current = s;
const observer = new ResizeObserver(() => s.measure());
observer.observe(host.current);
void document.fonts?.ready.then(() => scene.current === s && s.measure());
return () => {
observer.disconnect();
s.destroy();
scene.current = null;
};
}, [host]);
const key = next.ids.join("\u0001");
// Declared after the scene effect, so on mount the scene exists before its first sync.
React.useLayoutEffect(() => {
sync();
}, [key, next.layout, next.allowed, next.breathe]);
return scene;
}
/** Presence for one feed box: a removal closes on the scene's spring before unmounting. */
export function useActivityPresence<T extends HTMLElement>() {
const [isPresent, safeToRemove] = usePresence();
const scene = React.useContext(ActivitySceneContext);
const ref = React.useRef<T>(null);
// AnimatePresence registers an exit in its own layout effect, which runs after this
// child's; an instant completion waits a microtask so the parent can hear it.
const finish = React.useEffectEvent(() => {
const remove = safeToRemove;
queueMicrotask(() => remove?.());
});
React.useLayoutEffect(() => {
const el = ref.current;
if (isPresent) {
if (el) scene.current?.stay(el);
return;
}
if (!el || !scene.current) return finish();
scene.current.leave(el, () => finish());
}, [isPresent, scene]);
return [ref, !isPresent] as const;
}
registry/cojeev/lib/milestone-travel.tsx
"use client";
import * as React from "react";
import { path as outline, spring, type Spring } from "@/lib/cojeev-motion/geometry";
import {
cancelMotion,
registerMotionClock,
scheduleMotion,
type MotionTimer,
} from "@/lib/cojeev-motion/clock";
export type MilestoneTravel = "seed" | "droplet" | "division";
type State = "complete" | "current" | "upcoming" | "needs";
type Fill = "pink" | "olive" | "danger";
type Glyph = "none" | "core" | "check" | "bang";
type Pts = number[][];
const f2 = (n: number) => n.toFixed(2),
f3 = (n: number) => n.toFixed(3);
/* ---------- marks: 64 polar samples from the top, so any two morph point-to-point ---------- */
const R = 17;
const polar = (f: (t: number) => number): Pts =>
Array.from({ length: 64 }, (_, i) => {
const t = -Math.PI / 2 + (i / 64) * Math.PI * 2,
r = f(t);
return [r * Math.cos(t), r * Math.sin(t)];
});
/** The same 64 angles cast at a closed outline that is star-shaped about the centre. */
const cast = (ring: Pts): Pts =>
polar((t) => {
const ux = Math.cos(t),
uy = Math.sin(t);
let r = 0;
ring.forEach(([ax, ay], i) => {
const [bx, by] = ring[(i + 1) % ring.length],
ex = bx - ax,
ey = by - ay,
det = ex * uy - ux * ey;
if (Math.abs(det) < 1e-9) return;
const d = (ex * ay - ax * ey) / det,
s = (ux * ay - uy * ax) / det;
if (d > 0 && s >= -1e-9 && s <= 1 + 1e-9) r = Math.max(r, d);
});
return r;
});
/** The outline where overlapping soft circles [x, y, r] merge, like the goo between bodies. */
const merge = (balls: number[][]): Pts =>
polar((t) => {
const ux = Math.cos(t),
uy = Math.sin(t);
for (let r = 30; r > 0; r -= 0.02) {
let f = 0;
for (const [x, y, s] of balls)
f += (s * s) / ((r * ux - x) ** 2 + (r * uy - y) ** 2);
if (f >= 1) return r;
}
return R;
});
const SHAPES = {
// spinner.tsx pebble, scaled from its 41-unit body to 17 px
pebble: polar((t) => R + Math.cos(3 * t + 0.6) + 0.6 * Math.sin(5 * t)),
// division's done cell: round, a little uneven
done: polar(
(t) => R + 0.6 + 0.7 * Math.cos(2 * t + 1.1) + 0.45 * Math.sin(3 * t + 0.3),
),
// spinner.tsx STAR4, scaled the same way
star: polar((t) => ((21 + 23 * Math.abs(Math.cos(2 * t)) ** 1.9) * R) / 41),
needs: polar((t) => R - 0.6 + 3.2 * Math.cos(3 * t + 1.5 * Math.PI)),
// droplet: a settled drop, point up, its weight in the belly
drop: cast(
Array.from({ length: 480 }, (_, i) => {
const s = (i / 480) * Math.PI * 2;
return [19.5 * Math.sin(s) * Math.sin(s / 2), -17.6 * Math.cos(s)];
}),
),
// droplet: the working drop hangs from the spine on a thin neck
hang: merge([
[0, 4.5, 11.8],
[0, -9, 4.2],
[0, -15.5, 1.8],
]),
// division: the working cell, already pinched at the waist
split: merge([
[0, -8.5, 9.2],
[0, 8.5, 9.2],
]),
};
type Shape = keyof typeof SHAPES;
/** Rest paint. The travel layer settles on exactly these outlines before it hands back. */
export const MARK_PATHS = Object.fromEntries(
Object.entries(SHAPES).map(([name, pts]) => [name, outline(pts, true)]),
) as Record<Shape, string>;
/** The same 64 samples as points (viewBox -18..18), for layers that morph between marks. */
export const MARK_POINTS: Readonly<Record<Shape, readonly (readonly number[])[]>> =
SHAPES;
export const CHECK_PATH = "M-6.4 .2L-2 4.6L6.6-4.6";
export const BANG_PATH = "M0-6.6V1";
/** Each character's working body and done mark. */
export const MARK_LOOKS: Readonly<
Record<MilestoneTravel, { work: Shape; done: Shape }>
> = {
seed: { work: "pebble", done: "star" },
droplet: { work: "hang", done: "drop" },
division: { work: "split", done: "done" },
};
/** Where the check or nucleus sits: a drop carries it low, in its belly. */
export const MARK_GLYPH_Y: Readonly<Record<Shape, number>> = {
pebble: 0,
done: 0,
star: 0,
needs: 0,
drop: 4,
hang: 5,
split: 0,
};
const glyphY = (pts: Pts) =>
MARK_GLYPH_Y[
(Object.keys(SHAPES) as Shape[]).find((k) => SHAPES[k] === pts) ?? "pebble"
];
export type MarkDeco = { kind: "membrane" | "depth"; d: string };
/**
* The inner line that gives a character its material: a cell's membrane follows its outline;
* a drop's depth is a crescent low in the belly. Seed and the "needs" mark carry none.
*/
export function markDeco(
travel: MilestoneTravel,
pts: readonly (readonly number[])[],
cy: number,
): MarkDeco | null {
if (travel === "division")
return {
kind: "membrane",
d: outline(
pts.map(([x, y]) => [x * 0.74, y * 0.74]),
true,
),
};
if (travel === "droplet")
return {
kind: "depth",
d:
"M" +
pts
.slice(18, 29)
.map(([x, y]) => `${f2(x * 0.78)} ${f2(cy + (y - cy) * 0.78)}`)
.join("L"),
};
return null;
}
/* ---------- durations ---------- */
type Timing = { kind: string; startedAt?: number; elapsedMs?: number };
const valid = (n: unknown): n is number =>
typeof n === "number" && Number.isFinite(n) && n >= 0;
/**
* Milliseconds to show: undefined before a running clock's first client sample,
* null for timing that does not fit the state (never a guessed value).
*/
export function elapsedOf(
timing: Timing,
state: State,
now: number | null,
): number | null | undefined {
if (state === "upcoming") return null;
if (timing.kind !== (state === "current" ? "running" : "frozen")) return null;
if (timing.kind === "frozen")
return valid(timing.elapsedMs) ? timing.elapsedMs : null;
if (
!valid(timing.startedAt) ||
(timing.elapsedMs !== undefined && !valid(timing.elapsedMs))
)
return null;
if (now === null) return undefined;
return (timing.elapsedMs ?? 0) + Math.max(0, now - timing.startedAt);
}
/** Whole seconds as m:ss, then h:mm:ss; each entry is one digit run. */
export function elapsedParts(ms: number) {
const s = Math.floor(ms / 1000),
pad = (n: number) => String(n).padStart(2, "0"),
h = Math.floor(s / 3600),
m = Math.floor((s % 3600) / 60);
return h ? [String(h), pad(m), pad(s % 60)] : [String(m), pad(s % 60)];
}
/* ---------- what a state change means for the body ---------- */
export type TravelPlan =
| { kind: "none" }
| { kind: "anchor" }
| { kind: "handoff"; from: number; to: number }
| { kind: "finish"; at: number }
| { kind: "recoil"; at: number }
| { kind: "retry"; at: number };
const isActive = (s: State) => s === "current" || s === "needs";
/**
* Stable ids decide travel: the body parked at `at` hands off to a later sibling,
* becomes its own done mark, recoils or recovers. Anything else only re-anchors.
*/
export function planTravel(
prevIds: readonly string[],
ids: readonly string[],
states: readonly State[],
at: number,
needs: boolean,
): TravelPlan {
if (prevIds.length !== ids.length || ids.some((id, i) => id !== prevIds[i]))
return { kind: "anchor" };
const next = states.findIndex(isActive);
if (at < 0 || at >= states.length)
return next < 0 ? { kind: "none" } : { kind: "anchor" };
const here = states[at];
if (here === "complete") {
if (next < 0)
return states.every((s) => s === "complete")
? { kind: "finish", at }
: { kind: "anchor" };
if (next > at && states.slice(at, next).every((s) => s === "complete"))
return { kind: "handoff", from: at, to: next };
return { kind: "anchor" };
}
if (next !== at) return { kind: "anchor" };
if (here === "needs") return needs ? { kind: "none" } : { kind: "recoil", at };
if (here === "current") return needs ? { kind: "retry", at } : { kind: "none" };
return { kind: "anchor" };
}
/* ---------- one rAF loop for every path, steppable by the shared motion clock ---------- */
type Scene = { frame: (dt: number) => boolean };
const scenes = new Set<Scene>();
let clock: number | null = null,
previous: number | null = null,
raf = 0;
function stopLoop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
function tick(t: number) {
// Real elapsed time: the shared spring caps one step at 0.1 s, so slow machines keep pace with the clock.
const dt = previous === null ? 0 : Math.max(0, (t - previous) / 1000);
previous = t;
let active = false;
scenes.forEach((s) => {
active = s.frame(dt) || active;
});
return active;
}
function wake() {
if (clock !== null || raf || !scenes.size || document.hidden) return;
if (previous === null) previous = performance.now();
raf = requestAnimationFrame((t) => {
raf = 0;
if (tick(t)) wake();
else previous = null;
});
}
if (typeof window !== "undefined")
registerMotionClock((t) => {
stopLoop();
if (t === null) {
clock = null;
previous = null;
wake();
} else {
if (clock === null) previous = t;
clock = t;
tick(t);
}
});
/* ---------- the scene: a private decorative layer that owns paint only during an episode ---------- */
const NS = "http://www.w3.org/2000/svg";
const EXPRESSIVE: [number, number] = [260, 0.682]; // motionTokens.spring.expressive as k and damping ratio
const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const lerpPts = (A: Pts, B: Pts, m: number) =>
A.map((p, i) => [p[0] + (B[i][0] - p[0]) * m, p[1] + (B[i][1] - p[1]) * m]);
type Blob = {
g: SVGGElement;
shape: SVGPathElement;
gl: SVGGElement;
core: SVGCircleElement;
check: SVGPathElement;
dg: SVGGElement;
deco: SVGPathElement;
show: boolean;
at: number;
seg: number;
l0: number;
l1: number;
st: number;
p: Spring;
oy: Spring;
s: Spring;
q: Spring;
rot: Spring;
m: Spring;
co: Spring;
A: Pts;
B: Pts;
/** Glyph height on A and on B; it rides the morph with the outline. */
ga: number;
gb: number;
fill: Fill;
glyph: Glyph;
later?: { fill: Fill; glyph: Glyph };
};
type Seg = {
track: SVGPathElement;
ink: SVGPathElement;
pts: number[][];
cum: number[];
L: number;
};
type Sync = {
ids: readonly string[];
states: readonly State[];
travel: MilestoneTravel;
axis: "x" | "y";
allowed: boolean;
/** The top-level list previews a newly chosen character by replaying its last handoff. */
lead?: boolean;
};
function el<K extends keyof SVGElementTagNameMap>(
tag: K,
parent: Element,
attrs: Record<string, string | number> = {},
) {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, String(attrs[k]));
parent.appendChild(e);
return e;
}
const memo = new WeakMap<Element, Record<string, string | null>>();
function attr(e: Element, k: string, v: string | null) {
let c = memo.get(e);
if (!c) memo.set(e, (c = {}));
if (c[k] === v) return;
c[k] = v;
if (v === null) e.removeAttribute(k);
else e.setAttribute(k, v);
}
const OWNED = "data-travel-owned";
const BLOB_SPRINGS = ["p", "oy", "s", "q", "rot", "m", "co"] as const;
/** A phase wait: the shared motion clock bounds it; `when` lets it end early once the springs settle. */
type Wait = { g: number; timer: MotionTimer; when?: () => boolean; r: () => void };
function createScene(host: HTMLElement, svg: SVGSVGElement, gooId: string) {
const S = (k: number, z: number, x = 0): Spring => ({ x, v: 0, to: x, k, z });
const tune = (s: Spring, k: number, z: number) => {
s.k = k;
s.z = z;
return s;
};
const set = (s: Spring, x: number) => {
s.x = s.to = x;
s.v = 0;
};
const rest = (s: Spring) => s.x === s.to && s.v === 0;
let ids: readonly string[] = [],
states: readonly State[] = [],
travel: MilestoneTravel = "seed",
axis: "x" | "y" = "y";
let C: number[][] = [],
segs: Seg[] = [],
rings: { el: SVGCircleElement; show: boolean }[] = [],
ink: number[] = [],
marks: Blob[] = [];
let waits: Wait[] = [],
hook: (() => void) | null = null,
busy = false,
gen = 0,
painting = false,
at = -1, // where the body is parked, or heading
mood = false, // true while the body shows "needs one action"
ep: { to: number; landed: boolean; passed: Set<number> } | null = null;
// Index -> the exact element hidden, so a reorder mid-travel still unhides the right node.
const owned = new Map<number, HTMLElement | SVGElement>(),
ownedSegs = new Map<number, Element>();
// layers, back to front: spine, rings, halo, gooey bodies, material lines, upright glyphs
const defs = el("defs", svg);
const filter = el("filter", defs, {
id: gooId,
filterUnits: "userSpaceOnUse",
"color-interpolation-filters": "sRGB",
});
el("feGaussianBlur", filter, { in: "SourceGraphic", stdDeviation: 3.2 });
el("feColorMatrix", filter, {
values: "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8.5",
});
const gSpine = el("g", svg),
gRings = el("g", svg),
haloEl = el("circle", svg, { r: 22.5, class: "v-milestone-path__halo" }),
gBlobs = el("g", svg),
gNeck = el("g", gBlobs),
gBody = el("g", gBlobs),
gMarks = el("g", gBlobs),
gDeco = el("g", svg),
gGlyphs = el("g", svg);
const neckEls = Array.from({ length: 9 }, () =>
el("circle", gNeck, { class: "v-milestone-path__shape" }),
);
const neck = { n: 0, seg: 0, d: 0 };
const halo = { s: S(220, 0.5, 1), danger: false, x: 0, y: 0 };
const sep = S(90, 0.8);
function makeBlob(layer: SVGGElement): Blob {
const g = el("g", layer),
shape = el("path", g, { class: "v-milestone-path__shape" }),
dg = el("g", gDeco),
deco = el("path", dg, { class: "v-milestone-path__deco" }),
gl = el("g", gGlyphs, { class: "v-milestone-path__glyph" });
const core = el("circle", gl, { class: "v-milestone-path__core", r: 3.2 });
const check = el("path", gl, {
class: "v-milestone-path__check",
d: CHECK_PATH,
pathLength: 1,
});
const bang = el("g", gl, { class: "v-milestone-path__bang" });
el("path", bang, { d: BANG_PATH });
el("circle", bang, { cy: 5.6, r: 1.6 });
return {
g, shape, gl, core, check, dg, deco, show: false, at: 0, seg: -1, l0: 0, l1: 1, st: 0,
p: S(...EXPRESSIVE), oy: S(300, 0.5), s: S(260, 0.6, 1), q: S(300, 0.35),
rot: S(300, 0.4), m: S(240, 0.6, 1), co: S(220, 0.6),
A: SHAPES.pebble, B: SHAPES.pebble, ga: 0, gb: 0, fill: "pink", glyph: "core",
};
}
const body = makeBlob(gBody);
body.g.setAttribute("data-traveler", "");
const springs = () => [
halo.s,
sep,
...[body, ...marks].flatMap((b) => BLOB_SPRINGS.map((k) => b[k])),
];
const scene: Scene = { frame };
/* geometry: marker centres, and the Milestone path connection scaled between them */
const rows = () =>
Array.from(host.querySelectorAll<HTMLElement>(":scope > ol > li"));
const part = (i: number, name: "marker" | "connection") =>
rows()[i]?.querySelector<HTMLElement | SVGElement>(
`:scope > .v-milestone-path__${name}`,
) ?? null;
// Along the spine; the S-curve bows to the right of a column and below a row.
const along = () => (axis === "y" ? [0, 1] : [1, 0]);
function sampleSeg(sg: Seg, P0: number[], P1: number[]) {
const [nx, ny] = axis === "y" ? [1, 0] : [0, 1];
const P = (t: number, off: number) => [
P0[0] + (P1[0] - P0[0]) * t + nx * off,
P0[1] + (P1[1] - P0[1]) * t + ny * off,
];
// M18 0 C18 26 21 31 18 50 C15 69 18 75 18 100, scaled between the two marker centres
const c = [P(0, 0), P(0.26, 0), P(0.31, 3), P(0.5, 0), P(0.69, -3), P(0.75, 0), P(1, 0)];
sg.pts = [];
sg.cum = [];
sg.L = 0;
for (const k of [0, 3])
for (let i = k ? 1 : 0; i <= 24; i++) {
const t = i / 24,
u = 1 - t;
const p = [0, 1].map(
(j) =>
u * u * u * c[k][j] +
3 * u * u * t * c[k + 1][j] +
3 * u * t * t * c[k + 2][j] +
t * t * t * c[k + 3][j],
);
const last = sg.pts[sg.pts.length - 1];
if (last) sg.L += Math.hypot(p[0] - last[0], p[1] - last[1]);
sg.pts.push(p);
sg.cum.push(sg.L);
}
const xy = (p: number[]) => `${f2(p[0])} ${f2(p[1])}`;
const d = `M${xy(c[0])}C${xy(c[1])} ${xy(c[2])} ${xy(c[3])}C${xy(c[4])} ${xy(c[5])} ${xy(c[6])}`;
sg.track.setAttribute("d", d);
sg.ink.setAttribute("d", d);
sg.ink.setAttribute("stroke-dasharray", `${f2(sg.L)} ${f2(sg.L + 4)}`);
memo.delete(sg.ink);
}
function build(n: number) {
for (const g of [gSpine, gRings, gMarks]) g.replaceChildren();
for (const mk of marks) {
mk.gl.remove();
mk.dg.remove();
}
segs = Array.from({ length: Math.max(0, n - 1) }, () => ({
track: el("path", gSpine, { class: "v-milestone-path__track" }),
ink: el("path", gSpine, { class: "v-milestone-path__progress" }),
pts: [],
cum: [],
L: 1,
}));
rings = Array.from({ length: n }, () => ({
el: el("circle", gRings, { r: 5.5, class: "v-milestone-path__ring" }),
show: false,
}));
ink = segs.map(() => 0);
marks = Array.from({ length: n }, () => makeBlob(gMarks));
}
function measure() {
const box = host.getBoundingClientRect();
const items = rows();
if (items.length !== marks.length) build(items.length);
C = items.map((li) => {
const r = (
li.querySelector(":scope > .v-milestone-path__marker") ?? li
).getBoundingClientRect();
return [r.left - box.left + r.width / 2, r.top - box.top + r.height / 2];
});
segs.forEach((sg, i) => sampleSeg(sg, C[i], C[i + 1]));
rings.forEach((r, i) => {
r.el.setAttribute("cx", f2(C[i][0]));
r.el.setAttribute("cy", f2(C[i][1]));
});
filter.setAttribute("x", "-60");
filter.setAttribute("y", "-60");
filter.setAttribute("width", String(Math.ceil(box.width + 120)));
filter.setAttribute("height", String(Math.ceil(box.height + 120)));
}
// A point `len` px along the spine from marker `seg`, continuing straight past either end.
function pointAt(seg: number, len: number) {
const [ax, ay] = along();
let i = seg;
while (i < segs.length - 1 && len > segs[i].L) len -= segs[i++].L;
const sg = segs[i];
if (!sg) return [C[seg][0] + ax * len, C[seg][1] + ay * len];
if (len <= 0) return [C[i][0] + ax * len, C[i][1] + ay * len];
if (len >= sg.L)
return [C[i + 1][0] + ax * (len - sg.L), C[i + 1][1] + ay * (len - sg.L)];
let k = 1;
while (sg.cum[k] < len) k++;
const t = (len - sg.cum[k - 1]) / (sg.cum[k] - sg.cum[k - 1] || 1);
const [a, b] = [sg.pts[k - 1], sg.pts[k]];
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
const spanTo = (from: number, to: number) =>
segs.slice(from, to).reduce((sum, sg) => sum + sg.L, 0);
const lenOf = (b: Blob) => b.l0 + (b.l1 - b.l0) * b.p.x;
function pos(b: Blob) {
const [ax, ay] = along();
const [x, y] = b.seg < 0 ? (C[b.at] ?? [0, 0]) : pointAt(b.seg, lenOf(b));
return [x + ax * b.oy.x, y + ay * b.oy.x];
}
const currentPts = (b: Blob) => lerpPts(b.A, b.B, clamp(b.m.x, -0.25, 1.25));
const gyOf = (b: Blob) => b.ga + (b.gb - b.ga) * clamp(b.m.x, -0.25, 1.25);
function shapeAs(b: Blob, pts: Pts) {
b.A = b.B = pts;
b.ga = b.gb = glyphY(pts);
set(b.m, 1);
}
/* paint */
function drawBlob(b: Blob) {
attr(b.g, "display", b.show ? null : "none");
attr(b.gl, "display", b.show ? null : "none");
attr(b.dg, "display", b.show ? null : "none");
if (!b.show) return;
const [x, y] = pos(b),
s = Math.max(0, b.s.x),
wide = (s * (1 + b.q.x)) / (1 + b.st * 0.75),
tall = s * (1 - b.q.x) * (1 + b.st);
const [sx, sy] = axis === "y" ? [wide, tall] : [tall, wide];
const pose = `translate(${f2(x)} ${f2(y)}) scale(${f3(sx)} ${f3(sy)}) rotate(${f2(b.rot.x)})`;
const pts = currentPts(b),
gy = gyOf(b);
attr(b.g, "transform", pose);
attr(b.shape, "d", outline(pts, true));
attr(b.shape, "data-fill", b.fill);
// The material line keeps the body's pose; it shows on a settled working body or done mark.
const deco = b.fill === "danger" ? null : markDeco(travel, pts, gy);
attr(b.dg, "transform", pose);
attr(b.deco, "d", deco?.d ?? null);
attr(b.deco, "data-deco", deco?.kind ?? null);
attr(b.deco, "data-fill", b.fill);
attr(
b.deco,
"data-on",
deco &&
(b.glyph === "core" || b.glyph === "check") &&
(b !== body || b.seg < 0 || !!ep?.landed)
? ""
: null,
);
attr(b.gl, "transform", `translate(${f2(x)} ${f2(y)}) scale(${f3(s)})`);
const a = (b.rot.x * Math.PI) / 180; // only the nucleus turns with the body; check and "!" stay upright
attr(b.core, "cx", f2(Math.sin(a) * (b.co.x - gy)));
attr(b.core, "cy", f2(-Math.cos(a) * (b.co.x - gy)));
attr(b.check, "transform", gy ? `translate(0 ${f2(gy)})` : null);
attr(b.gl, "data-glyph", b.glyph);
}
function draw() {
segs.forEach((sg, i) => {
const mine = ownedSegs.has(i);
attr(sg.track, "display", mine ? null : "none");
attr(sg.ink, "display", mine && ink[i] > 0.001 ? null : "none");
attr(sg.ink, "stroke-dashoffset", f2(sg.L * (1 - ink[i])));
});
rings.forEach((r) => attr(r.el, "display", r.show ? null : "none"));
neckEls.forEach((c, k) => {
if (neck.n <= 0) return attr(c, "display", "none");
const t = (k + 1) / (neckEls.length + 1),
[x, y] = pointAt(neck.seg, neck.d * t);
const r = R * 0.5 * neck.n ** 0.75 * (0.5 + 0.5 * (2 * t - 1) ** 2);
attr(c, "display", null);
attr(c, "cx", f2(x));
attr(c, "cy", f2(y));
attr(c, "r", f2(r));
attr(c, "data-fill", body.fill);
});
if (body.show) [halo.x, halo.y] = pos(body);
const hs = halo.s.x;
attr(
haloEl,
"transform",
`translate(${f2(halo.x)} ${f2(halo.y)}) scale(${f3(Math.max(0, hs))})`,
);
attr(haloEl, "opacity", painting ? f3(clamp((hs - 0.6) / 0.4)) : "0");
attr(haloEl, "data-tone", halo.danger ? "danger" : null);
drawBlob(body);
marks.forEach(drawBlob);
}
/* the clock */
function frame(dt: number) {
if (!painting) return false;
for (const s of springs()) if (!rest(s)) spring(s, dt);
// Turns are in degrees: the shared 0.0008 snap would hold an invisible creep for about a second.
for (const b of [body, ...marks])
if (Math.abs(b.rot.to - b.rot.x) < 0.1 && Math.abs(b.rot.v) < 1) set(b.rot, b.rot.to); hook?.();
draw();
for (const w of waits.slice()) if (w.g !== gen || w.when?.()) done(w);
const active =
waits.length > 0 || !!hook || springs().some((s) => !rest(s));
if (!active && !busy) release(true);
return active;
}
function done(w: Wait) {
const i = waits.indexOf(w);
if (i < 0) return;
waits.splice(i, 1);
cancelMotion(w.timer);
w.r();
}
const wait = (ms: number, when?: () => boolean) =>
new Promise<void>((r) => {
const w: Wait = { g: gen, when, r, timer: scheduleMotion(() => done(w), ms) };
waits.push(w);
wake();
});
const sleep = (ms: number) => wait(ms);
const until = (when: () => boolean, maxMs = 3000) => wait(maxMs, when);
/* ownership: static markers hide only while this layer paints them */
function own(from: number, to: number) {
for (let i = from; i <= to; i++) {
const marker = part(i, "marker");
if (marker) {
owned.set(i, marker);
marker.setAttribute(OWNED, "");
}
const connection = i < to ? part(i, "connection") : null;
if (connection) {
ownedSegs.set(i, connection);
connection.setAttribute(OWNED, "");
}
}
}
const turnOf = (i: number) =>
parseFloat(part(i, "marker")?.style.getPropertyValue("--mp-turn") ?? "") ||
0;
function setTurn(i: number, deg: number) {
const m = part(i, "marker");
if (m) turn(m, deg);
}
function turn(m: HTMLElement | SVGElement, deg: number) {
const d = ((deg % 360) + 360) % 360;
if (d < 0.05 || d > 359.95) m.style.removeProperty("--mp-turn");
else m.style.setProperty("--mp-turn", `${d.toFixed(2)}deg`);
}
/**
* Hand every owned marker back to static paint. At rest the layer shows the same outline,
* so `keep` carries its turn over; a cancelled beat drops to the upright rest paint.
*/
function release(keep: boolean) {
for (const [i, marker] of owned) {
const b =
body.show && body.seg < 0 && body.at === i
? body
: marks[i]?.show
? marks[i]
: null;
turn(marker, keep && b ? b.rot.x : 0);
marker.removeAttribute(OWNED);
}
for (const connection of ownedSegs.values()) connection.removeAttribute(OWNED);
owned.clear();
ownedSegs.clear();
painting = false;
hook = null;
const pending = waits;
waits = [];
pending.forEach((w) => {
cancelMotion(w.timer);
w.r();
});
busy = false;
neck.n = 0;
ep = null;
gBlobs.removeAttribute("filter");
body.show = false;
marks.forEach((mk) => (mk.show = false));
rings.forEach((r) => (r.show = false));
draw();
}
/* blobs start each beat as the rest paint of the item they stand on */
function resetBlob(b: Blob) {
b.seg = -1;
b.st = 0;
b.later = undefined;
tune(b.p, ...EXPRESSIVE);
set(b.p, 0);
tune(b.oy, 300, 0.5);
set(b.oy, 0);
tune(b.s, 260, 0.6);
set(b.s, 1);
tune(b.q, 300, 0.35);
set(b.q, 0);
tune(b.m, 240, 0.6);
set(b.m, 1);
tune(b.rot, 300, 0.4);
set(b.co, 0);
}
const doneShape = () => SHAPES[MARK_LOOKS[travel].done];
const workShape = () => SHAPES[MARK_LOOKS[travel].work];
const doneGlyph = (): Glyph => (travel === "seed" ? "none" : "check");
/** Take over the static paint of items from..to (inclusive) and start a new beat. */
function begin(from: number, to: number) {
if (!painting) {
painting = true;
ink = segs.map(() => 0);
neck.n = 0;
set(sep, 0);
resetBlob(body);
set(body.rot, turnOf(at));
body.show = true;
body.at = at;
shapeAs(body, mood ? SHAPES.needs : workShape());
body.fill = mood ? "danger" : "pink";
body.glyph = mood ? "bang" : "core";
tune(halo.s, 220, 0.5);
set(halo.s, 1);
halo.danger = mood;
}
for (let i = from; i <= to; i++)
if (!owned.has(i) && i !== at) rings[i].show = true;
own(from, to);
busy = true;
return ++gen;
}
/* ---------- choreography shared by every character (ported from the step-travel prototype) ---------- */
function handOff(mk: Blob, b: Blob) {
// the step's mark takes over the body's exact look, so nothing pops
mk.show = true;
mk.seg = -1;
mk.at = b.at;
for (const k of ["oy", "s", "q", "rot"] as const)
Object.assign(mk[k], { ...b[k] });
mk.A = mk.B = currentPts(b);
mk.ga = mk.gb = gyOf(b);
set(mk.m, 1);
mk.fill = b.fill;
mk.glyph = b.glyph;
mk.st = b.st;
mk.later = undefined;
}
function morphTo(b: Blob, shape: Pts, k: number, z: number) {
b.ga = gyOf(b);
b.gb = glyphY(shape);
b.A = currentPts(b);
b.B = shape;
set(b.m, 0);
tune(b.m, k, z).to = 1;
}
function harden(mk: Blob) {
morphTo(mk, doneShape(), 240, 0.6);
mk.fill = "olive";
tune(mk.s, 260, 0.45).to = 1;
mk.glyph = "check";
}
const haloOut = () => {
tune(halo.s, 420, 1).to = 0.6;
};
function haloBloom() {
halo.danger = body.fill === "danger";
set(halo.s, 0.6);
tune(halo.s, 200, 0.5).to = 1;
}
function startTrack(b: Blob, seg: number, l0: number) {
b.seg = seg;
b.l0 = l0;
b.l1 = spanTo(seg, ep!.to);
tune(b.p, ...EXPRESSIVE);
set(b.p, 0);
}
// 1. Anticipation: gather and squash, about 120 ms.
async function anticipate(b: Blob) {
tune(b.q, 900, 1).to = 0.13;
tune(b.oy, 900, 1).to = -3;
tune(b.s, 900, 1).to = 0.95;
await sleep(120);
tune(b.q, 300, 0.35).to = 0;
tune(b.oy, 260, 0.55).to = 0;
tune(b.s, 300, 0.5).to = 1;
}
// A step the body passes on a retargeted run blooms its done mark as the body goes by.
function leaveMark(i: number) {
const mk = marks[i];
resetBlob(mk);
mk.show = true;
mk.at = i;
shapeAs(mk, doneShape());
mk.fill = "olive";
mk.glyph = doneGlyph();
set(mk.rot, 0);
set(mk.s, 0.55);
tune(mk.s, 260, 0.45).to = 1;
mk.q.v -= 1.2;
}
// 2-3. Travel on the expressive spring; arrival squash settles in ~625 ms (k 300, z .35).
function travelHook(b: Blob, extra?: (len: number) => void) {
return () => {
const e = ep!,
len = lenOf(b),
span = b.l1 - b.l0;
let start = 0;
for (let i = b.seg; i < e.to; i++) {
ink[i] = clamp((len - start) / segs[i].L);
start += segs[i].L;
if (len > start - 9) {
rings[i + 1].show = false;
if (i + 1 < e.to && !e.passed.has(i + 1)) {
e.passed.add(i + 1);
leaveMark(i + 1);
}
}
}
if (!e.landed) b.st = Math.min(0.3, (Math.abs(b.p.v) * span) / 1900);
else b.st *= 0.8;
if (!e.landed && b.p.x >= 1) {
e.landed = true;
b.q.v += Math.min(4.2, Math.abs(b.p.v) * span * 0.0055);
haloBloom();
gBlobs.removeAttribute("filter"); // the goo joins bodies only while they travel
}
extra?.(len);
};
}
// Park the body on its destination; any residual overshoot moves into oy, so nothing jumps.
function park(b: Blob) {
if (b.seg < 0 || !ep) return;
b.oy.x += lenOf(b) - b.l1;
b.oy.v += b.p.v * (b.l1 - b.l0);
b.seg = -1;
b.at = ep.to;
b.st = 0;
set(b.p, 0);
hook = null;
neck.n = 0;
set(sep, 0);
gBlobs.removeAttribute("filter");
}
function settled(g: number) {
if (g !== gen) return;
busy = false;
reconcile();
wake();
}
async function finishInPlace(mk: Blob, b: Blob, g: number) {
// the last step has nowhere to go: the body becomes its own mark
b.show = false;
haloOut();
if (travel === "seed") {
morphTo(mk, SHAPES.star, 220, 0.55);
mk.fill = "olive";
mk.glyph = "none";
tune(mk.rot, 170, 0.5).to = Math.ceil((mk.rot.x + 45) / 90) * 90;
} else {
harden(mk);
mk.s.x = Math.min(mk.s.x, 0.9);
}
at = -1;
await until(() => rest(mk.m) && rest(mk.s) && rest(mk.rot));
settled(g);
}
/* droplet: a neck pulls along the spine, thins and snaps; the drop left behind blooms into the done mark */
async function droplet(from: number, g: number) {
const b = body,
mk = marks[from];
await anticipate(b);
if (g !== gen) return;
handOff(mk, b);
mk.glyph = "none";
if (!ep) return finishInPlace(mk, b, g);
gBlobs.setAttribute("filter", `url(#${gooId})`);
tune(mk.s, 300, 0.9).to = 0.56;
haloOut();
startTrack(b, from, 0);
b.p.to = 1;
neck.seg = from;
let snap = false;
hook = travelHook(b, (len) => {
neck.d = len;
neck.n = clamp(1 - len / (segs[from].L * 0.6));
if (!snap && neck.n < 0.14 && len > 12) {
snap = true;
harden(mk);
mk.q.v -= 2.4;
b.q.v -= 1.2;
}
});
await until(() => !!ep?.landed && rest(b.p) && rest(b.q));
if (g !== gen) return;
park(b);
settled(g);
}
/* seed: the seed becomes the four-point star; a new seed buds off it and rolls to the next step */
async function seed(from: number, g: number) {
const b = body,
mk = marks[from];
await anticipate(b);
if (g !== gen) return;
handOff(mk, b);
b.show = false;
if (!ep) return finishInPlace(mk, b, g);
haloOut();
morphTo(mk, SHAPES.star, 220, 0.55);
mk.fill = "olive";
mk.glyph = "none";
tune(mk.rot, 170, 0.5).to = Math.ceil((mk.rot.x + 45) / 90) * 90;
tune(mk.s, 300, 0.5);
mk.s.v += 1.4;
await sleep(150);
if (g !== gen) return;
// the bud grows out from behind the star's lower point
b.show = true;
shapeAs(b, SHAPES.pebble);
b.fill = "pink";
b.glyph = "core";
set(b.rot, 0);
set(b.q, 0);
set(b.oy, 0);
set(b.s, 0);
tune(b.s, 320, 0.7).to = 0.46;
startTrack(b, from, 8);
await sleep(170);
if (g !== gen) return;
b.p.to = 1;
tune(b.s, 110, 0.85).to = 1;
tune(b.co, 200, 0.8).to = 5.5; // the nucleus sits off-centre while it rolls, so the turn is visible
let lastLen = lenOf(b);
hook = travelHook(b, (len) => {
// rolling: turn = distance / radius, so the overshoot rolls it back
set(b.rot, b.rot.x + ((len - lastLen) / (R * Math.max(0.35, b.s.x))) * 57.2958);
lastLen = len;
if (ep?.landed && b.co.to) tune(b.co, 220, 0.6).to = 0;
});
await until(
() => !!ep?.landed && rest(b.p) && rest(b.s) && rest(b.q) && rest(b.co),
);
if (g !== gen) return;
park(b);
settled(g);
}
/* division: the body elongates and pinches in two; one half hardens, the daughter travels */
async function division(from: number, g: number) {
const b = body,
mk = marks[from];
await anticipate(b);
if (g !== gen) return;
handOff(mk, b);
if (!ep) {
mk.glyph = "none";
return finishInPlace(mk, b, g);
}
gBlobs.setAttribute("filter", `url(#${gooId})`);
haloOut();
tune(mk.s, 260, 0.9).to = 0.8;
tune(b.s, 260, 0.9).to = 0.8;
set(sep, 0);
sep.to = 36;
startTrack(b, from, 0);
let pinched = false;
const travelling = travelHook(b, () => {
// The half keeps the body's colour until the goo lets go, so pink never blends into olive.
if (!mk.later) return;
const [x0, y0] = pos(mk),
[x1, y1] = pos(b);
if (Math.hypot(x1 - x0, y1 - y0) > R * (mk.s.x + b.s.x) + 8) {
mk.fill = mk.later.fill;
mk.glyph = mk.later.glyph;
mk.later = undefined;
}
});
hook = () => {
if (pinched) return travelling();
const e = clamp(sep.x / 36);
b.l0 = sep.x * 0.82;
set(mk.oy, -sep.x * 0.18);
mk.st = b.st = 0.22 * Math.sin(Math.PI * e); // the cell elongates along the spine as it divides
ink[from] = clamp(b.l0 / segs[from].L);
if (sep.x > 30.5) {
pinched = true;
mk.q.x = -mk.st * 0.85;
mk.st = 0; // the stretch snaps back as a wobble
harden(mk);
mk.later = { fill: "olive", glyph: "check" };
mk.fill = b.fill;
mk.glyph = "none";
tune(mk.oy, 220, 0.6).to = 0;
b.q.x = -b.st * 0.85;
b.p.v = (sep.v * 0.82) / (b.l1 - b.l0);
b.p.to = 1;
tune(b.s, 140, 0.8).to = 1;
}
};
await until(() => !!ep?.landed && rest(b.p) && rest(b.q) && rest(b.s));
if (g !== gen) return;
if (mk.later) {
mk.fill = mk.later.fill;
mk.glyph = mk.later.glyph;
mk.later = undefined;
}
park(b);
settled(g);
}
function run(from: number, to: number) {
park(body);
const g = begin(from, Math.max(from, to));
ep = to < 0 ? null : { to, landed: false, passed: new Set() };
if (to >= 0) at = to;
if (body.fill === "danger") {
// finished straight from needing an action: the traveller is working again
morphTo(body, workShape(), 240, 0.55);
body.fill = "pink";
body.glyph = "core";
halo.danger = false;
}
mood = false;
void { seed, droplet, division }[travel](from, g);
}
/**
* A newly chosen character introduces itself: the last done mark wakes back into the
* working body, then hands off to the current step again. Only text-free paint moves.
*/
async function replay() {
const to = at;
if (to < 1 || states[to] !== "current" || states[to - 1] !== "complete") return;
const from = to - 1,
b = body;
at = from;
mood = false;
const g = begin(from, to);
ep = { to, landed: false, passed: new Set() };
at = to;
shapeAs(b, doneShape());
b.fill = "olive";
b.glyph = doneGlyph();
set(halo.s, 0.6);
await sleep(60); // one painted frame of the done look, so the change below transitions
if (g !== gen) return;
morphTo(b, workShape(), 240, 0.6);
b.fill = "pink";
b.glyph = "core";
haloBloom();
await until(() => rest(b.m) && rest(halo.s), 700);
if (g !== gen) return;
void { seed, droplet, division }[travel](from, g);
}
function recoil() {
park(body);
const g = begin(at, at),
b = body;
mood = true;
// a flinch back up the spine, a contraction and a shudder, then the danger tone and the needs shape
tune(b.oy, 380, 0.42).v -= 240;
tune(b.s, 260, 0.5).v -= 2.2;
tune(b.q, 300, 0.35).v -= 1.6;
tune(b.rot, 420, 0.3).v += 380;
b.rot.to = Math.round(b.rot.x / 120) * 120;
morphTo(b, SHAPES.needs, 260, 0.5);
b.fill = "danger";
b.glyph = "bang";
halo.danger = true;
tune(halo.s, 300, 0.4).v -= 2.5;
void until(() => [b.oy, b.s, b.q, b.rot, b.m].every(rest)).then(() =>
settled(g),
);
}
function retry() {
park(body);
const g = begin(at, at),
b = body;
mood = false;
morphTo(b, workShape(), 240, 0.55);
// a drop or a cell comes back upright; the seed keeps the turn it shuddered to
if (travel !== "seed") tune(b.rot, 220, 0.6).to = Math.round(b.rot.x / 360) * 360;
b.fill = "pink";
b.glyph = "core";
b.q.v += 2.4;
b.s.v += 1.2;
haloBloom();
void until(() => [b.oy, b.s, b.q, b.rot, b.m].every(rest)).then(() =>
settled(g),
);
}
// Instant: drop any beat in flight and show the static paint.
function anchor() {
gen++;
if (painting) release(false);
at = states.findIndex(isActive);
mood = states[at] === "needs";
}
function act(plan: TravelPlan) {
if (plan.kind === "anchor") return anchor();
if (plan.kind === "none") return;
const inFlight = !!ep && !ep.landed && busy;
if (plan.kind === "handoff" && inFlight) {
// retarget from the current position and velocity
const b = body,
e = ep!;
e.to = plan.to;
at = plan.to;
for (let i = plan.from + 1; i <= plan.to; i++)
if (!e.passed.has(i)) rings[i].show = true;
own(plan.from, plan.to);
if (b.seg >= 0) {
const len = lenOf(b),
v = b.p.v * (b.l1 - b.l0);
b.l0 = len;
b.l1 = spanTo(b.seg, plan.to);
b.p.x = 0;
b.p.v = v / Math.max(1, b.l1 - b.l0);
}
return;
}
if (inFlight) return; // the beat in flight lands first, then reconcile() catches up
if (plan.kind === "handoff") return run(plan.from, plan.to);
if (plan.kind === "finish") return run(plan.at, -1);
if (plan.kind === "recoil") return recoil();
retry();
}
function reconcile() {
if (at >= 0) act(planTravel(ids, ids, states, at, mood));
}
return {
sync(next: Sync) {
const variant = next.travel !== travel;
const same =
next.axis === axis &&
next.ids.length === ids.length &&
next.ids.every((id, i) => id === ids[i]);
const fresh = variant || !same;
const instant = fresh || !next.allowed || document.hidden;
const preview =
variant && same && ids.length > 0 && !!next.lead && next.allowed && !document.hidden;
const before = new Map(ids.map((id, i) => [id, states[i]]));
const prevIds = ids;
if (instant && painting) {
gen++;
release(false);
}
ids = next.ids;
states = next.states;
travel = next.travel;
axis = next.axis;
measure();
const changed = ids.map(
(id, i) => variant || before.get(id) !== states[i],
);
const plan: TravelPlan = instant
? { kind: "anchor" }
: changed.some(Boolean)
? planTravel(prevIds, ids, states, at, mood)
: { kind: "none" };
// The layer sets the final turn of whatever it animates; everything else rests upright.
const lo =
plan.kind === "handoff" ? plan.from : "at" in plan ? plan.at : -1,
hi = plan.kind === "handoff" ? plan.to : lo;
changed.forEach(
(c, i) => c && !owned.has(i) && (i < lo || i > hi) && setTurn(i, 0),
);
act(plan);
if (preview) void replay();
draw();
wake();
},
measure() {
if (!painting) return;
measure();
draw();
},
attach() {
scenes.add(scene);
},
destroy() {
gen++;
if (painting) release(false);
scenes.delete(scene);
svg.replaceChildren();
},
};
}
/**
* Drives the decorative travel layer for one sibling list. The caller renders the static
* paint; this only borrows it while a beat plays and always hands back the same outline.
*/
export function useMilestoneTravel(
host: React.RefObject<HTMLElement | null>,
layer: React.RefObject<SVGSVGElement | null>,
next: Sync,
) {
const id = React.useId();
const scene = React.useRef<ReturnType<typeof createScene> | null>(null);
const sync = React.useEffectEvent(() => scene.current?.sync(next));
React.useLayoutEffect(() => {
if (!host.current || !layer.current) return;
const s = createScene(
host.current,
layer.current,
`mp-goo-${id.replace(/[^\w-]/g, "")}`,
);
s.attach();
scene.current = s;
const observer = new ResizeObserver(() => s.measure());
observer.observe(host.current);
return () => {
observer.disconnect();
s.destroy();
scene.current = null;
};
}, [host, layer, id]);
const key = `${next.ids.join("\u0001")}\u0002${next.states.join(",")}`;
// Declared after the scene effect, so on mount the scene exists before its first sync.
React.useLayoutEffect(() => {
sync();
}, [key, next.travel, next.axis, next.allowed, next.lead]);
}
registry/cojeev/styles/activity-feed.css
@layer cojeev-states {
.v-activity-feed[data-slot] {
min-width: 0;
width: 100%;
color: var(--v-text);
background: var(--v-paper);
padding: var(--s-7);
border: 1px solid var(--v-border);
border-radius: var(--r-panel) var(--r-panel) var(--r-sm) var(--r-panel);
}
.v-activity-feed__header {
display: grid;
gap: var(--s-2);
margin-block-end: var(--s-5);
}
.v-activity-feed__header :is(h3, p) {
margin: 0;
}
.v-activity-feed__heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-3);
}
.v-activity-feed__body {
position: relative;
}
.v-activity-feed__list {
display: grid;
list-style: none;
margin: 0;
padding: 0;
min-width: 0;
}
/* Rows and labels carry no padding of their own, so each can close to nothing. */
.v-activity-feed__entry,
.v-activity-feed__group {
position: relative;
min-width: 0;
}
.v-activity-feed :is(.v-activity-feed__entry, .v-activity-feed__group)::marker {
content: none;
}
.v-activity-feed__entry {
outline-offset: 3px;
border-radius: var(--r-sm);
}
.v-activity-feed__entry:focus-visible {
outline: 2px solid var(--v-blue);
}
.v-activity-feed__inner {
position: relative;
min-width: 0;
}
/* Only while a box springs open or closed: paint stays inside it. */
.v-activity-feed [data-moving] {
overflow: clip;
}
.v-activity-feed__portrait {
position: relative;
display: flex;
justify-content: center;
}
.v-activity-feed__mark {
position: relative;
display: grid;
place-items: center;
inline-size: 28px;
block-size: 28px;
isolation: isolate;
}
.v-activity-feed__mark > [data-slot] {
position: relative;
z-index: 1;
}
.v-activity-feed [data-mark="hidden"] .v-activity-feed__mark {
opacity: 0;
}
.v-activity-feed__group {
margin: 0;
color: var(--v-text-2);
font-size: var(--fs-caps);
font-weight: var(--fw-semibold);
letter-spacing: var(--ls-caps, 0.08em);
text-transform: uppercase;
z-index: 1;
}
/* A day's gap belongs to the day above it, so a new day's label never moves the one below. */
.v-activity-feed__group-text {
padding: 0 0 var(--s-1);
}
.v-activity-feed:is([data-variant="thread"], [data-variant="bursts"])
.v-activity-feed__entry[data-day-end]::after {
content: "";
display: block;
block-size: var(--s-4);
}
.v-activity-feed:is([data-variant="thread"], [data-variant="bursts"])
.v-activity-feed__list {
margin-block-end: calc(-1 * var(--s-4));
}
.v-activity-feed__header
.v-activity-feed__heading
[data-slot="typography-title"] {
font-size: var(--fs-title);
letter-spacing: -0.035em;
}
/* The product's own entries carry the four-point brand star. */
.v-activity-feed__marker svg {
display: block;
inline-size: 30px;
block-size: 30px;
margin: -1px;
overflow: visible;
flex: none;
}
.v-activity-feed__marker path {
fill: var(--v-olive);
}
.v-activity-feed__content[data-slot] {
position: relative;
z-index: 1;
row-gap: 2px;
min-width: 0;
align-content: start;
transition:
opacity var(--t-max) var(--enter),
translate var(--t-max) var(--enter);
}
.v-activity-feed [data-arriving] .v-activity-feed__content {
opacity: 0;
}
.v-activity-feed [data-slide] .v-activity-feed__content {
opacity: 0;
translate: 0 -8px;
}
/* A removed row fades inside its own closing height. */
.v-activity-feed [data-leaving] > * {
opacity: 0;
transition: opacity var(--t-element) var(--exit);
}
.v-activity-feed__entry-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--s-2) var(--s-3);
min-width: 0;
}
.v-activity-feed__title[data-slot] {
display: block;
overflow: visible;
-webkit-line-clamp: unset;
font-size: var(--fs-body);
line-height: 1.5;
overflow-wrap: anywhere;
}
.v-activity-feed__badge[data-slot] {
flex-shrink: 0;
max-width: 100%;
min-height: 20px;
height: auto;
padding-block: 3px;
white-space: normal;
line-height: 1.2;
}
.v-activity-feed__description[data-slot] {
font-size: var(--fs-control);
line-height: 1.55;
max-width: 68ch;
overflow-wrap: anywhere;
}
.v-activity-feed__description :first-child {
margin-block-start: 0;
}
.v-activity-feed__description :last-child {
margin-block-end: 0;
}
.v-activity-feed__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0 var(--s-2);
color: var(--v-text-2);
min-width: 0;
}
/*
* The one breathing thing in the ledger and bursts: the newest entry while it is fresh.
* Its space is always kept, so the dot coming and going never rewraps a line.
*/
.v-activity-feed__live {
display: none;
inline-size: 6px;
block-size: 6px;
border-radius: 50%;
background: var(--v-pink-deep);
flex: none;
}
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
.v-activity-feed__live {
display: inline-block;
opacity: 0;
transform: scale(0.4);
transition:
opacity var(--t-element) var(--exit),
transform var(--t-element) var(--exit);
}
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
[data-breath]
.v-activity-feed__live {
opacity: 1;
transform: none;
transition:
opacity var(--t-element) var(--enter),
transform var(--t-element) var(--enter);
animation: v-activity-dot var(--breath) ease-in-out infinite;
}
@keyframes v-activity-dot {
50% {
opacity: 0.4;
transform: scale(0.7);
}
}
.v-activity-feed__meta :is(time, span) {
overflow-wrap: anywhere;
}
.v-activity-feed__details {
display: grid;
gap: var(--s-2);
margin-block-start: var(--s-1);
min-width: 0;
overflow-wrap: anywhere;
}
.v-activity-feed__footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--s-3);
padding-block-start: var(--s-4);
border-block-start: 1px solid var(--v-edge);
}
.v-activity-feed__footer > [data-slot="button"] {
min-height: 44px;
width: auto;
}
/* ---------- thread: one row per entry on a living thread ---------- */
.v-activity-feed[data-variant="thread"] .v-activity-feed__group-text {
padding-inline-start: calc(36px + var(--s-4));
}
.v-activity-feed[data-variant="thread"] .v-activity-feed__inner {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
column-gap: var(--s-4);
padding-block: var(--s-3) var(--s-4);
}
.v-activity-feed[data-variant="thread"] [data-breath] .v-activity-feed__mark::before {
content: "";
position: absolute;
inset: -6px;
z-index: -1;
border-radius: 43% 57% 49% 51% / 55% 44% 56% 45%;
background: var(--v-pink);
opacity: 0.2;
animation: v-activity-halo var(--breath) ease-in-out infinite;
}
@keyframes v-activity-halo {
50% {
opacity: 0.36;
transform: scale(1.1);
}
}
/* Paint only, never a hit target: the spine and the blooming seed. */
.v-activity-feed__thread {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
overflow: visible;
pointer-events: none;
z-index: 3;
}
.v-activity-feed__spine {
fill: none;
stroke: var(--v-border);
stroke-width: 1.5;
stroke-linecap: round;
}
.v-activity-feed__seed {
transition: fill 240ms var(--enter);
}
.v-activity-feed__seed[data-fill="pink"] {
fill: var(--v-pink);
}
.v-activity-feed__seed[data-fill="olive"] {
fill: var(--v-olive);
}
/* ---------- ledger and bursts share the compact row ---------- */
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
.v-activity-feed__content[data-slot] {
grid-template-columns: minmax(0, 1fr) auto;
column-gap: var(--s-3);
}
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
.v-activity-feed__content
> * {
grid-column: 1 / -1;
}
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
.v-activity-feed__content
> .v-activity-feed__entry-heading {
grid-column: 1;
}
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
.v-activity-feed__content
> .v-activity-feed__meta {
grid-column: 2;
grid-row: 1;
justify-self: end;
align-self: baseline;
white-space: nowrap;
}
/* The run names the actor once; each row keeps it for assistive technology. */
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
:is(.v-activity-feed__who, .v-activity-feed__sep) {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.v-activity-feed:is([data-variant="ledger"], [data-variant="bursts"])
.v-activity-feed__title[data-slot] {
font-size: var(--fs-control);
}
/* ---------- ledger: dense runs, pinned day labels ---------- */
.v-activity-feed[data-variant="ledger"] .v-activity-feed__group {
position: sticky;
top: 0;
z-index: 4;
background: var(--v-paper);
box-shadow: 0 1px 0 var(--v-border);
}
.v-activity-feed[data-variant="ledger"] .v-activity-feed__group-text {
padding: var(--s-2) 0;
}
/* A run is its rows: the first carries the avatar and name, the edges carry the padding. */
.v-activity-feed[data-variant="ledger"] .v-activity-feed__inner {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
column-gap: var(--s-3);
padding-block: 3px;
}
.v-activity-feed[data-variant="ledger"]
.v-activity-feed__entry[data-first]
> .v-activity-feed__inner {
padding-block-start: calc(var(--s-3) + 3px);
}
.v-activity-feed[data-variant="ledger"]
.v-activity-feed__entry[data-last]
> .v-activity-feed__inner {
padding-block-end: calc(var(--s-3) + 3px);
}
.v-activity-feed[data-variant="ledger"]
.v-activity-feed__entry
+ .v-activity-feed__entry[data-first]
> .v-activity-feed__inner {
box-shadow: inset 0 1px 0 var(--v-border);
}
.v-activity-feed[data-variant="ledger"] .v-activity-feed__portrait {
padding-block-start: 1px;
}
.v-activity-feed__body-cell {
min-width: 0;
}
.v-activity-feed__line {
position: relative;
min-width: 0;
}
.v-activity-feed__run-name {
margin: -3px 0 3px;
font-size: var(--fs-small);
font-weight: var(--fw-semibold);
line-height: 1.5;
color: var(--v-text);
}
/* The ink wash marks a new line, then fades within one draw. */
.v-activity-feed__wash {
position: absolute;
inset: 0 calc(-1 * var(--s-2));
z-index: 0;
border-radius: var(--r-sm);
background: var(--sel-bg);
opacity: 0;
pointer-events: none;
transition: opacity var(--t-draw) var(--exit);
}
.v-activity-feed__wash[data-on] {
opacity: 1;
transition: none;
}
/* ---------- bursts: one card per actor's burst of updates ---------- */
/* A card is its rows: each paints its own slice of the card, the edges round it off. */
.v-activity-feed[data-variant="bursts"] .v-activity-feed__entry {
--v-activity-gap: 0px;
}
.v-activity-feed[data-variant="bursts"] .v-activity-feed__entry[data-last] {
--v-activity-gap: var(--s-3);
}
.v-activity-feed[data-variant="bursts"]
.v-activity-feed__entry[data-last][data-day-end] {
--v-activity-gap: calc(var(--s-3) + var(--s-4));
}
.v-activity-feed[data-variant="bursts"] .v-activity-feed__entry::before {
content: "";
position: absolute;
inset: 0 0 var(--v-activity-gap);
background: var(--v-canvas);
border: 1px solid var(--v-border);
border-block-width: 0;
}
.v-activity-feed[data-variant="bursts"] .v-activity-feed__entry[data-first]::before {
border-block-start-width: 1px;
border-start-start-radius: var(--r-card-sm);
border-start-end-radius: var(--r-card-sm);
}
.v-activity-feed[data-variant="bursts"] .v-activity-feed__entry[data-last]::before {
border-block-end-width: 1px;
border-end-end-radius: var(--r-card-sm);
border-end-start-radius: var(--r-sm);
}
/* The gap to the next card, kept inside the last row's height. */
.v-activity-feed[data-variant="bursts"] .v-activity-feed__entry[data-last]::after {
content: "";
display: block;
block-size: var(--v-activity-gap);
}
.v-activity-feed__card {
position: relative;
padding-inline: var(--s-4);
}
.v-activity-feed__entry[data-first] > .v-activity-feed__card {
padding-block-start: var(--s-3);
}
.v-activity-feed__entry[data-last] > .v-activity-feed__card {
padding-block-end: var(--s-3);
}
.v-activity-feed__card-head {
position: relative;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--s-3);
min-height: 28px;
list-style: none;
border-radius: var(--r-sm);
}
.v-activity-feed__card-head::-webkit-details-marker {
display: none;
}
summary.v-activity-feed__card-head {
cursor: pointer;
}
summary.v-activity-feed__card-head:focus-visible {
outline: 2px solid var(--v-blue);
outline-offset: 2px;
}
.v-activity-feed__card-who {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 var(--s-2);
min-width: 0;
}
.v-activity-feed__card-name {
font-weight: var(--fw-semibold);
overflow-wrap: anywhere;
}
.v-activity-feed__card-count {
font-size: var(--fs-meta);
color: var(--v-text-2);
font-variant-numeric: tabular-nums;
}
.v-activity-feed__chevron {
inline-size: 16px;
block-size: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
color: var(--v-text-2);
transition: rotate var(--t-element) var(--enter);
}
.v-activity-feed__fold[open]:not([data-closing]) .v-activity-feed__chevron {
rotate: 180deg;
}
.v-activity-feed[data-variant="bursts"] .v-activity-feed__inner {
margin-inline-start: calc(28px + var(--s-3));
padding-block: var(--s-2);
}
.v-activity-feed[data-variant="bursts"]
.v-activity-feed__entry[data-first]
.v-activity-feed__inner {
padding-block-start: calc(var(--s-1) + var(--s-2));
}
.v-activity-feed[data-variant="bursts"]
.v-activity-feed__entry[data-last]
.v-activity-feed__inner {
padding-block-end: 0;
}
.v-activity-feed[data-variant="bursts"]
.v-activity-feed__entry:not([data-first])
.v-activity-feed__inner {
box-shadow: inset 0 1px 0 var(--v-border);
}
/* Quiet and reduced motion: instant, nothing loops. */
.v-activity-feed[data-motion-quiet] *,
.v-activity-feed[data-motion-quiet] *::before {
animation: none;
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.v-activity-feed *,
.v-activity-feed *::before {
animation: none;
transition: none;
}
}
@media (max-width: 480px) {
.v-activity-feed[data-variant="thread"] .v-activity-feed__inner {
column-gap: var(--s-3);
}
.v-activity-feed[data-variant="thread"] .v-activity-feed__group-text {
padding-inline-start: calc(36px + var(--s-3));
}
.v-activity-feed__footer {
justify-content: flex-start;
gap: var(--s-3) var(--s-5);
}
}
@media (max-width: 600px) {
.v-activity-feed[data-slot] {
padding: var(--s-5);
}
}
@media (max-width: 560px) {
.v-activity-feed__card {
padding-inline: var(--s-3);
}
}
.v-activity-example {
width: 100%;
min-width: 0;
display: grid;
gap: var(--s-6);
}
}