# Range bar (Notchset): prompt.md (v2.0.0)

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

A value against a named range, never bare: the lab's band with its limits written, an optimal band named by its source, the value with its halo, the previous value as a tick and the word (Low, In range, High). Out of range is warn; a lab flag or a value that may be wrong is bad. Three sizes..

## 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/range-bar.tsx`; shared code: `lib/notchset/physics.ts`, `lib/notchset/standing.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `rolling-number`, `status-pill`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `RangeBar`, `rangeDomain`, 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-bad`, `--ns-band`, `--ns-band-edge`, `--ns-band-optimal`, `--ns-font-display`, `--ns-font-mono`, `--ns-glow`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-leading`, `--ns-line`, `--ns-ok-ink`, `--ns-panel`, `--ns-radius-full`, `--ns-series`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-text-body`, `--ns-text-meta`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-tracking-display`, `--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 { RangeBar } from "@/components/ui/notchset/range-bar";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/range-bar.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
- rolling-number (`rolling-number`), installed with it: reuse it, don't rebuild it
- status-pill (`status-pill`), installed with it: reuse it, don't rebuild it
- Physics (shared helper, ships with it)
- Standing (shared helper, ships with it)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import { RangeBar } from "@/components/ui/notchset/range-bar";

export default function RangeBarDemo() {
  return (
    <div className="flex max-w-[520px] flex-col gap-8">
      <RangeBar
        name="Vitamin D"
        value={41}
        unit="ng/mL"
        range={{ low: 30, high: 100, kind: "lab", source: "Northside Lab" }}
        optimal={{ low: 40, high: 60, kind: "optimal", source: "Your clinic's optimal set" }}
        previous={{ value: 23, label: "25 Mar" }}
        measured="Northside Lab · 16 Oct"
      >
        Back in range since March, when it was low.
      </RangeBar>
      <div className="grid grid-cols-[120px_48px_minmax(0,1fr)] items-center gap-3 text-[13px]">
        <span>LDL cholesterol</span>
        <span className="text-right tabular-nums">128</span>
        <RangeBar size="row" name="LDL cholesterol" value={128} unit="mg/dL" range={{ high: 99, kind: "lab", source: "Northside Lab" }} previous={{ value: 143, label: "25 Mar" }} />
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `name` | `string` |  | what's measured: "Vitamin D" |
| `value` | `number \| null` |  | the value; null when there's no result yet |
| `unit` | `string` |  | Required. |
| `range` | `ValueRange` |  | the range it's read against: the lab's printed reference range, or your baseline |
| `optimal` | `ValueRange` |  | an optimal range inside it, with its source |
| `previous` | `{ value: number; label: string }` |  | the previous value, drawn as an ink tick with the way it moved |
| `flag` | `ValueFlag` |  |  |
| `measured` | `string` |  | when and where it was measured: "Lab · 16 Oct", "Ring · synced 7:02" |
| `size` | `"full" \| "card" \| "row"` | `"full"` |  |
| `format` | `(n: number) => string` | `(n) => formatReading(n)` | how a value is written (default: one decimal under 10) |

Full docs: https://notchset.dev/components/range-bar

## 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/range-bar.json
```

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

## Continue the workflow

- [Result list](https://notchset.dev/blocks/result-list)
- [Import review](https://notchset.dev/blocks/import-review)
- [Result summary](https://notchset.dev/blocks/result-summary)

## Source

### components/ui/notchset/range-bar.tsx

```tsx
// Notchset range-bar 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { springStep, useFrame, useLessMotion, type SpringState } from "@/lib/notchset/physics";
import { RollingNumber } from "@/components/ui/notchset/rolling-number";
import { StatusPill } from "@/components/ui/notchset/status-pill";
import { flagWord, formatReading, gradeOf, gradeTone, gradeWord, rangeName, rangeText, standingOf, standingTone, standingWord, type ValueFlag, type ValueRange } from "@/lib/notchset/standing";

const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

/*
 * Range bar: where one value stands, never a bare number. The range is a neutral band with its two limits written
 * (an open end fades out: "< 100" has no floor), an optimal range inside it is a soft tint named with its source,
 * the value is the series dot with its halo, the previous value an ink tick joined to it by a dotted line, and the
 * word for where it stands sits beside it: three standings against a lab's range, Optimal (inside the optimal range,
 * or the lab's where no optimal one is named), Normal (inside the lab's, outside the optimal) and Out of range; Your
 * usual for a baseline. Out of range is said in
 * the warn tone; only the lab's critical flag or a value that may be wrong is said in the bad tone, and nothing is
 * concluded from a value that may be wrong (its dot is hollow).
 *
 *   <RangeBar name="Vitamin D" value={41} unit="ng/mL" range={{ low: 30, high: 100, kind: "lab", source: "Northside Lab" }}
 *     optimal={{ low: 50, high: 80, kind: "optimal", source: "Endocrine Society 2024" }} previous={{ value: 23, label: "14 Mar" }} measured="Lab · 16 Oct" />
 *
 * Three sizes: `full` (a record: the value at the display size, the bar, every limit and source written), `card`
 * (the value's bar with its limits, for a card) and `row` (the bar alone, for a list row; it still says everything to
 * a screen reader). The dot springs to its place only when the value changes after it's on screen (a correction, a
 * new panel), never on arrival; with less motion it lands at once.
 */
type RangeBarProps = {
  /** what's measured: "Vitamin D" */
  name: string;
  /** the value; null when there's no result yet */
  value: number | null;
  unit: string;
  /** the range it's read against: the lab's printed reference range, or your baseline */
  range: ValueRange;
  /** an optimal range inside it, with its source */
  optimal?: ValueRange | undefined;
  /** the previous value, drawn as an ink tick with the way it moved */
  previous?: { value: number; label: string } | undefined;
  flag?: ValueFlag;
  /** when and where it was measured: "Lab · 16 Oct", "Ring · synced 7:02" */
  measured?: string | undefined;
  size?: "full" | "card" | "row" | undefined;
  /** how a value is written (default: one decimal under 10) */
  format?: ((n: number) => string) | undefined;
  /** the sentence of meaning under a full bar */
  children?: React.ReactNode;
  className?: string | undefined;
};

/** The stretch of the axis a bar draws: every limit, the value and the previous one, with room at an open end. */
export function rangeDomain(range: Pick<ValueRange, "low" | "high">, ...more: (number | null | undefined)[]): [number, number] {
  const pts = [range.low, range.high, ...more].filter((n): n is number => n != null && Number.isFinite(n));
  if (!pts.length) return [0, 1];
  let lo = Math.min(...pts), hi = Math.max(...pts);
  const span = hi - lo || Math.abs(hi) || 1;
  lo = range.low == null ? Math.min(lo, 0) : lo - span * 0.28;
  hi = range.high == null ? hi + span * 0.45 : hi + span * 0.28;
  if (Math.min(...pts) >= 0) lo = Math.max(0, lo);
  return [lo, hi];
}

function RangeBar({ name, value, unit, range, optimal, previous, flag, measured, size = "full", format = (n) => formatReading(n), children, className }: RangeBarProps) {
  /* a value that may be wrong never stretches the scale: it sits past the edge, and the scale stays the range's */
  const [lo, hi] = rangeDomain(range, flag === "may-be-wrong" ? null : value, previous?.value, optimal?.low, optimal?.high);
  const pct = (v: number) => Math.max(1.5, Math.min(98.5, ((v - lo) / (hi - lo || 1)) * 100));
  const standing = standingOf(value, range);
  /* three standings against a lab's range (Optimal, Normal, Out of range); a baseline says "Your usual" */
  const grade = gradeOf(value, range, optimal);
  const tone = range.kind === "lab" ? gradeTone(grade, flag) : standingTone(standing, flag, range.kind);
  const word = flagWord(flag) ?? (range.kind === "lab" ? gradeWord(grade) : standingWord(standing, range.kind));
  const target = value == null ? null : pct(value);
  const unitText = unit ? ` ${unit}` : "";
  const said = [
    `${name} ${value == null ? "no result yet" : `${format(value)}${unitText}`}`,
    value == null ? null : word,
    `${rangeName(range.kind)} ${rangeText(range, format)}${unitText}${range.kind === "lab" ? `, from ${range.source}` : ""}`,
    optimal ? `optimal ${rangeText(optimal, format)}${unitText}, from ${optimal.source}` : null,
    previous ? `previous ${format(previous.value)} on ${previous.label}` : null,
    measured ? `measured ${measured}` : null,
  ].filter(Boolean).join("; ");

  /* the dot springs to a new value you caused (a correction, a new panel); on arrival it is simply there */
  const less = useLessMotion();
  const dot = React.useRef<HTMLElement>(null);
  const spring = React.useRef<SpringState>({ x: target ?? 0, v: 0 });
  const [moving, setMoving] = React.useState(false);
  const shown = React.useRef(target);
  /* before the new value paints, the dot is put back where it was, so the spring carries it from there */
  useIsomorphicLayoutEffect(() => {
    if (target == null || shown.current === target) return;
    const from = shown.current;
    shown.current = target;
    if (less || from == null) {
      spring.current = { x: target, v: 0 };
      return;
    }
    if (dot.current) dot.current.style.left = `${spring.current.x}%`;
    queueMicrotask(() => setMoving(true));
  }, [target, less]);
  useFrame((dt) => {
    if (target == null) return;
    const s = (spring.current = springStep(spring.current, target, 170, 0.62, dt));
    if (dot.current) dot.current.style.left = `${s.x}%`;
    if (Math.abs(s.x - target) < 0.02 && Math.abs(s.v) < 0.2) {
      spring.current = { x: target, v: 0 };
      if (dot.current) dot.current.style.left = `${target}%`;
      setMoving(false);
    }
  }, moving && !less);

  const bandFrom = range.low == null ? 0 : pct(range.low);
  const bandTo = range.high == null ? 100 : pct(range.high);
  const row = size === "row";
  const trackH = row ? "h-[6px]" : "h-[10px]";
  const bar = (
    <div data-slot="range-bar-track" role="img" aria-label={said} className={cn("relative w-full", row ? "h-[14px]" : "h-[22px]")}>
      <i aria-hidden className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-[var(--ns-line)]" />
      <i
        aria-hidden
        data-slot="range-bar-band"
        className={cn("absolute top-1/2 -translate-y-1/2 rounded-[var(--ns-radius-full)] bg-[var(--ns-band)]", trackH)}
        style={{
          left: `${bandFrom}%`,
          width: `${bandTo - bandFrom}%`,
          /* an open end fades away: there is no limit on that side */
          maskImage: range.low == null ? "linear-gradient(to right, transparent, #000 38%)" : range.high == null ? "linear-gradient(to left, transparent, #000 38%)" : undefined,
        }}
      />
      {optimal && (
        <i
          aria-hidden
          data-slot="range-bar-optimal"
          className={cn("absolute top-1/2 -translate-y-1/2 rounded-[var(--ns-radius-full)] bg-[var(--ns-band-optimal)]", trackH)}
          style={{ left: `${optimal.low == null ? bandFrom : pct(optimal.low)}%`, width: `${(optimal.high == null ? bandTo : pct(optimal.high)) - (optimal.low == null ? bandFrom : pct(optimal.low))}%` }}
        />
      )}
      {!row &&
        [range.low, range.high].map((l, i) =>
          l == null ? null : <i key={i} aria-hidden className="absolute top-1/2 h-[16px] w-px -translate-x-1/2 -translate-y-1/2 bg-[var(--ns-band-edge)]" style={{ left: `${pct(l)}%` }} />,
        )}
      {previous && value != null && (
        <>
          <i
            aria-hidden
            data-slot="range-bar-move"
            className="absolute top-1/2 h-0 -translate-y-1/2 border-t border-dotted border-[var(--ns-ink-3)]"
            style={{ left: `${Math.min(pct(previous.value), target!)}%`, width: `${Math.abs(target! - pct(previous.value))}%` }}
          />
          <i aria-hidden data-slot="range-bar-previous" className={cn("absolute top-1/2 w-[2px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--ns-ink-3)]", row ? "h-[10px]" : "h-[14px]")} style={{ left: `${pct(previous.value)}%` }} />
        </>
      )}
      {target != null && (
        <i
          ref={dot}
          aria-hidden
          data-slot="range-bar-value"
          data-flag={flag}
          className={cn(
            "absolute top-1/2 block -translate-x-1/2 -translate-y-1/2 rounded-full",
            row ? "size-[10px]" : "size-[12px]",
            flag === "may-be-wrong"
              ? "bg-[var(--ns-panel)] shadow-[inset_0_0_0_2px_var(--ns-bad),0_0_0_2px_var(--ns-panel)]"
              : row
                ? /* a row is one of many: its dot is cut from the panel, with no glow (one glow per screen region) */
                  "bg-[var(--ns-series)] shadow-[0_0_0_2px_var(--ns-panel)]"
                : "bg-[var(--ns-series)] shadow-[0_0_0_2.5px_var(--ns-panel),0_0_0_7px_color-mix(in_oklab,var(--ns-series)_16%,transparent),0_0_14px_5px_color-mix(in_oklab,var(--ns-series)_var(--ns-glow),transparent)]",
          )}
          style={{ left: `${target}%` }}
        >
          {/* on a record's bar the value is written over its dot, so the scale reads at a glance */}
          {size === "full" && value != null && (
            <span className="absolute bottom-[calc(100%+7px)] left-1/2 -translate-x-1/2 text-[length:var(--ns-text-meta)] font-[number:var(--ns-weight-medium)] whitespace-nowrap text-[var(--ns-ink)] not-italic tabular-nums">{format(value)}</span>
          )}
        </i>
      )}
    </div>
  );

  if (row) return <div data-slot="range-bar" data-standing={standing} data-grade={grade} data-size="row" className={cn("min-w-[72px]", className)}>{bar}</div>;

  const limits = (
    <div aria-hidden className="relative h-4 font-[family-name:var(--ns-font-mono)] text-[length:var(--ns-text-meta)] leading-4 text-[var(--ns-ink-3)] tabular-nums">
      {[range.low, range.high].map((l, i) => {
        if (l == null) return null;
        const at = pct(l);
        return (
          <span key={i} className="absolute top-0 whitespace-nowrap" style={at > 80 ? { right: `${100 - at}%`, transform: "translateX(50%)" } : { left: `${at}%`, transform: "translateX(-50%)" }}>
            {format(l)}
          </span>
        );
      })}
    </div>
  );

  if (size === "card")
    return (
      <div data-slot="range-bar" data-standing={standing} data-grade={grade} data-size="card" className={cn("flex min-w-0 flex-col gap-0.5", className)}>
        {value == null && <span className="text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)]">No result yet · {rangeName(range.kind)} {rangeText(range, format)}{unitText}</span>}
        {bar}
        {limits}
      </div>
    );

  return (
    <div data-slot="range-bar" data-standing={standing} data-grade={grade} data-size="full" className={cn("flex min-w-0 flex-col gap-3", className)}>
      <div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
        {value == null ? (
          <span className="font-[family-name:var(--ns-font-display)] text-[length:var(--ns-text-record)] leading-none font-[number:var(--ns-weight-medium)] tracking-[var(--ns-tracking-display)] text-[var(--ns-ink-3)]">--</span>
        ) : (
          <RollingNumber value={format(value)} className="font-[family-name:var(--ns-font-display)] text-[length:var(--ns-text-record)] leading-none font-[number:var(--ns-weight-medium)] tracking-[var(--ns-tracking-display)] text-[var(--ns-ink)]" />
        )}
        <span className="text-[length:var(--ns-text-strong)] text-[var(--ns-ink-2)]">{unit}</span>
        {value != null && (
          <StatusPill tone={tone} className="ml-1 self-center">
            {word}
          </StatusPill>
        )}
      </div>
      {children != null && <p className="m-0 max-w-[60ch] text-[length:var(--ns-text-body)] leading-[var(--ns-leading)] text-[var(--ns-ink-2)]">{children}</p>}
      <div className="flex flex-col gap-1 pt-1">
        {optimal &&
          (() => {
            /* the optimal band's name sits over its start, or over its end when the value's own label is there; with
               the value at both, it is left to the list below, so two labels never sit on each other */
            const from = Math.min(70, optimal.low == null ? bandFrom : pct(optimal.low));
            const to = optimal.high == null ? bandTo : pct(optimal.high);
            const at = value == null || flag === "may-be-wrong" ? null : pct(value);
            const clear = (a: number, b: number) => at == null || at < a - 4 || at > b + 4;
            const style = clear(from, from + 22) ? { left: `${from}%` } : clear(to - 22, to) ? { right: `${Math.max(0, 100 - to)}%` } : null;
            return (
              <span aria-hidden className="relative h-4 text-[length:var(--ns-text-meta)] leading-4 text-[var(--ns-ok-ink)]">
                {style && (
                  <span className="absolute whitespace-nowrap" style={style}>
                    Optimal {rangeText(optimal, format)}
                  </span>
                )}
              </span>
            );
          })()}
        {bar}
        {limits}
      </div>
      <dl className="m-0 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-[length:var(--ns-text-small)]">
        <dt className="text-[var(--ns-ink-3)]">{rangeName(range.kind)}</dt>
        <dd className="m-0 text-[var(--ns-ink-2)]">
          {rangeText(range, format)}
          {unitText} · {range.source}
        </dd>
        {optimal && (
          <>
            <dt className="text-[var(--ns-ink-3)]">Optimal</dt>
            <dd className="m-0 text-[var(--ns-ink-2)]">
              {rangeText(optimal, format)}
              {unitText} · {optimal.source}
            </dd>
          </>
        )}
        {previous && (
          <>
            <dt className="text-[var(--ns-ink-3)]">Previous</dt>
            <dd className="m-0 text-[var(--ns-ink-2)] tabular-nums">
              {format(previous.value)}
              {unitText} · {previous.label}
            </dd>
          </>
        )}
        {measured && (
          <>
            <dt className="text-[var(--ns-ink-3)]">Measured</dt>
            <dd className="m-0 text-[var(--ns-ink-2)]">{measured}</dd>
          </>
        )}
      </dl>
    </div>
  );
}

export { RangeBar, type RangeBarProps };
```

### lib/notchset/physics.ts

```tsx
// Notchset range-bar 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;
}
```

### lib/notchset/standing.ts

```tsx
// Notchset range-bar 2.0.0 · https://notchset.dev/changelog
/*
 * Standing: where a value sits against a named range, as one calculation every health piece reads (the range bar,
 * the results strip, a result list, a metric card), so a value can never read "High" in one place and "In range" in
 * another. A range always says whose it is: the lab's printed reference range, an optimal range with its source, or
 * your own baseline from your last nights. Plain functions, no React.
 */

/** Whose range it is. Never a bare band: the source is always said. */
export type RangeKind = "lab" | "optimal" | "baseline";

/** A range, open at either end ("< 100" has no low). Values are in the unit the value is shown in. */
export type ValueRange = {
  low?: number | undefined;
  high?: number | undefined;
  kind: RangeKind;
  /** who set it: "Northside Lab", "Endocrine Society 2024", "Your last 14 nights" */
  source: string;
};

/** Where a value stands: below, inside or above its range; unknown when there is no range or no value. */
export type Standing = "low" | "in" | "high" | "unknown";

/** A value's standing against a range: inclusive at both limits, as labs print them ("30–100" includes 30). */
export function standingOf(value: number | null | undefined, range: Pick<ValueRange, "low" | "high"> | null | undefined): Standing {
  if (value == null || !Number.isFinite(value) || !range) return "unknown";
  if (range.low == null && range.high == null) return "unknown";
  if (range.low != null && value < range.low) return "low";
  if (range.high != null && value > range.high) return "high";
  return "in";
}

/** The word for a standing. A baseline says "usual", never "range", because it is yours, not a reference. */
export function standingWord(s: Standing, kind: RangeKind = "lab"): string {
  if (kind === "baseline") return s === "low" ? "Below your usual" : s === "high" ? "Above your usual" : s === "in" ? "Your usual" : "No baseline yet";
  return s === "low" ? "Low" : s === "high" ? "High" : s === "in" ? "In range" : "No range";
}

/** A value's flag beyond its standing: the lab marked it critical, or it may have been read wrong. */
export type ValueFlag = "critical" | "may-be-wrong" | undefined;

/**
 * The tone a standing is said in (LANGUAGE.md §9b): in range is good news (ok), out of range needs attention (warn;
 * a high LDL isn't an error), and only the lab's critical flag or a value that may be wrong is bad. Your usual is
 * plain ink: being normal for you is not news.
 */
export function standingTone(s: Standing, flag?: ValueFlag, kind: RangeKind = "lab"): "ok" | "warn" | "bad" | "neutral" {
  if (flag) return "bad";
  if (s === "unknown") return "neutral";
  if (s === "in") return kind === "baseline" ? "neutral" : "ok";
  return "warn";
}

/** The word a flag is said in, which replaces the standing's word: nothing is concluded from it. */
export function flagWord(flag: ValueFlag): string | null {
  return flag === "critical" ? "Flagged by your lab" : flag === "may-be-wrong" ? "May be wrong" : null;
}

/** "30–100", "< 100", "> 40", in the value's own precision. */
export function rangeText(r: Pick<ValueRange, "low" | "high">, fmt: (n: number) => string = String): string {
  if (r.low != null && r.high != null) return `${fmt(r.low)}–${fmt(r.high)}`;
  if (r.high != null) return `< ${fmt(r.high)}`;
  if (r.low != null) return `> ${fmt(r.low)}`;
  return "";
}

/** Whose range, in words: "Lab range", "Optimal", "Your usual". */
export function rangeName(kind: RangeKind): string {
  return kind === "lab" ? "Lab range" : kind === "optimal" ? "Optimal" : "Your usual";
}

/** A number in the precision its unit is read at: one decimal under 10, none above (5.4 %, 128 mg/dL). */
export function formatReading(n: number, decimals?: number): string {
  const d = decimals ?? (Math.abs(n) < 10 && !Number.isInteger(n) ? 1 : 0);
  return n.toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d });
}

