# Prism edge (Notchset): prompt.md (v2.0.0)

- id: `prism-edge` · version 2.0.0 · component · free
- category: Data
- build: one set of files for every shadcn style
- install: `npx shadcn@latest add https://notchset.dev/r/prism-edge.json`
- npm dependencies: none
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json
- docs: https://notchset.dev/components/prism-edge
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A hairline of the Signature turning through its light round a glass card, with a whisper of spectral tint at one corner, all code. When something new arrives it sweeps once in about 1.2 s and rests; never on a mount, and under reduced motion the edge simply shows. Used once per app..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: none beyond React.
- Files: `components/ui/notchset/prism-edge.tsx`; shared code: `lib/notchset/living.ts`, `lib/notchset/physics.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `PrismEdge`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-beat`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-radius-card`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { Button } from "@/components/ui/notchset/button";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/prism-edge.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when

### Not when

## Built from
- Living (shared helper, ships with it)
- Physics (shared helper, ships with it)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/notchset/button";
import { useArrival } from "@/lib/notchset/living";
import { PrismEdge } from "@/components/ui/notchset/prism-edge";

export default function PrismEdgeDemo() {
  /* a new id is new results: the edge sweeps once, then rests */
  const [n, setN] = React.useState(1);
  const [sweep, swept] = useArrival(`set-${n}`);
  return (
    <section className="relative isolate flex w-full max-w-[380px] flex-col gap-3 rounded-[var(--ns-radius-card)] bg-[var(--ns-glass)] p-6 [box-shadow:var(--ns-lit-edge),var(--ns-glass-shadow)] backdrop-blur-[var(--ns-glass-blur)]">
      <PrismEdge sweep={sweep} onSwept={swept} />
      <span className="text-[length:var(--ns-text-small)] text-[var(--ns-ink-2)]">Lipid panel · Northside Lab</span>
      <p className="m-0 font-[family-name:var(--ns-font-display)] text-[19px] leading-[1.3] text-[var(--ns-ink)]">5 results in: 4 read cleanly, 1 to check.</p>
      <Button className="self-start" onClick={() => setN((x) => x + 1)}>
        New results
      </Button>
    </section>
  );
}
```

