Theme
On this page (5)
Component Skill — components/anim-canvas/component-skill.md

Native basis

.anim-canvas viewport + .anim-canvas-slide sections. JS moves the slides into one .anim-canvas-board layer carrying the pan/zoom transform (transform-origin: 0 0); positions derive by BFS from the relation map. Scaling is a ResizeObserver on the root.

Web Platform APIs

el.animate()ResizeObserverinertdata-*transformprefers-reduced-motion

Attributes

data-activedata-eastdata-westdata-northdata-southdata-anim-indata-anim-in-delay--anim-canvas-gapdata-overviewdata-current-slidedata-pan-duration

Notes

• Animations are engine names - see the Motion vocabulary and the per-animation deep dives (fade/slide/zoom/pop/spin/flip/skew/blur/wipe/iris + the blocks roll-over). • Only the slide you move to animates - the one you leave pans out of frame still. Its arrival starts as the camera reaches it, then its content builds in (data-df-entrance / data-df-draw / data-count replay through the shared motion controller). • blocksIn makes the arriving slide land under a curtain that rolls off it - give it a visible data-anim-in-color. • --anim-canvas-gap (board units, default 80) separates the cells - the overview shows them as tiles. • Arrow keys never swallow a direction that has no neighbor.

§A 3×2 story board

Six slides on one board, wired as a 3×2 grid. Click the canvas, then use the arrow keys (or the buttons); O or Escape zooms out to the overview (the gap separates the tiles), clicking a tile zooms back in. Only the slide you move TO animates: each declares its own df$.anim arrival (zoomIn, a blocks curtain it arrives under - painted a visible color via data-anim-in-color - wipeIn, slideIn, irisIn, blurIn) and its content then builds in through the shared motion entrances (data-df-entrance, data-df-draw, data-count). The slide you leave stays still and pans out of frame. The State tab drives the same canvas through the schema (slide id + overview).

§Curtain color (blend-over)

Every blend-over animation takes an explicit color so the cover is SEEN against the slide - never let it inherit into invisibility. blocksIn covers the card with five staggered panels painted the picked color; blocksOut rolls them back off.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - one slide framed 1:1; setState('default', { slide: 's2' }) focuses that slide, bare setState('default') re-frames the active one
  • overview - the whole board zoomed out (data-overview); every slide is a clickable tile that zooms back in

The demo card carries data-state-demo; bun run screenshots drives it via el.api.setState(name).

Machine contract - verified against anim-canvas.schema.json by bun run verify:

StateTypeValuesDefaultDescription
slidestring——Active slide id - setState('default', { slide: '<id>' }); mirrored as data-current-slide on .anim-canvas and updated by arrow keys, controls and overview clicks.
overviewbooleantrue, falsefalseZoomed-out board view (data-overview on the root) - setState('overview') / setState('overview', { value: false }).

§CSS view file

The canvas surface: viewport + board layer, card-ish slides, the overview tile affordance (cursor, radius, ring hover), optional directional chrome, and the required reduced-motion / forced-colors blocks. All motion is the shared engine - this sheet animates nothing.

/* -- Animation Canvas component ----------------------------------
   Chess-board slide canvas: the root is the viewport (overflow hidden),
   JS-created .anim-canvas-board carries the pan/zoom transform, slides are
   absolute cells in board units (--anim-canvas-width/height, literal
   defaults, token-free artboard like presentation). All motion is the shared
   df$.anim engine + one WAAPI board pan; this sheet owns only the surface:
   card-ish slides (allowed tokens), the overview tile affordance, and the
   optional directional chrome. */
