shadimage
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 · light

Registry 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);
}

}

More from 000h-cojeev

Browse all components in this library →