This example also uses `button` (https://notchset.dev/components/button). Installing this item doesn't bring them; to paste the example as it is: `npx shadcn@latest add https://notchset.dev/r/prism-edge.json https://notchset.dev/r/button.json`.

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `sweep` | `boolean` | `false` | play the one sweep (new results landed) |
| `onSwept` | `() => void` |  | the sweep ended (or, under less motion, wasn't played) |

Full docs: https://notchset.dev/components/prism-edge

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/prism-edge.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/prism-edge`. Every components.json style gets the same files.

## Continue the workflow

- [Flood tile](https://notchset.dev/components/flood-tile)
- [Hue mesh](https://notchset.dev/components/hue-mesh)
- [Light field](https://notchset.dev/components/light-field)

## Source

### components/ui/notchset/prism-edge.tsx

```tsx
// Notchset prism-edge 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useAwake } from "@/lib/notchset/living";
import { useLessMotion } from "@/lib/notchset/physics";

/*
 * Prism edge: frosted glass's one moment. A hairline of the Signature turning through its light (deep to arc to
 * white, `--ns-prism-edge`) round the card, and a whisper of spectral tint at its top right corner
 * (`--ns-prism-tint`), all code. Used once in an app: the card where new results arrive (Health: Today's test card).
 * When `sweep` turns on (new results landed, living.ts `useArrival`), one band of light runs along the edge in about
 * 1.2 s, then `onSwept` and the edge rests; never on a mount or a re-render. Off screen or in a hidden tab the sweep
 * waits, so it plays where it is seen; under less motion the edge simply shows (and `onSwept` comes at once).
 * Put it first inside a `relative isolate overflow-hidden` host with rounded corners; it inherits the radius.
 *
 *   <section className="relative isolate overflow-hidden rounded-[var(--ns-radius-card)] …">
 *     <PrismEdge sweep={arrived} /> …
 *   </section>
 */
/** the hairline: only the 1px ring of the layer shows (the content box is masked out) */
const ring = "pointer-events-none absolute inset-0 rounded-[inherit] p-px [mask:linear-gradient(#000_0_0)_content-box_exclude,linear-gradient(#000_0_0)]";

type PrismEdgeProps = {
  /** play the one sweep (new results landed) */
  sweep?: boolean | undefined;
  /** the sweep ended (or, under less motion, wasn't played) */
  onSwept?: (() => void) | undefined;
  className?: string | undefined;
};

function PrismEdge({ sweep = false, onSwept, className }: PrismEdgeProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const less = useLessMotion();
  /* the sweep waits until most of the card is seen */
  useAwake(ref, sweep, 0.6);
  React.useEffect(() => {
    if (sweep && less) onSwept?.();
  }, [sweep, less, onSwept]);
  return (
    <span ref={ref} aria-hidden data-slot="prism-edge" data-sweep={sweep || undefined} className={cn("pointer-events-none absolute inset-0 -z-10 rounded-[inherit]", className)}>
      <span className="absolute inset-0 rounded-[inherit] [background:var(--ns-prism-tint)]" />
      <span className={cn(ring, "opacity-60 [background:var(--ns-prism-edge)]")} />
      {sweep && (
        <>
          {/* the light crossing the glass: a faint sheen over the body, and the band along the edge */}
          <span className="absolute inset-0 overflow-hidden rounded-[inherit]">
            <span data-ns-still="" className="absolute inset-0 animate-[ns-prism-sweep_1.2s_cubic-bezier(0.45,0,0.2,1)_both] opacity-0 [background:var(--ns-prism-glint)] will-change-transform" />
          </span>
          <span className={cn(ring, "z-10 overflow-hidden p-[1.5px]")}>
            <span data-ns-still="" onAnimationEnd={onSwept} className="absolute inset-0 animate-[ns-prism-sweep_1.2s_cubic-bezier(0.45,0,0.2,1)_both] opacity-0 [background:var(--ns-prism-sheen)] will-change-transform" />
          </span>
        </>
      )}
    </span>
  );
}

export { PrismEdge, type PrismEdgeProps };
```

### lib/notchset/living.ts

```tsx
// Notchset prism-edge 2.0.0 · https://notchset.dev/changelog
"use client";
/*
 * Living light: the few things in an app that move at rest, and the rules they share (LANGUAGE.md, "Motion that
 * lives"). Motion that lives is earned by data (a heartbeat at the live heart rate) or by the page's one light (the
 * flood tile's drift, the light field), never more than one per page, and a moment that lands (new results) plays
 * once. Plain hooks and functions, no markup; the keyframes live in foundation.css and move transform and opacity
 * only, so the compositor runs them without layout or paint.
 *
 * - Asleep when no one can see it: `useAwake` marks the element `data-ns-asleep` while the tab is hidden or the
 *   element is off screen, and foundation.css pauses every animation under that mark.
 * - Still under less motion: the moving parts carry `data-ns-still`, so the app's switch and the device's both
 *   hold them (foundation.css).
 */
import * as React from "react";

/** The slowest and fastest heart rates a pulse follows: a beat outside them is drawn at the edge. */
export const BEAT_BPM = [40, 190] as const;

/**
 * One heartbeat in seconds at a heart rate (60 / bpm), clamped to 40–190 bpm and rounded to 2 bpm so a reading
 * that wobbles by one doesn't restart the beat. No heart rate, no beat (null).
 */
export function beatPeriod(bpm: number | null | undefined): number | null {
  if (bpm == null || !Number.isFinite(bpm) || bpm <= 0) return null;
  const b = Math.max(BEAT_BPM[0], Math.min(BEAT_BPM[1], Math.round(bpm / 2) * 2));
  return Math.round((60 / b) * 1000) / 1000;
}

/* the heartbeat's classes, written whole so Tailwind keeps their keyframes; the period comes from beatStyle */
/** a halo that swells and fades once a beat (the live position) */
export const BEAT_HALO = "animate-[ns-beat_var(--ns-beat)_cubic-bezier(0.2,0.7,0.3,1)_infinite] [transform-origin:center]";
/** a mark that brightens once a beat (the current zone in a heart-rate band) */
export const BEAT_SOFT = "animate-[ns-beat-soft_var(--ns-beat)_cubic-bezier(0.2,0.7,0.3,1)_infinite] [transform-origin:center]";

/** The heartbeat's period for BEAT_HALO or BEAT_SOFT (`--ns-beat`), or none when there's no beat. */
export function beatStyle(bpm: number | null | undefined): React.CSSProperties | undefined {
  const s = beatPeriod(bpm);
  return s == null ? undefined : ({ "--ns-beat": `${s}s` } as React.CSSProperties);
}

/**
 * Marks the element `data-ns-asleep` while the tab is hidden or the element is off screen, so its animations pause
 * (foundation.css). `seenAt` is how much of it must show to count as seen (0, any of it; the prismatic sweep waits
 * for most of its card). Without an IntersectionObserver (a test) it counts as on screen. It changes the attribute
 * only, so scrolling never re-renders the part.
 */
export function useAwake(el: React.RefObject<Element | null>, on = true, seenAt = 0): void {
  React.useEffect(() => {
    const node = el.current;
    if (!node || !on) return;
    let seen = true;
    const apply = () => {
      const a = seen && !document.hidden;
      node.toggleAttribute("data-ns-asleep", !a);
    };
    const io =
      typeof IntersectionObserver === "undefined"
        ? null
        : new IntersectionObserver(
            (es) => {
              seen = es.some((e) => e.isIntersecting && (!seenAt || e.intersectionRatio >= seenAt));
              apply();
            },
            { threshold: seenAt ? [0, seenAt] : 0 },
          );
    io?.observe(node);
    document.addEventListener("visibilitychange", apply);
    apply();
    return () => {
      io?.disconnect();
      document.removeEventListener("visibilitychange", apply);
      node.removeAttribute("data-ns-asleep");
    };
  }, [el, on, seenAt]);
}

/** How long a moment that lands plays (the prismatic edge's one sweep), in ms. */
export const ARRIVAL_MS = 1200;

const readSeen = (k: string) => {
  try {
    return localStorage.getItem(k);
  } catch {
    return null;
  }
};
const writeSeen = (k: string, v: string) => {
  try {
    localStorage.setItem(k, v);
  } catch {}
};

/**
 * A moment that lands once: `[playing, done]`. Playing turns on when `key` (what arrived, a result set's id) is new,
 * and off when the part calls `done` (its animation ended, or there was none to play). New means it changed while
 * the part is mounted, or it differs from the one this device last saw (kept under `remember`, when given). A first
 * sight with nothing remembered, a re-render and a remount of the same key never play it.
 */
export function useArrival(key: string | null | undefined, remember?: string | undefined): readonly [boolean, () => void] {
  const [playing, setPlaying] = React.useState(false);
  const last = React.useRef<string | null | undefined>(undefined);
  React.useEffect(() => {
    if (key == null) return;
    const before = last.current === undefined ? (remember ? readSeen(remember) : null) : last.current;
    last.current = key;
    if (remember) writeSeen(remember, key);
    if (before != null && before !== key) setPlaying(true);
  }, [key, remember]);
  const done = React.useCallback(() => setPlaying(false), []);
  return [playing, done] as const;
}
```

### lib/notchset/physics.ts

```tsx
// Notchset prism-edge 2.0.0 · https://notchset.dev/changelog
"use client";
/*
 * Physics: what every showcase piece moves by (SHOWCASE-RULES.md, "Real physics"), so a spring settles the same at 60
 * and at 120 Hz and only one piece on a screen is ever running. Plain hooks and functions, no markup.
 *
 * - Time-based: a frame gets the seconds since the last one (capped, so a hidden tab never lands as one huge step),
 *   and simulations that collide take fixed steps of 1/120 s from an accumulator.
 * - One running loop per viewport: the latest piece that asked and is on screen moves; every other one shows its
 *   still until it is the latest again.
 * - Less motion, from the app (data-motion="reduced") or the device, which always wins: a piece shows its still.
 * - Self-play hands over for good at the first pointer press, key or wheel inside the piece.
 */
import * as React from "react";

/** Less motion, asked by the device or by the app on the root. */
export function lessMotion(): boolean {
  if (typeof document === "undefined") return true;
  return document.documentElement.dataset.motion === "reduced" || (typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches);
}

/** Less motion, kept current as the device's setting or the root's data-motion changes. */
export function useLessMotion(): boolean {
  /* read at once in the browser, so a piece never starts in its still and then jumps; true on a server */
  const [less, setLess] = React.useState(() => lessMotion());
  React.useEffect(() => {
    const read = () => setLess(lessMotion());
    read();
    const q = typeof matchMedia !== "undefined" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
    q?.addEventListener("change", read);
    const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(read) : null;
    mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"] });
    return () => {
      q?.removeEventListener("change", read);
      mo?.disconnect();
    };
  }, []);
  return less;
}

/** Whether the element is on screen (without an IntersectionObserver, as in a test, it counts as seen). */
export function useSeen(el: React.RefObject<Element | null>): boolean {
  const [seen, setSeen] = React.useState(typeof IntersectionObserver === "undefined");
  React.useEffect(() => {
    const node = el.current;
    if (!node || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver((es) => setSeen(es.some((e) => e.isIntersecting)), { threshold: 0.15 });
    io.observe(node);
    const vis = () => document.hidden && setSeen(false);
    document.addEventListener("visibilitychange", vis);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", vis);
    };
  }, [el]);
  return seen;
}

/* ---------- one running loop per viewport ---------- */
const askers: string[] = [];
const listeners = new Set<() => void>();
let ownerNow: string | null = null;
function publish() {
  ownerNow = askers[askers.length - 1] ?? null;
  for (const l of listeners) l();
}
function subscribe(l: () => void) {
  listeners.add(l);
  return () => listeners.delete(l);
}

/**
 * Whether this piece may run its loop now: it asks while `wants` (on screen, motion allowed, something to play), and
 * the latest that asked runs; when it stops asking, the one before it takes over.
 */
export function useLoopTurn(wants: boolean): boolean {
  const id = React.useId();
  React.useEffect(() => {
    if (!wants) return;
    askers.push(id);
    publish();
    return () => {
      const i = askers.lastIndexOf(id);
      if (i >= 0) askers.splice(i, 1);
      publish();
    };
  }, [id, wants]);
  const owner = React.useSyncExternalStore(subscribe, () => ownerNow, () => null);
  return wants && owner === id;
}

/** Calls `frame(dt, now)` every animation frame while `on`, with dt in seconds (at most 1/20 s); paused in a hidden tab. */
export function useFrame(frame: (dt: number, now: number) => void, on: boolean): void {
  const cb = React.useRef(frame);
  React.useEffect(() => {
    cb.current = frame;
  });
  React.useEffect(() => {
    if (!on) return;
    let raf = 0, last = performance.now();
    const tick = (now: number) => {
      const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
      last = now;
      if (!document.hidden) cb.current(dt, now);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [on]);
}

/** A damped spring's state: where it is and how fast it is going. */
export type SpringState = { x: number; v: number };

/**
 * One step of a damped spring toward `target` (semi-implicit Euler, stable at a frame's dt): `stiffness` pulls, and
 * `damping` is the share of critical damping (1 settles without overshoot, under 1 overshoots and rings).
 */
export function springStep(s: SpringState, target: number, stiffness: number, damping: number, dt: number): SpringState {
  const c = 2 * Math.sqrt(stiffness) * damping;
  const v = s.v + (stiffness * (target - s.x) - c * s.v) * dt;
  return { x: s.x + v * dt, v };
}

/** Whether a spring has come to rest at its target. */
export function springAtRest(s: SpringState, target: number, eps = 0.0005): boolean {
  return Math.abs(s.x - target) < eps && Math.abs(s.v) < eps * 10;
}

/** Runs `step(h)` in fixed steps of `h` seconds for the time that passed; returns the time left over for next frame. */
export function fixedSteps(carry: number, dt: number, step: (h: number) => void, h = 1 / 120, max = 12): number {
  let acc = carry + dt, n = 0;
  while (acc >= h && n < max) {
    step(h);
    acc -= h;
    n++;
  }
  return n >= max ? 0 : acc;
}

/**
 * Self-play until the first touch: `mine` turns true for good at the first pointer press, key or wheel inside the
 * root, so every queued step can be dropped and the visitor holds it from there. Bind `handOver` to the root's events.
 */
export function useHandover(start = false): [boolean, () => void] {
  const [mine, setMine] = React.useState(start);
  const handOver = React.useCallback(() => setMine(true), []);
  return [mine, handOver];
}

/** A ghost cursor's script: where it is (0–1 of the stage) at each moment (ms), and whether it presses. */
export type GhostKey = { t: number; x: number; y: number; down?: boolean | undefined };

/** Where the ghost is at `t` ms: eased between its keys (a hand's narration, not physics), pressing as its key says. */
export function ghostAt(script: GhostKey[], t: number): { x: number; y: number; down: boolean; done: boolean } {
  const last = script[script.length - 1]!;
  if (t >= last.t) return { x: last.x, y: last.y, down: !!last.down, done: true };
  let i = 0;
  while (i < script.length - 1 && script[i + 1]!.t <= t) i++;
  const a = script[i]!, b = script[i + 1] ?? a;
  const u = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
  const e = u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
  return { x: a.x + (b.x - a.x) * e, y: a.y + (b.y - a.y) * e, down: !!a.down, done: false };
}

/** A colour role read from the page (for a canvas, which can't read a CSS variable), as an [r, g, b, a] in 0–255. */
export function readColor(el: Element, role: string): [number, number, number, number] {
  const probe = document.createElement("i");
  probe.style.cssText = `position:absolute;visibility:hidden;color:var(${role})`;
  el.appendChild(probe);
  const c = getComputedStyle(probe).color;
  probe.remove();
  /* whatever space the browser serialises it in (rgb, color(srgb …), oklab …), a pixel says it in sRGB */
  try {
    const cv = document.createElement("canvas");
    cv.width = cv.height = 1;
    const g = cv.getContext("2d", { willReadFrequently: true });
    if (g) {
      g.clearRect(0, 0, 1, 1);
      g.fillStyle = c;
      g.fillRect(0, 0, 1, 1);
      const d = g.getImageData(0, 0, 1, 1).data;
      return [d[0]!, d[1]!, d[2]!, d[3]!];
    }
  } catch {}
  const m = c.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0, 1];
  return [m[0] ?? 0, m[1] ?? 0, m[2] ?? 0, (m[3] ?? 1) * 255];
}

/** Mixes two [r, g, b, a] colours by t (0–1). */
export function mixColor(a: readonly number[], b: readonly number[], t: number): string {
  const k = Math.max(0, Math.min(1, t));
  const ch = (i: number) => a[i]! + (b[i]! - a[i]!) * k;
  return `rgba(${Math.round(ch(0))},${Math.round(ch(1))},${Math.round(ch(2))},${(ch(3) / 255).toFixed(3)})`;
}

/** The width an element is given, kept current (a callback ref, so a replaced element is followed). */
export function useWidth<T extends HTMLElement>(fallback = 520): [React.RefCallback<T>, number] {
  const [el, setEl] = React.useState<T | null>(null);
  const [w, setW] = React.useState(fallback);
  React.useEffect(() => {
    if (!el) return;
    const read = () => setW(Math.max(200, Math.round(el.clientWidth || fallback)));
    read();
    if (typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(read);
    ro.observe(el);
    return () => ro.disconnect();
  }, [el, fallback]);
  return [setEl, w];
}

/**
 * A copy of a simulation's state for drawing: the frame loop changes the live state (a ref) in place, and the piece
 * draws from the copy it last painted, so rendering never reads a ref. Plain objects, arrays and typed arrays.
 */
export function snapshot<T>(x: T): T {
  if (Array.isArray(x)) return x.map(snapshot) as T;
  if (ArrayBuffer.isView(x)) return (x as unknown as Float32Array).slice() as unknown as T;
  if (x && typeof x === "object") {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(x)) out[k] = snapshot(v);
    return out as T;
  }
  return x;
}
```

The foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://notchset.dev/r/notchset-foundation.json.