@layer components {
  .anim-canvas {
    /* artboard contract - override on the mount for other formats */
    --anim-canvas-width: 1280;
    --anim-canvas-height: 720;
    /* gutter between board cells (board units): separates the overview
       tiles and shows as a seam while the board pans. 0 = flush cells. */
    --anim-canvas-gap: 80;
    position: relative;
    display: block;
    width: 100%;
    aspect-ratio: var(--anim-canvas-width) / var(--anim-canvas-height);
    overflow: hidden;
    background: var(--muted);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    /* the one pan/zoom layer (JS-created; transform-origin pinned so the
       runtime's translate+scale math stays in raw board units) */
    & .anim-canvas-board {
      position: absolute;
      inset: 0;
      transform-origin: 0 0;
      will-change: transform;
    }
    /* a board cell: card surface, separated from its neighbors by the gap
       (radius + shadow appear in overview, where every cell is a tile) */
    & .anim-canvas-slide {
      position: absolute;
      box-sizing: border-box;
      overflow: hidden;
      background: var(--card);
      color: var(--card-foreground);
    }
    /* overview: every slide reads as a clickable tile */
    &[data-overview] .anim-canvas-slide {
      cursor: pointer;
      border-radius: var(--radius-lg);
      box-shadow: var(--shadow-md);
      &:hover {
        outline: 2px solid var(--ring);
        outline-offset: 2px;
      }
    }
    /* optional authored chrome: directional + overview buttons
       ([data-anim-canvas-go="east|west|north|south|overview"]) */
    & .anim-canvas-controls {
      position: absolute;
      inset-block-end: 0.75rem;
      inset-inline-end: 0.75rem;
      display: flex;
      align-items: center;
      gap: 0.375rem;
      z-index: 2;
    }
    & .anim-canvas-control {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      inline-size: 2rem;
      block-size: 2rem;
      padding: 0;
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--card);
      color: var(--card-foreground);
      cursor: pointer;
      font: inherit;
      line-height: 1;
      transition: background-color 150ms ease;
      &:hover {
        background: var(--accent);
        color: var(--accent-foreground);
      }
      &:disabled {
        opacity: 0.4;
        cursor: default;
      }
    }
  }
  /* accessibility: honor motion + contrast preferences (REQUIRED). The
     engine collapses its own durations and the runtime collapses the board
     pan to 1ms; here the chrome's hover transition flattens. */
  @media (prefers-reduced-motion: reduce) {
    .anim-canvas .anim-canvas-control {
      transition: none;
    }
  }
  @media (prefers-contrast: more) {
    .anim-canvas .anim-canvas-control {
      border-width: 2px;
    }
  }
  @media (forced-colors: active) {
    .anim-canvas {
      border-color: CanvasText;
    }
    .anim-canvas[data-overview] .anim-canvas-slide {
      outline: 1px solid CanvasText;
    }
    .anim-canvas .anim-canvas-control {
      border-color: ButtonBorder;
      color: ButtonText;
      background: ButtonFace;
    }
  }
}

§JavaScript view file

The runtime: relation-map BFS with fail-loud validation, ResizeObserver scaling, WAAPI board pan (1ms under prefers-reduced-motion), keyboard via the shared bindGlobalKeys, overview with click-to-zoom, and the State API - every slide transition plays through df$.anim channels by name.

// -- Animation Canvas ----------------------------------------------
// A chess-board slide canvas: slides sit side by side on one board, the
// viewport frames exactly one of them (1:1), arrow keys move directionally
// (data-east/west/north/south id refs), and a zoomed-out overview shows the
// whole board at once (click a tile to zoom back in). EVERY animation is the
// shared engine (df$.anim, src/shared/anim.ts): each slide declares how it
// ARRIVES (data-anim-in + discrete config attrs) - only the slide you move to
// animates, the one you leave pans out of frame still - played through the registry - the canvas composes the public engine, it
// never grows a private one. The board pan is plain WAAPI (el.animate), the
// platform API, with a 1ms collapse under prefers-reduced-motion (same
// contract the engine keeps). Keyboard rides the shared bindGlobalKeys.
//
// Markup contract: .anim-canvas viewport > .anim-canvas-slide sections with
// id + relation attrs. JS moves the slides into ONE .anim-canvas-board div
// that carries the pan/zoom transform (transform-origin: 0 0). Slide positions
// derive by BFS from the start slide (0,0): east = x+1, west = x−1,
// south = y+1, north = y−1. Dangling id refs and position conflicts are loud
// console errors, never silent misplacement. State lives ON THE ROOT element
// (dataset.stateName + data-current-slide + data-overview) - the bound `api`
// is the only mutator (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// NOTE: `anim` (the registry) is imported, not animChannel - the shared
// namespace core installs (src/core/index.ts) publishes `anim` only, so the
// emitted binding can only destructure names that exist there. Name
// validation below goes through anim.names, keeping the same fail-loud
// contract animChannel has.
import { defussGlobals, defussQuery, anim, bindGlobalKeys, entrance, draw, animateCount } from '../../shared/state-api.js';
import type { AnimChannel, AnimDirection, AnimOptions } from '../../shared/anim.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const animCanvasStates = ['default', 'overview'];
/** Board geometry of one slide cell, in board units (px at scale 1). */
interface SlidePos {
  x: number;
  y: number;
}
/** The one pan/zoom the board currently rests at (or animates toward). */
interface BoardView {
  s: number;
  tx: number;
  ty: number;
}
/** Per-instance context - state lives on the element, never module scope. */
interface CanvasCtx {
  board: HTMLElement;
  slides: HTMLElement[];
  byId: Map<string, HTMLElement>;
  pos: Map<HTMLElement, SlidePos>;
  w: number;
  h: number;
  /** gutter between board cells, board units (--anim-canvas-gap) */
  gap: number;
  active: HTMLElement;
  busy: boolean;
  view: BoardView | null;
  pan: Animation | null;
}
type CanvasRoot = HTMLElement & { _animCanvas?: CanvasCtx };
const DIRS: Record<AnimDirection, [number, number]> = {
  east: [1, 0],
  west: [-1, 0],
  south: [0, 1],
  north: [0, -1],
};
/**
 * Registry lookup with the engine's fail-loud contract: a typo in
 * data-anim-in throws a naming error, never a silent no-op
 * (mirrors animChannel - which the emitted shared binding cannot carry, see
 * the preamble note).
 */
