# Baseline meter (Notchset): prompt.md (v2.0.0)

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

Nights in ink fill with the series as each one is measured, the count written (9 of 14 nights) and what's left, so nothing is read against a usual that doesn't exist yet. Full, it becomes the range bar of your usual with today on it. A night added while it's on screen pops in once..

## 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/baseline-meter.tsx`; shared code: `lib/notchset/standing.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `range-bar`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `BaselineMeter`, 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-ease`, `--ns-ease-pop`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-line`, `--ns-series`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-text-meta`, `--ns-text-small`, `--ns-time-page`.
- 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 { BaselineMeter } from "@/components/ui/notchset/baseline-meter";
```

## Parts
- A single export that takes props.

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

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import * as React from "react";
import { BaselineMeter } from "@/components/ui/notchset/baseline-meter";
import { Button } from "@/components/ui/notchset/button";

export default function BaselineMeterDemo() {
  const [nights, setNights] = React.useState(9);
  return (
    <div className="flex max-w-[420px] flex-col gap-4">
      <BaselineMeter metric="HRV" unit="ms" nights={nights} band={{ low: 47, high: 55 }} value={61} />
      <Button size="sm" className="self-start" onClick={() => setNights((n) => (n >= 14 ? 9 : n + 1))}>
        {nights >= 14 ? "Start over" : "Add a night"}
      </Button>
    </div>
  );
}
```

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/baseline-meter.json https://notchset.dev/r/button.json`.

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `metric` | `string` |  | what's being learned: "Skin temperature" |
| `nights` | `number` |  | nights measured so far |
| `of` | `number` | `14` | nights it needs |
| `unit` | `string` | `""` |  |
| `band` | `{ low: number; high: number }` |  | your usual range, once learned |
| `value` | `number \| null` | `null` | today's value, drawn on the band once it's learned |
| `format` | `(n: number) => string` | `(n) => formatReading(n)` |  |

Full docs: https://notchset.dev/components/baseline-meter

## 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/baseline-meter.json
```

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

## Continue the workflow

- [Draw history](https://notchset.dev/components/draw-history)
- [Glance tile](https://notchset.dev/components/glance-tile)
- [Load range](https://notchset.dev/components/load-range)

## Source

### components/ui/notchset/baseline-meter.tsx

```tsx
// Notchset baseline-meter 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { RangeBar } from "@/components/ui/notchset/range-bar";
import { formatReading } from "@/lib/notchset/standing";

/*
 * Baseline meter: the honest "learning your baseline". A row of nights in ink (14 by default) fills with the series as
 * each night is measured, with the count written ("9 of 14 nights") and what's left ("5 more nights"). Until it's
 * full nothing is said against your usual, because there isn't one yet. Full, it becomes your band: the range bar of
 * your usual with today's value on it. A night added while you watch pops in once; with less motion it simply shows.
 *
 *   <BaselineMeter metric="Skin temperature" nights={9} />
 *   <BaselineMeter metric="HRV" unit="ms" nights={14} band={{ low: 46, high: 58 }} value={61} />
 */
type BaselineMeterProps = {
  /** what's being learned: "Skin temperature" */
  metric: string;
  /** nights measured so far */
  nights: number;
  /** nights it needs */
  of?: number | undefined;
  unit?: string | undefined;
  /** your usual range, once learned */
  band?: { low: number; high: number } | undefined;
  /** today's value, drawn on the band once it's learned */
  value?: number | null | undefined;
  format?: ((n: number) => string) | undefined;
  className?: string | undefined;
};

/** "skin temperature" in a sentence, "HRV" kept as it is */
const inSentence = (s: string) => (/^[A-Z]{2}/.test(s) ? s : s.charAt(0).toLowerCase() + s.slice(1));

function BaselineMeter({ metric, nights, of = 14, unit = "", band, value = null, format = (n) => formatReading(n), className }: BaselineMeterProps) {
  const have = Math.max(0, Math.min(of, Math.floor(nights)));
  const done = have >= of && band != null;
  const left = of - have;
  /* a night that arrives while it's on screen pops in; on arrival every dot is simply there */
  const seen = React.useRef(have);
  const [fresh, setFresh] = React.useState<number | null>(null);
  React.useEffect(() => {
    if (have > seen.current) setFresh(have - 1);
    seen.current = have;
  }, [have]);

  if (done)
    return (
      <div data-slot="baseline-meter" data-state="learned" className={cn("flex min-w-0 flex-col gap-1.5", className)}>
        <div className="flex items-baseline justify-between gap-3 text-[length:var(--ns-text-small)]">
          <span className="text-[var(--ns-ink-2)]">Your usual {inSentence(metric)}</span>
          <span className="text-[var(--ns-ink)] tabular-nums">
            {format(band.low)}–{format(band.high)}
            {unit && ` ${unit}`}
          </span>
        </div>
        <div data-ns-moves="" className="[animation:ns-fade_var(--ns-time-page)_var(--ns-ease)_both]">
          <RangeBar size="card" name={metric} value={value} unit={unit} format={format} range={{ low: band.low, high: band.high, kind: "baseline", source: `Your last ${of} nights` }} />
        </div>
      </div>
    );

  return (
    <div data-slot="baseline-meter" data-state="learning" className={cn("flex min-w-0 flex-col gap-2", className)}>
      <div className="flex items-baseline justify-between gap-3 text-[length:var(--ns-text-small)]">
        <span className="text-[var(--ns-ink-2)]">Learning your baseline</span>
        <span className="text-[var(--ns-ink)] tabular-nums">
          {have} of {of} nights
        </span>
      </div>
      <div
        role="meter"
        aria-label={`${metric}: learning your baseline`}
        aria-valuemin={0}
        aria-valuemax={of}
        aria-valuenow={have}
        aria-valuetext={`${have} of ${of} nights`}
        className="grid gap-[3px]"
        style={{ gridTemplateColumns: `repeat(${of}, minmax(0, 1fr))` }}
      >
        {Array.from({ length: of }, (_, i) => (
          <i
            key={i}
            aria-hidden
            data-on={i < have || undefined}
            data-ns-still={i === fresh ? "" : undefined}
            className={cn(
              "mx-auto block aspect-square w-full max-w-[10px] rounded-full",
              i < have ? "bg-[var(--ns-series)]" : "shadow-[inset_0_0_0_1.5px_var(--ns-line)]",
              i === fresh && "[animation:ns-pop_360ms_var(--ns-ease-pop)]",
            )}
          />
        ))}
      </div>
      <span className="text-[length:var(--ns-text-meta)] text-[var(--ns-ink-3)]">
        {left === 1 ? "1 more night" : `${left} more nights`} before {inSentence(metric)} is read against your usual
      </span>
    </div>
  );
}

export { BaselineMeter, type BaselineMeterProps };
```

### lib/notchset/standing.ts

```tsx
// Notchset baseline-meter 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.
