# Flood tile (Notchset): prompt.md (v2.0.0)

- id: `flood-tile` · 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/flood-tile.json`
- npm dependencies: none
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json
- docs: https://notchset.dev/components/flood-tile
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A page's key figure on the Signature's gradient, grain on top, white type that passes AA, a fine arc or ruler drawn large; the standing rides on it as a white pill. One per page. With drift its light moves over about 52 s, paused when hidden or off screen and still under reduced motion..

## 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/flood-tile.tsx`; shared code: `lib/notchset/living.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: `FloodGrain`, `FloodTile`, `floodSurface`, 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-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-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-wash`, `--ns-font-display`, `--ns-radius-card`, `--ns-text-body`, `--ns-text-meta`, `--ns-text-small`, `--ns-weight-light`, `--ns-weight-medium`.
- 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 { FloodTile } from "@/components/ui/notchset/flood-tile";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/flood-tile.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)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import { FloodTile } from "@/components/ui/notchset/flood-tile";
import { StatusPill } from "@/components/ui/notchset/status-pill";

export default function FloodTileDemo() {
  return (
    <div className="grid w-full max-w-[720px] grid-cols-2 gap-4 max-[600px]:grid-cols-1">
      <FloodTile drift label="HRV · vs your usual" value="+6" unit="ms" pill={<StatusPill tone="ok">Above your usual</StatusPill>} figure={{ kind: "arc", value: 61, low: 47, high: 55 }} caption="Your usual 47–55 ms, last 21 nights" />
      <FloodTile label="LDL cholesterol · 16 Oct" value="128" unit="mg/dL" pill={<StatusPill tone="warn">Out of range</StatusPill>} figure={{ kind: "ruler", value: 128, low: 0, high: 99 }} caption="Northside Lab range under 99" />
    </div>
  );
}
```

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

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `label` | `React.ReactNode` |  | what the figure is: "Sleep score · last night" |
| `value` | `React.ReactNode` |  | Required. |
| `unit` | `React.ReactNode` |  |  |
| `pill` | `React.ReactNode` |  | the standing, as a StatusPill (drawn white on the tile) |
| `figure` | `Figure` |  | the line drawing under the figure: the value against its range, as an arc or a ruler |
| `caption` | `React.ReactNode` |  | one line under the drawing |
| `facts` | `FloodFact[]` |  | the reading's context as facts, a plain label over its value ("Your range" over "47–55 ms"), at most three; preferred over a caption sentence |
| `drift` | `boolean` |  | the page's one living light: the tile's light drifts slowly (one per page, never beside another living part) |

Full docs: https://notchset.dev/components/flood-tile

## 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/flood-tile.json
```

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

## Continue the workflow