function channelFor(name: string): AnimChannel {
  if (!(anim.names as readonly string[]).includes(name)) {
    throw new Error(`anim-canvas: unknown animation "${name}" (supported: ${anim.names.join(', ')})`);
  }
  return anim[name as keyof typeof anim] as AnimChannel;
}
/** The arrival animation a slide declares (default slideIn, travel direction). */
function animNameFor(slide: HTMLElement): string {
  return slide.dataset.animIn || 'slideIn';
}
/**
 * Discrete per-slide arrival config (data-anim-in-direction|duration|delay|
 * easing|origin|distance|blocks|stagger|color). Absent values fall back to
 * the engine defaults; direction falls back to the travel direction, delay to
 * the arrival lead (see arrive).
 */
function animOptsFor(slide: HTMLElement, travel: AnimDirection): AnimOptions {
  const d = slide.dataset as Record<string, string | undefined>;
  const p = 'animIn';
  const opts: AnimOptions = { direction: (d[`${p}Direction`] as AnimDirection | undefined) ?? travel };
  const duration = parseFloat(d[`${p}Duration`] ?? '');
  if (Number.isFinite(duration)) opts.duration = duration;
  const delay = parseFloat(d[`${p}Delay`] ?? '');
  if (Number.isFinite(delay)) opts.delay = delay;
  if (d[`${p}Easing`]) opts.easing = d[`${p}Easing`];
  if (d[`${p}Origin`]) opts.origin = d[`${p}Origin`];
  if (d[`${p}Distance`]) opts.distance = d[`${p}Distance`];
  const blocks = parseInt(d[`${p}Blocks`] ?? '', 10);
  if (Number.isFinite(blocks)) opts.blocks = blocks;
  const stagger = parseFloat(d[`${p}Stagger`] ?? '');
  if (Number.isFinite(stagger)) opts.stagger = stagger;
  if (d[`${p}Color`]) opts.color = d[`${p}Color`];
  return opts;
}
const transformFor = (v: BoardView): string => `translate(${v.tx}px, ${v.ty}px) scale(${v.s})`;
const reducedMotion = (): boolean =>
  typeof globalThis.matchMedia === 'function' &&
  globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Root-level pan duration (data-pan-duration, ms); 1500 default, 1 when reduced. */
