# Ruler (Notchset): prompt.md (v2.0.0)

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

Hairline ticks at an even step, your usual band as the solid stretch at its foot and the value as one taller mark, with the band's limits in mono. Sits under a hero number so it reads as a measurement..

## 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/ruler.tsx`; shared code: `lib/notchset/standing.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: `Ruler`, 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-font-mono`, `--ns-ink`, `--ns-ink-3`, `--ns-series`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`.
- 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 { Ruler } from "@/components/ui/notchset/ruler";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/ruler.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
- Standing (shared helper, ships with it)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import { Ruler } from "@/components/ui/notchset/ruler";

export default function RulerDemo() {
  return <Ruler value={61} low={47} high={55} className="w-[340px] max-w-full" />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `value` | `number` |  | Required. |
| `low` | `number` |  | your usual (or the range the value is read against) |
| `high` | `number` |  | Required. |
| `format` | `(n: number) => string` | `(n) => formatReading(n)` |  |

Full docs: https://notchset.dev/components/ruler

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

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

## Continue the workflow

- [Baseline meter](https://notchset.dev/components/baseline-meter)
- [Draw history](https://notchset.dev/components/draw-history)
- [Glance tile](https://notchset.dev/components/glance-tile)

## Source

### components/ui/notchset/ruler.tsx

```tsx
// Notchset ruler 2.0.0 · https://notchset.dev/changelog
"use client";
import { cn } from "@/lib/utils";
import { formatReading } from "@/lib/notchset/standing";

/*
 * Ruler: an instrument's scale under a reading. Hairline ticks at an even step (every fifth taller), your usual band
 * drawn as the solid stretch along its foot, and the value as one taller ink mark. The limits of your usual are
 * written in mono at its ends; nothing else. It sits under a hero number, so the number reads as a measurement.
 *
 *   <Ruler value={61} low={47} high={55} />
 */
type RulerProps = {
  value: number;
  /** your usual (or the range the value is read against) */
  low: number;
  high: number;
  format?: ((n: number) => string) | undefined;
  className?: string | undefined;
};

function Ruler({ value, low, high, format = (n) => formatReading(n), className }: RulerProps) {
  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 pct = (v: number) => ((v - lo) / (hi - lo)) * 100;
  const N = 60;
  return (
    <div data-slot="ruler" aria-hidden className={cn("relative h-[30px] w-full", className)}>
      {Array.from({ length: N + 1 }, (_, i) => (
        <i key={i} className="absolute top-[6px] w-px bg-[var(--ns-ink)]" style={{ left: `${(i / N) * 100}%`, height: i % 5 === 0 ? 9 : 5, opacity: i % 5 === 0 ? 0.28 : 0.16 }} />
      ))}
      <i className="absolute top-[17px] h-[2px] rounded-full bg-[var(--ns-ink)] opacity-70" style={{ left: `${pct(low)}%`, width: `${pct(high) - pct(low)}%` }} />
      <i className="absolute top-0 h-[19px] w-[2px] -translate-x-1/2 rounded-full bg-[var(--ns-series)]" style={{ left: `${pct(value)}%` }} />
      {[low, high].map((l, i) => (
        <span key={i} className="absolute top-[21px] -translate-x-1/2 font-[family-name:var(--ns-font-mono)] text-[10px] leading-none text-[var(--ns-ink-3)] tabular-nums" style={{ left: `${pct(l)}%` }}>
          {format(l)}
        </span>
      ))}
    </div>
  );
}

export { Ruler, type RulerProps };
```

### lib/notchset/standing.ts

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