- [Hue mesh](https://notchset.dev/components/hue-mesh)
- [Light field](https://notchset.dev/components/light-field)
- [Prism edge](https://notchset.dev/components/prism-edge)

## Source

### components/ui/notchset/flood-tile.tsx

```tsx
// Notchset flood-tile 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";

/*
 * Flood tile: a page's one key figure on a soft wash of the Signature (Amethyst: periwinkle washing to a lilac mist;
 * Graphite: slate-steel washing to a silver mist), in one of two skins: Aurora by day (a diagonal ribbon of light
 * across the wash) and Glass by night (a frosted pane with light refracted behind it), with grain on top. The
 * label and the figure sit white on the deep corner; the fine line drawing of our own (the standing arc or the
 * ruler, drawn large at a hairline) and the caption sit in the low ink on the pale side, each passing AA where it
 * sits. Identity colour, never standing: the standing rides on it as a white status pill (`pill`). One per page at
 * most, and never on a page that already has the light field's hero reading (Today). With `drift`, the tile is the
 * page's one living light: its light drifts over about 52 s (the ribbon by day, a sheen across the pane by night),
 * moved by transform and opacity on a layer drawn once, paused while the tab is hidden or the tile is off screen
 * (living.ts) and still under less motion.
 *
 *   <FloodTile label="HRV · vs your usual" value="+6" unit="ms" pill={<StatusPill tone="ok">Above your usual</StatusPill>}
 *     figure={{ kind: "arc", value: 61, low: 47, high: 55 }} caption="Your usual 47–55 ms, last 21 nights" />
 */
type Figure = { kind: "arc" | "ruler" | "dial"; value: number; low: number; high: number };
type FloodTileProps = {
  /** what the figure is: "Sleep score · last night" */
  label: React.ReactNode;
  value: React.ReactNode;
  unit?: React.ReactNode;
  /** the standing, as a StatusPill (drawn white on the tile) */
  pill?: React.ReactNode;
  /** the line drawing under the figure: the value against its range, as an arc or a ruler */
  figure?: Figure | undefined;
  /** one line under the drawing */
  caption?: React.ReactNode;
  /** the reading's context as facts, a plain label over its value ("Your range" over "47–55 ms"), at most three;
      preferred over a caption sentence */
  facts?: FloodFact[] | undefined;
  /** anything else the tile holds (counts, a link), in white */
  children?: React.ReactNode;
  /** the page's one living light: the tile's light drifts slowly (one per page, never beside another living part) */
  drift?: boolean | undefined;
  className?: string | undefined;
};

type FloodFact = { label: string; value: React.ReactNode };

const W = 520;

/** The tile's facts: label over value, split by hairlines, on one baseline. They sit low on the wash, where white
    fails AA, so both are the low ink (dark by day, white at night), the value in the medium weight. */
function Facts({ facts, center }: { facts: FloodFact[]; center?: boolean }) {
  return (
    <dl data-slot="flood-tile-facts" className={cn("m-0 flex flex-wrap gap-y-3", center && "justify-center")}>
      {facts.slice(0, 3).map((f, i) => (
        <div key={f.label} className={cn("flex min-w-0 flex-col gap-1", i > 0 ? "border-l border-[color-mix(in_oklab,var(--ns-flood-ink-low)_35%,transparent)] pl-4" : "", i < facts.length - 1 && "pr-4")}>
          <dt className="text-[length:var(--ns-text-meta)] leading-none text-[var(--ns-flood-ink-low)]">{f.label}</dt>
          <dd className="m-0 text-[length:var(--ns-text-body)] leading-none font-[number:var(--ns-weight-medium)] whitespace-nowrap text-[var(--ns-flood-ink-low)] tabular-nums">{f.value}</dd>
        </div>
      ))}
    </dl>
  );
}

/** The standing pill on a flood: white, its word in the flood's deep tone, its dot keeping the standing's colour. */
const pillOnFlood =
  "[&_[data-slot=status-pill]]:bg-[var(--ns-flood-ink)] [&_[data-slot=status-pill]]:text-[var(--ns-flood-pill-ink)] [&_[data-slot=status-pill][data-tone=neutral]]:before:bg-[var(--ns-flood-pill-ink)] [&_[data-slot=status-pill][data-tone=signal]]:before:bg-[var(--ns-flood-deep)]";

/**
 * The flood as a surface for a host's own object that is the page's key figure (Plan's current capsule): the wash
 * alone (no deep corner), read in the low ink throughout, its inks and lines turned to that ink so every word passes
 * AA on it. Spread `style` and add `className`; put `<FloodGrain />` first inside.
 */
const floodSurface = {
  className: cn("relative isolate overflow-hidden text-[var(--ns-flood-ink-low)] shadow-[var(--ns-flood-shadow)]", pillOnFlood),
  style: {
    backgroundImage: "var(--ns-flood-wash)",
    "--ns-ink": "var(--ns-flood-ink-low)",
    "--ns-ink-2": "color-mix(in srgb, var(--ns-flood-ink-low) 94%, transparent)",
    "--ns-ink-3": "color-mix(in srgb, var(--ns-flood-ink-low) 94%, transparent)",
    "--ns-line": "color-mix(in srgb, var(--ns-flood-ink-low) 22%, transparent)",
    "--ns-line-soft": "color-mix(in srgb, var(--ns-flood-ink-low) 14%, transparent)",
    "--ns-hover": "color-mix(in srgb, var(--ns-flood-ink-low) 7%, transparent)",
    "--ns-signal": "var(--ns-flood-ink-low)",
    color: "var(--ns-flood-ink-low)",
  } as React.CSSProperties,
};
/**
 * The flood's skin under its content: by day Aurora's ribbon of light runs diagonally across the wash, low and to the
 * right of the deep corner; by night Glass's two soft lights sit behind a frosted pane. The theme turns each on.
 */
function FloodSkin({ drift = false }: { drift?: boolean | undefined }) {
  const layer = "pointer-events-none absolute -z-10 block";
  return (
    <>
      <span aria-hidden data-flood-skin="glass" className={cn(layer, "top-[-16%] left-[-6%] h-[90%] w-[70%] rounded-full blur-[40px] [background:var(--ns-flood-behind-1)]")} style={{ opacity: "calc(var(--ns-flood-glass) * 0.75)" }} />
      <span aria-hidden className={cn(layer, "right-[-10%] bottom-[-20%] h-[80%] w-[60%] rounded-full blur-[40px] [background:var(--ns-flood-behind-2)]")} style={{ opacity: "calc(var(--ns-flood-glass) * 0.7)" }} />
      <span aria-hidden className={cn(layer, "inset-0 rounded-[inherit] backdrop-blur-[26px] [background:var(--ns-flood-pane)] [box-shadow:var(--ns-flood-pane-edge)]")} style={{ opacity: "var(--ns-flood-glass)" }} />
      {/* the light that drifts sits above the pane, so the pane's blur never redraws: one layer, drawn once, moved */}
      <span aria-hidden data-flood-drift={drift || undefined} data-ns-still="" className={cn(layer, "inset-0", drift && "animate-[ns-flood-drift_52s_ease-in-out_infinite] will-change-transform")}>
        <span data-flood-skin="aurora" className={cn(layer, "top-[40%] left-[-8%] h-[58%] w-[130%] -rotate-[24deg] blur-[22px] [background:var(--ns-flood-ribbon)] [mask-image:radial-gradient(closest-side,#000_35%,transparent_100%)]")} style={{ opacity: "var(--ns-flood-aurora)" }} />
        <span className={cn(layer, "top-[62%] left-[12%] h-[14%] w-[100%] -rotate-[24deg] blur-[12px] [background:var(--ns-flood-ribbon-core)] [mask-image:radial-gradient(closest-side,#000_20%,transparent_100%)]")} style={{ opacity: "var(--ns-flood-aurora)" }} />
        {/* by night, a sheen across the glass, low and right of the figure */}
        {drift && <span className={cn(layer, "top-[30%] left-[20%] h-[80%] w-[90%] [background:radial-gradient(closest-side,var(--ns-flood-bloom),transparent)]")} style={{ opacity: "var(--ns-flood-glass)" }} />}
      </span>
    </>
  );
}

/** The grain over the flood, so the colour reads as a material. */
function FloodGrain() {
  return <span aria-hidden className="pointer-events-none absolute inset-0 -z-10 opacity-60 [background-image:var(--ns-flood-grain)]" />;
}

/** The standing arc drawn large: a shallow horizon of hairline ticks, the usual as a firmer stroke, the value a ring. */
function Arc({ value, low, high }: Figure) {
  const H = 120, R = 560, CY = R + 28;
  const span = high - low || 1;
  const lo = Math.min(low - span * 1.2, value - span * 0.4), hi = Math.max(high + span * 1.2, value + span * 0.4);
  const SWEEP = (26 * Math.PI) / 180;
  const ang = (v: number) => -Math.PI / 2 - SWEEP + ((v - lo) / (hi - lo)) * SWEEP * 2;
  const pt = (a: number, r = R) => [W / 2 + Math.cos(a) * r, CY + Math.sin(a) * r] as const;
  const path = (a: number, b: number) => {
    const [x1, y1] = pt(a), [x2, y2] = pt(b);
    return `M${x1.toFixed(1)} ${y1.toFixed(1)} A ${R} ${R} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}`;
  };
  const [bx, by] = pt(ang(value));
  const N = 48;
  return (
    <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full overflow-visible" aria-hidden>
      {Array.from({ length: N + 1 }, (_, i) => {
        const a = -Math.PI / 2 - SWEEP + (i / N) * SWEEP * 2;
        const [x1, y1] = pt(a, R + 6), [x2, y2] = pt(a, R + (i % 6 === 0 ? 16 : 11));
        return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="currentColor" strokeOpacity={i % 6 === 0 ? 0.6 : 0.32} strokeWidth={0.75} />;
      })}
      <path d={path(-Math.PI / 2 - SWEEP, -Math.PI / 2 + SWEEP)} fill="none" stroke="currentColor" strokeOpacity={0.45} strokeWidth={0.75} />
      <path d={path(ang(low), ang(high))} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
      <circle cx={bx} cy={by} r={7} fill="none" stroke="currentColor" strokeWidth={1} />
      <circle cx={bx} cy={by} r={2.5} fill="currentColor" />
    </svg>
  );
}

/** The ruler drawn large: fine ticks across the tile, the range as a firmer bar, the value a tall mark. */
function RulerLine({ value, low, high }: Figure) {
  const H = 44, N = 80;
  const span = high - low || 1;
  const lo = Math.min(low - span * 0.9, value - span * 0.3), hi = Math.max(high + span * 0.9, value + span * 0.3);
  const x = (v: number) => ((v - lo) / (hi - lo)) * W;
  return (
    <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full overflow-visible" aria-hidden>
      {Array.from({ length: N + 1 }, (_, i) => (
        <line key={i} x1={(i / N) * W} x2={(i / N) * W} y1={8} y2={i % 10 === 0 ? 22 : 16} stroke="currentColor" strokeOpacity={i % 10 === 0 ? 0.6 : 0.3} strokeWidth={0.75} />
      ))}
      <line x1={x(low)} x2={x(high)} y1={30} y2={30} stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
      <line x1={x(value)} x2={x(value)} y1={2} y2={38} stroke="currentColor" strokeWidth={1} />
      <circle cx={x(value)} cy={30} r={3} fill="currentColor" />
    </svg>
  );
}

/**
 * The standing dial (a body page's lead reading): a near-full ring of fine ticks, the usual band as a brighter
 * segment, the reading's arc in a gradient within the Signature (never a standing colour), and a glowing bead at the
 * value; the figure and its pill sit in the centre. The bead moves only on change, with a spring, and holds still
 * under reduced motion.
 */
function Dial({ value, low, high, children }: Figure & { children: React.ReactNode }) {
  const S = 300, C = S / 2, R = 122, START = 120, SWEEP = 300;
  const span = high - low || 1;
  const lo = Math.min(low - span * 1.4, value - span * 0.4), hi = Math.max(high + span * 1.4, value + span * 0.4);
  const deg = (v: number) => START + ((Math.max(lo, Math.min(hi, v)) - lo) / (hi - lo)) * SWEEP;
  const pt = (d: number, r = R) => [C + Math.cos((d * Math.PI) / 180) * r, C + Math.sin((d * Math.PI) / 180) * r] as const;
  const arc = (a: number, b: number, r = R) => {
    const [x1, y1] = pt(a, r), [x2, y2] = pt(b, r);
    return `M${x1.toFixed(1)} ${y1.toFixed(1)} A ${r} ${r} 0 ${b - a > 180 ? 1 : 0} 1 ${x2.toFixed(1)} ${y2.toFixed(1)}`;
  };
  const id = `dial${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`;
  const N = 60;
  const at = deg(value);
  return (
    <div data-slot="flood-dial" className="relative mx-auto aspect-square w-full max-w-[300px]">
      <svg viewBox={`0 0 ${S} ${S}`} className="absolute inset-0 h-full w-full overflow-visible" aria-hidden>
        <defs>
          <linearGradient id={`${id}g`} x1="0" y1="1" x2="1" y2="0">
            <stop offset="0" stopColor="var(--ns-flood-deep)" />
            <stop offset="1" stopColor="var(--ns-flood-arc)" />
          </linearGradient>
          <filter id={`${id}b`} x="-100%" y="-100%" width="300%" height="300%">
            <feGaussianBlur stdDeviation="5" />
          </filter>
        </defs>
        {/* the ruler, bent into a ring: 0.5px ticks, every fifth longer */}
        {Array.from({ length: N + 1 }, (_, i) => {
          const d = START + (i / N) * SWEEP;
          const [x1, y1] = pt(d, R + 12), [x2, y2] = pt(d, R + (i % 5 === 0 ? 22 : 17));
          return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="currentColor" strokeOpacity={i % 5 === 0 ? 0.55 : 0.32} strokeWidth={0.5} />;
        })}
        <path d={arc(START, START + SWEEP)} fill="none" stroke="currentColor" strokeOpacity={0.14} strokeWidth={6} strokeLinecap="round" />
        <path d={arc(START, at)} fill="none" stroke={`url(#${id}g)`} strokeWidth={6} strokeLinecap="round" />
        {/* your usual: the brighter segment */}
        <path d={arc(deg(low), deg(high), R + 7)} fill="none" stroke="var(--ns-flood-ink)" strokeOpacity={0.95} strokeWidth={2} strokeLinecap="round" />
        <g data-ns-still="" className="origin-center [transition:transform_700ms_cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:[transition:none] [[data-motion=reduced]_&]:[transition:none]" style={{ transformOrigin: `${C}px ${C}px`, transform: `rotate(${at - START}deg)` }}>
          {(() => {
            const [bx, by] = pt(START);
            return (
              <>
                <circle cx={bx} cy={by} r={10} fill="var(--ns-flood-ink)" opacity={0.7} filter={`url(#${id}b)`} />
                <circle cx={bx} cy={by} r={6.5} fill="var(--ns-flood-ink)" />
                <circle cx={bx} cy={by} r={2.5} fill="var(--ns-flood-deep)" />
              </>
            );
          })()}
        </g>
      </svg>
      <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center">{children}</div>
    </div>
  );
}

function FloodTile({ label, value, unit, pill, figure, caption, facts, children, drift, className }: FloodTileProps) {
  const ref = React.useRef<HTMLElement>(null);
  useAwake(ref, !!drift);
  return (
    <section ref={ref} data-slot="flood-tile" data-drift={drift || undefined} data-figure={figure?.kind} className={cn("relative isolate flex min-w-0 flex-col gap-5 overflow-hidden rounded-[var(--ns-radius-card)] p-7 shadow-[var(--ns-flood-shadow)] max-[600px]:p-5", pillOnFlood, className)} style={{ backgroundImage: figure?.kind === "dial" ? "radial-gradient(42% 38% at 50% 50%, var(--ns-flood-dial-bloom), transparent 80%), var(--ns-flood-image)" : "var(--ns-flood-image)" }}>
      <FloodSkin drift={drift} />
      <FloodGrain />
      {figure?.kind === "dial" ? (
        <>
          <span className="text-[length:var(--ns-text-small)] text-[var(--ns-flood-ink)]">{label}</span>
          <div className="text-[var(--ns-flood-ink-low)]">
            <Dial {...figure}>
              <span className="flex items-end gap-1.5">
                <span data-slot="flood-tile-value" className="font-[family-name:var(--ns-font-display)] text-[clamp(52px,5vw,72px)] leading-[0.85] font-[number:var(--ns-weight-light)] tracking-[-0.05em] tabular-nums">
                  {value}
                </span>
                {unit != null && <span className="pb-1 text-[length:var(--ns-text-body)]">{unit}</span>}
              </span>
              {pill}
            </Dial>
          </div>
          {facts?.length ? <Facts facts={facts} center /> : caption != null && <span className="text-center text-[length:var(--ns-text-small)] text-[var(--ns-flood-ink-low)]">{caption}</span>}
          {children}
        </>
      ) : (
        <>
      {/* the deep corner: white type, AA there */}
      <div className="flex flex-col gap-5 text-[var(--ns-flood-ink)]">
        <span className="text-[length:var(--ns-text-small)]">{label}</span>
        <span className="flex flex-wrap items-end gap-x-3 gap-y-2">
          <span data-slot="flood-tile-value" className="font-[family-name:var(--ns-font-display)] text-[clamp(64px,6.4vw,96px)] leading-[0.82] font-[number:var(--ns-weight-light)] tracking-[-0.05em] tabular-nums">
            {value}
          </span>
          {/* 24px, so the unit reads as large text beside the figure (3:1) where the wash starts to lighten */}
          {unit != null && <span className="pb-1 text-[24px] leading-none font-[number:var(--ns-weight-light)]">{unit}</span>}
        </span>
        {pill && <span className="flex flex-wrap items-center gap-2">{pill}</span>}
      </div>
      {/* the pale side: the drawing and the caption in the low ink */}
      {(figure || caption != null || facts?.length || children) && (
        <div className="mt-auto flex flex-col gap-3 pt-3 text-[var(--ns-flood-ink-low)]">
          {figure && (figure.kind === "arc" ? <Arc {...figure} /> : figure.kind === "ruler" ? <RulerLine {...figure} /> : null)}
          {facts?.length ? <Facts facts={facts} /> : caption != null && <span className="text-[length:var(--ns-text-small)]">{caption}</span>}
          {children}
        </div>
      )}
        </>
      )}
    </section>
  );
}

export { type FloodFact, FloodGrain, FloodTile, floodSurface, type FloodTileProps };
```

### lib/notchset/living.ts

```tsx
// Notchset flood-tile 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;
}
```

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.