function panDuration(root: CanvasRoot): number {
  if (reducedMotion()) return 1;
  const v = parseFloat(root.dataset.panDuration ?? '');
  return Number.isFinite(v) ? v : 1500;
}
/** The 1:1 view: the given slide framed exactly by the viewport, centered. */
function focusView(root: CanvasRoot, ctx: CanvasCtx, slide: HTMLElement): BoardView | null {
  const box = root.getBoundingClientRect();
  if (box.width <= 0 || box.height <= 0) return null; // hidden mount - ResizeObserver re-fires
  const p = ctx.pos.get(slide) ?? { x: 0, y: 0 };
  const s = Math.min(box.width / ctx.w, box.height / ctx.h);
  return {
    s,
    tx: (box.width - ctx.w * s) / 2 - p.x * (ctx.w + ctx.gap) * s,
    ty: (box.height - ctx.h * s) / 2 - p.y * (ctx.h + ctx.gap) * s,
  };
}
/** The overview view: every slide in frame at once, ~8% margin around the bbox. */
function overviewView(root: CanvasRoot, ctx: CanvasCtx): BoardView | null {
  const box = root.getBoundingClientRect();
  if (box.width <= 0 || box.height <= 0) return null;
  let minX = Infinity;
  let minY = Infinity;
  let maxX = -Infinity;
  let maxY = -Infinity;
  for (const p of ctx.pos.values()) {
    minX = Math.min(minX, p.x);
    minY = Math.min(minY, p.y);
    maxX = Math.max(maxX, p.x);
    maxY = Math.max(maxY, p.y);
  }
  // cells + the gutters BETWEEN them (none outside the outermost cells)
  const bw = (maxX - minX + 1) * (ctx.w + ctx.gap) - ctx.gap;
  const bh = (maxY - minY + 1) * (ctx.h + ctx.gap) - ctx.gap;
  const s = Math.min(box.width / bw, box.height / bh) * 0.92;
  return {
    s,
    tx: (box.width - bw * s) / 2 - minX * (ctx.w + ctx.gap) * s,
    ty: (box.height - bh * s) / 2 - minY * (ctx.h + ctx.gap) * s,
  };
}
/**
 * Pan/zoom the board to a view. Platform WAAPI with fill:'both' while in
 * flight; the settled transform lands as the board's own style so the resting
 * state never depends on a live animation. A new pan cancels the old one.
 */
function panTo(root: CanvasRoot, ctx: CanvasCtx, view: BoardView | null, animate = true): Promise<void> {
  if (!view) return Promise.resolve();
  ctx.pan?.cancel();
  ctx.pan = null;
  const to = transformFor(view);
  const from = ctx.view ? transformFor(ctx.view) : null;
  ctx.view = view;
  if (!animate || !from || from === to || panDuration(root) <= 1) {
    ctx.board.style.transform = to;
    return Promise.resolve();
  }
  const flight = ctx.board.animate([{ transform: from }, { transform: to }], {
    duration: panDuration(root),
    easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
    fill: 'both',
  });
  ctx.pan = flight;
  return flight.finished
    .catch(() => undefined) // cancel rejects - a superseded pan is not an error
    .then(() => {
      if (ctx.pan === flight) {
        ctx.board.style.transform = to;
        flight.cancel();
        ctx.pan = null;
      }
    });
}
/** a11y + chrome mirrors of the active slide: inert/aria-hidden, control availability. */
function applySlideState(root: CanvasRoot, ctx: CanvasCtx): void {
  const overview = root.hasAttribute('data-overview');
  for (const s of ctx.slides) {
    const on = overview || s === ctx.active;
    s.inert = !on;
    if (on) s.removeAttribute('aria-hidden');
    else s.setAttribute('aria-hidden', 'true');
  }
  // authored directional controls follow the active slide's relation map
  root.querySelectorAll('[data-anim-canvas-go]').forEach((el) => {
    const dir = el.getAttribute('data-anim-canvas-go');
    if (dir === 'overview' || !(el instanceof HTMLButtonElement)) return;
    el.disabled = !ctx.active.dataset[dir as AnimDirection];
  });
}
/** The one place slide activation (and its mirrors) changes. */
function activate(root: CanvasRoot, ctx: CanvasCtx, slide: HTMLElement): void {
  ctx.active = slide;
  for (const s of ctx.slides) s.toggleAttribute('data-active', s === slide);
  root.dataset.currentSlide = slide.id; // the bridge/schema observation point
  applySlideState(root, ctx);
}
/** Arrival lead (ms): the in-animation starts when the pan is ~35% there,
 * so the slide materializes as the camera reaches it - not off-screen. */