/** What a change means: better, worse or the same, by the measure's better direction (LANGUAGE.md §9, delta chips). */
export type Better = "up" | "down" | "in-range";

/**
 * A change between two readings, signed and toned by what's better for this measure, never by up or down. For a
 * measure whose better direction is "in-range" (vitamin D, TSH), moving toward the range is better.
 */
export function changeTone(now: number, then: number, better: Better, range?: Pick<ValueRange, "low" | "high">): "ok" | "bad" | "neutral" {
  if (now === then) return "neutral";
  if (better === "up") return now > then ? "ok" : "bad";
  if (better === "down") return now < then ? "ok" : "bad";
  if (!range) return "neutral";
  const dist = (v: number) => (range.low != null && v < range.low ? range.low - v : range.high != null && v > range.high ? v - range.high : 0);
  const a = dist(now), b = dist(then);
  return a === b ? "neutral" : a < b ? "ok" : "bad";
}

/** Plausible limits for a reading a person types (the log), per measure, so a slip ("310/80") is caught before it's kept. */
export const PLAUSIBLE = {
  weight: { low: 25, high: 300, unit: "kg" },
  systolic: { low: 60, high: 260, unit: "mmHg" },
  diastolic: { low: 30, high: 160, unit: "mmHg" },
  glucose: { low: 20, high: 600, unit: "mg/dL" },
  heartRate: { low: 25, high: 230, unit: "bpm" },
} as const;