function arrivalLead(root: CanvasRoot): number {
  return reducedMotion() ? 0 : Math.round(panDuration(root) * 0.35);
}
/** Content builds this long after the slide itself starts arriving. */
const CONTENT_LAG = 280;
/** Parse a resolved CSS time list ("0.24s", "120ms") to ms (first entry). */
function cssMs(v: string): number {
  const first = (v || '').split(',')[0].trim();
  const n = parseFloat(first);
  if (!Number.isFinite(n)) return 0;
  return first.endsWith('ms') ? n : n * 1000;
}
/**
 * Why: the arrival is more than the slide's own in-animation - its content
 * builds in. Every [data-df-entrance] / [data-df-draw] descendant replays
 * through the SHARED motion controller (same calls presentation makes on
 * activation) offset by the arrival, and [data-count] counters re-run.
 * Each element keeps its authored timing: the resolved animation-delay
 * (inline --df-motion-delay OR a [data-df-stagger] grade) is read once and
 * cached, the offset rides on top - never flattening a stagger, never
 * accumulating across revisits. Fill-mode both keeps content in its start
 * state during the delay, so nothing flashes while the camera arrives.
 */
function replayContent(slide: HTMLElement, offset: number): void {
  const withOffset = (el: HTMLElement | SVGElement): number => {
    const d = (el as HTMLElement).dataset;
    if (d.animCanvasBaseDelay === undefined) d.animCanvasBaseDelay = String(cssMs(getComputedStyle(el).animationDelay));
    return parseFloat(d.animCanvasBaseDelay) + offset;
  };
  slide.querySelectorAll<HTMLElement>('[data-df-entrance]').forEach((el) => {
    entrance(el, undefined, { delay: withOffset(el) });
  });
  slide.querySelectorAll<SVGElement>('[data-df-draw]').forEach((el) => {
    draw(el, { delay: withOffset(el) });
  });
  slide.querySelectorAll<HTMLElement>('[data-count]').forEach((el) => {
    animateCount(el, { delay: offset });
  });
}
/** A slide we LEAVE never animates: any arrival still in flight on it
 * snaps to its settled end (fill-mode keeps the finished frame), so the pan
 * carries a still slide out of frame. */
function settle(slide: HTMLElement): void {
  for (const a of slide.getAnimations({ subtree: true })) {
    try {
      a.finish();
    } catch {
      a.cancel(); // an infinite animation cannot finish - unwind it instead
    }
  }
}
/**
 * The arrival: ONLY the slide we move to animates. Its declared in-channel
 * (data-anim-in + config, default slideIn with the travel direction) plays
 * with a lead so it lands as the camera arrives; the blocks pair composes as
 * a curtain the TARGET arrives under - it starts fully covered and the
 * panels roll off it (blocksOut) - and its content builds in after it.
 */
function arrive(root: CanvasRoot, target: HTMLElement, travel: AnimDirection): void {
  const lead = arrivalLead(root);
  const inName = animNameFor(target);
  const opts = animOptsFor(target, travel);
  if (opts.delay === undefined) opts.delay = lead;
  channelFor(inName === 'blocksIn' ? 'blocksOut' : inName).play(target, opts);
  replayContent(target, (opts.delay ?? lead) + (reducedMotion() ? 0 : CONTENT_LAG));
}
/**
 * The transition. No-op while busy or already there; an overview open
 * collapses into the target (the zoom IS the transition, the content still
 * builds in). Otherwise the board pans and only the arriving slide animates
 * (see arrive) - the slide we leave stays still and simply pans out of
 * frame (settle). busy covers the pan; the arrival animations are
 * per-channel and restart deterministically, so a fast arrow-key repeat is
 * never swallowed by a cosmetic tail.
 */
async function goTo(root: CanvasRoot, ctx: CanvasCtx, id: string): Promise<void> {
  const target = ctx.byId.get(id);
  if (!target) {
    console.error(`anim-canvas: goTo("${id}") - no .anim-canvas-slide with that id in this canvas`);
    return;
  }
  if (ctx.busy) return;
  if (target === ctx.active && !root.hasAttribute('data-overview')) return;
  ctx.busy = true;
  try {
    if (root.hasAttribute('data-overview')) {
      root.removeAttribute('data-overview');
      activate(root, ctx, target);
      replayContent(target, reducedMotion() ? 0 : Math.round(panDuration(root) * 0.5));
      await panTo(root, ctx, focusView(root, ctx, target));
      return;
    }
    const from = ctx.pos.get(ctx.active) ?? { x: 0, y: 0 };
    const to = ctx.pos.get(target) ?? from;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    const travel: AnimDirection = dx > 0 ? 'east' : dx < 0 ? 'west' : dy > 0 ? 'south' : dy < 0 ? 'north' : 'east';
    settle(ctx.active);
    activate(root, ctx, target);
    const pan = panTo(root, ctx, focusView(root, ctx, target));
    arrive(root, target, travel);
    await pan;
  } finally {
    ctx.busy = false;
  }
}
/** Overview mode: every slide in frame, clickable (CSS owns the cursor). */
function enterOverview(root: CanvasRoot, ctx: CanvasCtx): void {
  // an in-flight pan never blocks the toggle - panTo cancels it cleanly
  if (root.hasAttribute('data-overview')) return;
  ctx.busy = true;
  root.setAttribute('data-overview', ''); // the schema observation point
  applySlideState(root, ctx); // every slide is visible now - nothing stays inert
  void panTo(root, ctx, overviewView(root, ctx)).then(() => {
    ctx.busy = false;
  });
}
/** Leave overview; `focus` (a clicked tile) becomes the active slide. */
function exitOverview(root: CanvasRoot, ctx: CanvasCtx, focus?: HTMLElement): void {
  // an in-flight enter pan never swallows the exit (the bridge toggles fast)
  if (!root.hasAttribute('data-overview')) return;
  ctx.busy = true;
  root.removeAttribute('data-overview');
  if (focus && ctx.byId.get(focus.id) === focus) {
    activate(root, ctx, focus);
    replayContent(focus, reducedMotion() ? 0 : Math.round(panDuration(root) * 0.5));
  } else applySlideState(root, ctx);
  void panTo(root, ctx, focusView(root, ctx, ctx.active)).then(() => {
    ctx.busy = false;
  });
}
function toggleOverview(root: CanvasRoot, ctx: CanvasCtx): void {
  if (root.hasAttribute('data-overview')) exitOverview(root, ctx);
  else enterOverview(root, ctx);
}
/**
 * UI side of setState: 'overview' zooms out (config.value === false zooms
 * back - the bridge's checkbox off); 'default' with { slide } focuses that
 * slide, bare 'default' leaves overview / re-frames the active slide.
 * Unknown names throw.
 */
function triggerStateChange(root: CanvasRoot, stateName: string, config: Record<string, unknown> = {}): void {
  if (!animCanvasStates.includes(stateName)) {
    throw new Error(`anim-canvas: unknown state "${stateName}" (supported: ${animCanvasStates.join(', ')})`);
  }
  const ctx = root._animCanvas;
  if (!ctx) return;
  if (stateName === 'overview') {
    if (config.value === false) exitOverview(root, ctx);
    else enterOverview(root, ctx);
    return;
  }
  if (typeof config.slide === 'string') void goTo(root, ctx, config.slide);
  else if (root.hasAttribute('data-overview')) exitOverview(root, ctx);
  else void panTo(root, ctx, focusView(root, ctx, ctx.active));
}
/** Registry-level API; pass the root explicitly. Unknown names throw. */
export const animCanvasApi = {
  setState(root: HTMLElement, stateName: string, config: Record<string, unknown> = {}) {
    triggerStateChange(root as CanvasRoot, stateName, config);
    // state lives on the ELEMENT, not module scope (AGENTS.md "State API")
    root.dataset.stateName = stateName;
    root._stateConfig = config;
  },
  getState(root: HTMLElement) {
    const ctx = (root as CanvasRoot)._animCanvas;
    // reflect reality: keyboard/clicks move the canvas without setState()
    return {
      name: root.dataset.stateName || 'default',
      config: {
        ...root._stateConfig,
        slide: ctx?.active.id,
        overview: root.hasAttribute('data-overview'),
      },
    };
  },
};
df$.animCanvasApi = animCanvasApi;
df$.animCanvasStates = animCanvasStates;
/**
 * Keyboard routing: the focused canvas answers first; otherwise the first
 * canvas intersecting the viewport (presentation's routing idea, simplified —
 * editable targets are already filtered by the shared listener). Unhandled
 * directions (no neighbor that way) are NOT swallowed, so arrow-key scrolling
 * keeps working at the board's edge.
 */