/** Whether a typed reading is plausible; when it isn't, the words that say what to check ("BP 310/80? Check the value"). */
export function plausible(kind: keyof typeof PLAUSIBLE, value: number): { ok: true } | { ok: false; reason: string } {
  const p = PLAUSIBLE[kind];
  if (!Number.isFinite(value)) return { ok: false, reason: "Type a number" };
  if (value < p.low || value > p.high) return { ok: false, reason: `${formatReading(value)} ${p.unit}? Check the value: it's outside what a body reads (${p.low}–${p.high})` };
  return { ok: true };
}

/**
 * Where a value stands, in three (Superpower's and Whoop's model): Optimal (inside the optimal range), Normal (inside
 * the lab's range, outside the optimal one) and Out of range (outside the lab's). Where no optimal range is named the
 * lab's range is the optimal one, and the host says so. A flag (critical, may be wrong) is said instead, never a grade.
 */
export type Grade = "optimal" | "normal" | "out" | "unknown";
export function gradeOf(value: number | null | undefined, lab: Pick<ValueRange, "low" | "high"> | null | undefined, optimal?: Pick<ValueRange, "low" | "high"> | null): Grade {
  const s = standingOf(value, lab);
  if (s === "unknown") return "unknown";
  if (s !== "in") return "out";
  if (!optimal) return "optimal";
  return standingOf(value, optimal) === "in" ? "optimal" : "normal";
}
export function gradeWord(g: Grade): string {
  return g === "optimal" ? "Optimal" : g === "normal" ? "Normal" : g === "out" ? "Out of range" : "No range";
}
/**
 * The tone a grade is said in, from the existing roles (no new hue): Optimal ok, Normal plain ink (inside the lab's
 * range is not news), Out of range warn, and a flag (critical, may be wrong) bad. Always a dot and a word.
 */
export function gradeTone(g: Grade, flag?: ValueFlag): "ok" | "neutral" | "warn" | "bad" {
  if (flag) return "bad";
  return g === "optimal" ? "ok" : g === "out" ? "warn" : "neutral";
}
/** The dot's colour for a grade (the segmented bar, a row's dot). */
export function gradeColor(g: Grade, flag?: ValueFlag): string {
  const t = gradeTone(g, flag);
  return t === "ok" ? "var(--ns-standing-in)" : t === "warn" ? "var(--ns-standing-out)" : t === "bad" ? "var(--ns-standing-critical)" : "var(--ns-ink-3)";
}

/**
 * The tone a reading against your own usual is said in, by which way is better for that measure: above your usual
 * HRV is good news (ok), above your usual resting heart rate needs a look (warn), inside your usual is plain ink.
 * For a measure that's best inside its band (skin temperature), outside it either way is warn.
 */
export function usualTone(s: Standing, better: Better): "ok" | "neutral" | "warn" {
  if (s === "in" || s === "unknown") return "neutral";
  if (better === "up") return s === "high" ? "ok" : "warn";
  if (better === "down") return s === "low" ? "ok" : "warn";
  return "warn";
}
```

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.