function pickCanvas(target: EventTarget | null): CanvasRoot | null {
  const focused = target instanceof HTMLElement ? (target.closest('.anim-canvas') as CanvasRoot | null) : null;
  if (focused) return focused;
  const all = Array.from(document.querySelectorAll<CanvasRoot>('.anim-canvas'));
  return (
    all.find((r) => {
      const b = r.getBoundingClientRect();
      return b.bottom > 0 && b.top < globalThis.innerHeight && b.right > 0 && b.left < globalThis.innerWidth;
    }) ??
    all[0] ??
    null
  );
}
let keysBound = false;
function bindKeys(): void {
  if (keysBound) return;
  keysBound = true;
  bindGlobalKeys((e) => {
    const key = e.key;
    const dir: AnimDirection | null =
      key === 'ArrowRight' ? 'east' : key === 'ArrowLeft' ? 'west' : key === 'ArrowDown' ? 'south' : key === 'ArrowUp' ? 'north' : null;
    const toggle = key === 'o' || key === 'O' || key === 'Escape';
    if (!dir && !toggle) return;
    const root = pickCanvas(e.target);
    const ctx = root?._animCanvas;
    if (!root || !ctx) return;
    if (dir && !ctx.active.dataset[dir]) return; // no neighbor - never swallow the key
    e.preventDefault();
    if (dir) void goTo(root, ctx, ctx.active.dataset[dir] as string);
    else toggleOverview(root, ctx);
    return true;
  });
}
function init(): void {
  document.querySelectorAll<CanvasRoot>('.anim-canvas:not([data-init])').forEach((root) => {
    root.dataset.init = '';
    // bind-scope the api per instance: `$('#board').api.setState('overview')`
    root.api = {
      setState: (stateName: string, config?: Record<string, unknown>) =>
        animCanvasApi.setState(root, stateName, config),
      getState: () => animCanvasApi.getState(root),
    };
    // the board: ONE transformed layer holding every slide. Reused when the
    // markup already carries one (the docs CodeExample round-trips serialized
    // live DOM back into the editor - init must stay idempotent for it).
    let board = root.querySelector<HTMLElement>(':scope > .anim-canvas-board');
    if (!board) {
      board = document.createElement('div');
      board.className = 'anim-canvas-board';
      for (const slide of Array.from(root.querySelectorAll<HTMLElement>(':scope > .anim-canvas-slide'))) {
        dfDollar(board).append(slide);
      }
      root.prepend(board);
    }
    const slides = dfDollar(board).find('.anim-canvas-slide') as HTMLElement[];
    if (slides.length === 0) return;
    // artboard contract: board units come from --anim-canvas-width/height
    const cs = getComputedStyle(root);
    const w = parseFloat(cs.getPropertyValue('--anim-canvas-width')) || 1280;
    const h = parseFloat(cs.getPropertyValue('--anim-canvas-height')) || 720;
    // gutter between cells (board units): the overview reads as separate
    // tiles, and a pan shows the seam between neighbors. 0 is allowed.
    const gapRaw = parseFloat(cs.getPropertyValue('--anim-canvas-gap'));
    const gap = Number.isFinite(gapRaw) && gapRaw >= 0 ? gapRaw : 80;
    // relation map → board positions by BFS from the start slide (0,0):
    // the authored data-active slide, else the first one. Dangling id refs
    // and conflicting positions fail LOUD (console.error), never silently.
    const byId = new Map<string, HTMLElement>();
    for (const s of slides) {
      if (!s.id) {
        console.error('anim-canvas: every .anim-canvas-slide needs an id - the data-east/west/north/south relation map references slides by id');
        continue;
      }
      byId.set(s.id, s);
    }
    const start = slides.find((s) => s.hasAttribute('data-active')) ?? slides[0];
    const pos = new Map<HTMLElement, SlidePos>([[start, { x: 0, y: 0 }]]);
    const queue: HTMLElement[] = [start];
    for (let qi = 0; qi < queue.length; qi++) {
      const cur = queue[qi];
      const p = pos.get(cur) as SlidePos;
      for (const dir of Object.keys(DIRS) as AnimDirection[]) {
        const ref = cur.dataset[dir];
        if (!ref) continue;
        const neighbor = byId.get(ref);
        if (!neighbor) {
          console.error(
            `anim-canvas: #${cur.id || '(unnamed)'} declares data-${dir}="${ref}" but no .anim-canvas-slide with id="${ref}" exists in this canvas - dangling id ref`,
          );
          continue;
        }
        const np: SlidePos = { x: p.x + DIRS[dir][0], y: p.y + DIRS[dir][1] };
        const existing = pos.get(neighbor);
        if (existing) {
          if (existing.x !== np.x || existing.y !== np.y) {
            console.error(
              `anim-canvas: conflicting position for #${ref} - reached as (${np.x},${np.y}) from #${cur.id}, already placed at (${existing.x},${existing.y}); the relation map must be consistent`,
            );
          }
          continue;
        }
        pos.set(neighbor, np);
        queue.push(neighbor);
      }
    }
    // slides unreachable from the start slide: loud error + a deterministic
    // fallback row past the board's east edge (never silently stacked at 0,0)
    const unreachable = slides.filter((s) => !pos.has(s));
    if (unreachable.length) {
      console.error(
        `anim-canvas: ${unreachable.map((s) => `#${s.id || '(unnamed)'}`).join(', ')} unreachable from #${start.id || '(the first slide)'} - wire them into the data-east/west/north/south relation map`,
      );
      let fx = Math.max(...Array.from(pos.values()).map((p) => p.x)) + 1;
      for (const s of unreachable) pos.set(s, { x: fx++, y: 0 });
    }
    // one cell per coordinate pair (a cycle wiring two slides onto the same
    // cell corrupts the board - say so)
    const taken = new Map<string, HTMLElement>();
    for (const [s, p] of pos) {
      const k = `${p.x},${p.y}`;
      const other = taken.get(k);
      if (other) {
        console.error(
          `anim-canvas: #${s.id || '(unnamed)'} and #${other.id || '(unnamed)'} both land on board cell (${k}) - the relation map must give every slide its own cell`,
        );
      } else taken.set(k, s);
    }
    // board layout: absolute cells in board units (px at scale 1)
    for (const [s, p] of pos) {
      s.style.left = `${p.x * (w + gap)}px`;
      s.style.top = `${p.y * (h + gap)}px`;
      s.style.width = `${w}px`;
      s.style.height = `${h}px`;
    }
    // declared animation names are validated up front - a markup typo must
    // fail loud at init, not mid-transition (the registry throws the same
    // way; this just moves the error to authoring time)
    for (const s of slides) {
      if (s.dataset.animIn) channelFor(s.dataset.animIn);
      // the leaving slide never animates - an authored out-animation would
      // be silently dead markup, so say so once at init
      if (s.dataset.animOut) {
        console.warn(`anim-canvas: #${s.id || '(unnamed)'} declares data-anim-out="${s.dataset.animOut}" - ignored: only the ARRIVING slide animates (declare its data-anim-in)`);
      }
    }
    const ctx: CanvasCtx = { board, slides, byId, pos, w, h, gap, active: start, busy: false, view: null, pan: null };
    root._animCanvas = ctx;
    // authored directional/overview chrome (optional): click delegation on the
    // root; in overview a click on a slide tile zooms back into it
    root.addEventListener('click', (e) => {
      const c = root._animCanvas;
      if (!c) return;
      const t = e.target as HTMLElement | null;
      const control = t?.closest?.('[data-anim-canvas-go]');
      if (control && root.contains(control)) {
        const dir = control.getAttribute('data-anim-canvas-go');
        if (dir === 'overview') toggleOverview(root, c);
        else {
          const id = c.active.dataset[dir as AnimDirection];
          if (id) void goTo(root, c, id);
        }
        return;
      }
      if (!root.hasAttribute('data-overview')) return;
      const tile = t?.closest?.('.anim-canvas-slide') as HTMLElement | null;
      if (tile && c.slides.includes(tile)) exitOverview(root, c, tile);
    });
    // uniform scale: board units → rendered viewport (ResizeObserver does the
    // math, never a window resize listener - same technique as presentation)
    const frame = (): void => {
      const c = root._animCanvas;
      if (!c) return;
      void panTo(root, c, root.hasAttribute('data-overview') ? overviewView(root, c) : focusView(root, c, c.active), false);
    };
    new ResizeObserver(frame).observe(root);
    activate(root, ctx, start);
    frame();
    // the start slide's counters run once on load (its entrances already
    // play from CSS as the page renders)
    start.querySelectorAll<HTMLElement>('[data-count]').forEach((el) => {
      animateCount(el);
    });
  });
}
bindKeys();
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub