# Textarea (Notchset): prompt.md (v1.0.0)

- id: `textarea` · version 1.0.0 · component · free
- category: Inputs
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/textarea.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/notchset-foundation.json
- docs: https://beautiful-ui.dev/components/textarea
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

shadcn's Textarea in Notchset, Archivo in a ruled box with the ink focus trace. TextareaField is the full instrument: a label row, a box that grows from 96 to 220px and then scrolls, a 24-dot token meter with an estimate and cost per run, and a helper that changes on focus.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/notchset/textarea.tsx`; shared code: `lib/beautiful-ui/notchset/field.tsx`, `lib/beautiful-ui/notchset/field-classes.ts`, `lib/beautiful-ui/notchset/instrument.tsx`, `lib/beautiful-ui/notchset/root.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `Textarea`, `TextareaField`, and every exported type.
- CSS: the install adds the notchset foundation (tokens, keyframes, motion levels) 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.
- Tokens: retheme with the `--notchset-*` custom properties (`--notchset-check`, `--notchset-control-edge`, `--notchset-draw-from`, `--notchset-ease-bloom`, `--notchset-ease-travel`, `--notchset-fade`, `--notchset-focus-color`, `--notchset-focus-inset`, `--notchset-life-from`, `--notchset-life-ms`, `--notchset-node-blink`, `--notchset-node-bloom`, `--notchset-node-delay`, `--notchset-plate-color`, `--notchset-rise-from`, `--notchset-rule`, `--notchset-scroll`, `--notchset-signal`, `--notchset-sweep-to`). Never add Tailwind colour classes inside the component.

```tsx
import { TextareaField } from "@/components/ui/notchset/textarea";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Textarea` | `textarea` | The textarea. |
| `TextareaField` | `textarea-field` | The instrument's box. |
| `Footer` | `textarea-footer` | The token meter, estimate and cost. |

Style a part with `[data-slot="<slot>"]` selectors or its `className`; keep the attributes when editing.

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `SoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/notchset-sound.json`, import from `@/components/ui/notchset/sound-provider`); `useSound()` gives `muted` and `setMuted` for a mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/notchset/textarea.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
- textarea, prompt editor, system prompt, multi-line input, token count, Notchset
- Prompts, notes and anything longer than a line
- TextareaField when the length matters (tokens, cost)

### Not when
- One line: use Input

## Mistakes
- Tokens are an estimate: pass estimateTokens for your model's tokenizer

## Usage

```tsx
import { TextareaField } from "@/components/ui/notchset/textarea";

<TextareaField label="System prompt" meta="Planner v14" costPerToken={0.0000004} helper="Sent before every run" focusHelper="Saved as draft" />
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `Textarea` | `as shadcn` |  | The bare textarea (it sizes to its content). |
| `TextareaField label / meta` | `ReactNode` |  | The label and a reading on the right. |
| `value / defaultValue / onValueChange` | `string / (value) => void` |  | The value. |
| `minHeight / maxHeight` | `number` |  | Auto-grow bounds. Default 96 and 220; past the maximum it scrolls. |
| `tokenLimit` | `number` |  | The meter's limit. Default 2000. |
| `estimateTokens` | `(text) => number` |  | Default ceil(chars / 4). |
| `costPerToken` | `number` |  | Shows the cost per run. |
| `helper / focusHelper` | `string` |  | The helper, and the helper while focused. |

Full docs: https://beautiful-ui.dev/components/textarea

## Customising
- Colours: the component reads your shadcn tokens (`--background`, `--foreground`, `--border` …), refined by the `--notchset-*` tokens. The signal colour is `--notchset-signal` (it falls back to `--destructive`). Set tokens on `:root`, or on any container to retheme one area.
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Into and out of the field (no traps) |

## Performance

- A native textarea; the counter updates on change, nothing runs while idle.

## Responsive

- Fills its container and grows with its rows; the footer wraps its helper under the count when narrow.

## Motion inventory

| Interaction | What moves |
|---|---|
| Grow | The box animates to its content over 200ms cubic-bezier(.16,1,.3,1), clamped 96–220px |
| Meter | Dots bloom (r 1.1 → 1.6) over 260ms as the estimate rises |
| Focus | The ink trace draws from the left; the helper flips |

## Accessibility contract (preserve when editing)
- A real <label>; the meter line is aria-describedby
- The estimate turns signal past 90% of the limit, in words as well as colour

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/textarea.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/textarea.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/textarea`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/notchset/textarea.tsx

```tsx
/**
 * Textarea (Notchset) v1.0.0 · Beautiful UI, by Chimpy (https://x.com/stochastichimp)
 * Docs: https://beautiful-ui.dev/components/textarea · Agent prompt: https://beautiful-ui.dev/md/textarea.md
 * MIT licensed: free to use, change and share.
 */
import type * as React from "react";
import { cn } from "@/lib/utils";
import { TextareaField } from "@/lib/beautiful-ui/notchset/field";
import { textareaClass } from "@/lib/beautiful-ui/notchset/field-classes";
import { NOTCHSET_ROOT } from "@/lib/beautiful-ui/notchset/root";

/*
 * Notchset Textarea. shadcn's Textarea in Notchset (it sizes to its content, like shadcn's): Archivo
 * 14/1.5 in a ruled box whose focus is a 2px ink trace along the bottom edge. TextareaField is the
 * full instrument: label and node, an auto-growing box (96–220px, then it scrolls), a 24-dot token
 * meter with an estimate and cost per run, and a helper that changes on focus.
 */

function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
  return <textarea {...NOTCHSET_ROOT} data-slot="textarea" className={cn(textareaClass, className)} {...props} />;
}

export { Textarea, TextareaField };
export type { TextareaFieldProps } from "@/lib/beautiful-ui/notchset/field";
```

### lib/beautiful-ui/notchset/field.tsx

```tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { inputClass, textareaClass } from "@/lib/beautiful-ui/notchset/field-classes";
import { FlipText, motionOff, Node, OWN_SOUND, NOTCHSET_ROOT, type NodeState } from "@/lib/beautiful-ui/notchset/instrument";

/*
 * Notchset fields, shared by both builds. The bare Input and Textarea classes (shadcn's shape: one
 * element, className on it) draw the field's focus as a 2px ink trace along the bottom edge, from
 * the left, with a signal caret and a signal-tint selection; never a ring. InputField and
 * TextareaField are the full instruments: a label row with a node, the field, and a live helper
 * (plus a counter and clear key, or an auto-growing box with a token meter).
 */

/** The label row: a mono label, then a reading (counter, meta) and the node on the right. */
function FieldHead({ htmlFor, label, reading, node }: { htmlFor: string; label: React.ReactNode; reading?: React.ReactNode; node: NodeState }) {
  return (
    <div className="flex items-center justify-between gap-3">
      <label htmlFor={htmlFor} className="font-mono text-[10px] font-medium tracking-[0.1em] whitespace-nowrap text-foreground uppercase">
        {label}
      </label>
      <span className="flex items-center gap-2 font-mono text-[8.5px] tracking-[0.06em] whitespace-nowrap text-muted-foreground tabular-nums">
        {reading}
        <Node state={node} />
      </span>
    </div>
  );
}

/** The 2px trace under a composite field's box (it spans the border, -1px each side). */
function FieldTrace({ on, invalid }: { on: boolean; invalid?: boolean }) {
  return (
    <span
      aria-hidden
      data-slot="field-trace"
      className={cn("pointer-events-none absolute -right-px -bottom-px -left-px h-[2px] origin-left", invalid ? "bg-[var(--notchset-signal,var(--destructive))]" : "bg-foreground")}
      style={{ transform: `scaleX(${on ? 1 : 0})`, transition: on ? "transform 300ms var(--notchset-ease-travel)" : "transform 200ms var(--notchset-ease-travel)" }}
    />
  );
}

function useControllable(value: string | undefined, defaultValue: string | undefined, onValueChange?: (v: string) => void) {
  const [own, setOwn] = React.useState(defaultValue ?? "");
  const current = value ?? own;
  const set = (next: string) => {
    setOwn(next);
    onValueChange?.(next);
  };
  return [current, set] as const;
}

// ---------------------------------------------------------------------------------------------
// InputField
// ---------------------------------------------------------------------------------------------

export type InputFieldProps = Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "prefix"> & {
  label: React.ReactNode;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** A fixed prefix before the value (e.g. "@"). */
  prefix?: React.ReactNode;
  /** Return an error to show, or null when the value is fine. Checked on every change. */
  validate?: (value: string) => string | null;
  /** A live reading of a valid value (e.g. the path it becomes). */
  preview?: (value: string) => string;
  /** The helper line while empty (and the reason while disabled). */
  helper?: string;
  /** The clear key, shown once there is a value. Default true. */
  clearable?: boolean;
  /** The counter ("08/32"), when maxLength is set. Default true. */
  counter?: boolean;
  clearLabel?: string;
};

function InputField({
  label,
  value: valueProp,
  defaultValue,
  onValueChange,
  prefix,
  validate,
  preview,
  helper = "",
  clearable = true,
  counter = true,
  clearLabel = "Clear",
  maxLength,
  disabled,
  className,
  id: idProp,
  onChange,
  onFocus,
  onBlur,
  onKeyDown,
  ...props
}: InputFieldProps) {
  const auto = React.useId();
  const id = idProp ?? `${auto}-input`;
  const helperId = `${auto}-helper`;
  const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange);
  const [focused, setFocused] = React.useState(false);
  const inputRef = React.useRef<HTMLInputElement>(null);
  // Validate every value, empty too (a required field reports when cleared); an aria-invalid from
  // the caller (a server or form-library error) also counts.
  const error = validate ? validate(value) : null;
  const invalid = Boolean(error) || props["aria-invalid"] === true || props["aria-invalid"] === "true";
  const node: NodeState = disabled ? "off" : invalid ? "signal" : focused || (value && !invalid && validate) ? "on" : "off";
  const helpText = error ?? (value && preview && !disabled ? preview(value) : helper);
  const helpTone = invalid ? "text-[var(--notchset-signal-text,var(--destructive))]" : value && preview ? "text-foreground" : "text-muted-foreground";
  const showClear = clearable && !disabled && value.length > 0;
  const change = (next: string) => setValue(maxLength ? next.slice(0, maxLength) : next);

  return (
    <div {...NOTCHSET_ROOT} data-slot="input-field-root" className={cn("flex w-full flex-col gap-[9px]", className)}>
      <FieldHead htmlFor={id} label={label} node={node} reading={counter && maxLength ? `${String(value.length).padStart(2, "0")}/${maxLength}` : undefined} />
      <div
        data-slot="input-field"
        data-invalid={invalid || undefined}
        data-disabled={disabled || undefined}
        className={cn(
          "relative flex h-10 items-center border border-solid bg-background transition-[border-color] duration-[140ms] pointer-coarse:h-11",
          invalid ? "border-[var(--notchset-signal-text,var(--destructive))]" : focused ? "border-foreground" : "border-[var(--notchset-control-edge,var(--input))] hover:border-foreground",
          disabled && "border-[var(--notchset-rule,var(--border))] opacity-40",
        )}
      >
        {prefix && (
          <span aria-hidden className="pl-3 font-mono text-[12px] whitespace-nowrap text-muted-foreground">
            {prefix}
          </span>
        )}
        <input
          {...props}
          ref={inputRef}
          id={id}
          data-slot="input"
          value={value}
          maxLength={maxLength}
          disabled={disabled}
          aria-invalid={invalid || undefined}
          aria-describedby={[helperId, props["aria-describedby"]].filter(Boolean).join(" ")}
          onChange={(e) => {
            onChange?.(e);
            change(e.target.value);
          }}
          onFocus={(e) => {
            onFocus?.(e);
            setFocused(true);
          }}
          onBlur={(e) => {
            onBlur?.(e);
            setFocused(false);
          }}
          onKeyDown={(e) => {
            onKeyDown?.(e);
            if (!e.defaultPrevented && e.key === "Escape" && value) {
              e.preventDefault();
              change("");
            }
          }}
          className={cn(
            "h-[38px] min-w-0 flex-1 border-0 bg-transparent pr-2 font-mono text-[13px] tracking-[0.01em] text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
            "caret-[var(--notchset-signal,var(--destructive))] selection:bg-[var(--notchset-tint,color-mix(in_oklab,var(--destructive)_18%,transparent))]",
            prefix ? "pl-1" : "pl-3",
          )}
        />
        {clearable && (
          <button {...OWN_SOUND}
            type="button"
            tabIndex={-1}
            aria-label={clearLabel}
            data-slot="input-clear"
            onClick={() => {
              change("");
              inputRef.current?.focus();
            }}
            className={cn(
              `notchset-focus`,
              "mr-1.5 flex size-7 flex-none cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-muted-foreground transition-[opacity,transform] duration-[120ms,220ms] ease-[linear,cubic-bezier(.34,1.6,.5,1)] [--notchset-focus-inset:-3px] hover:text-foreground",
              showClear ? "scale-100 opacity-100" : "pointer-events-none scale-[.6] opacity-0",
            )}
          >
            <svg aria-hidden width="10" height="10" viewBox="0 0 10 10">
              <path d="M1.5 1.5 L8.5 8.5 M8.5 1.5 L1.5 8.5" stroke="currentColor" strokeWidth="1.3" />
            </svg>
          </button>
        )}
        <FieldTrace on={focused || invalid} invalid={invalid} />
      </div>
      <div id={helperId} aria-live="polite" data-slot="input-helper" className={cn("font-mono text-[9px] leading-[1.4] tracking-[0.04em] [overflow-wrap:anywhere]", helpTone)}>
        {helpText && <FlipText className="!whitespace-normal">{helpText}</FlipText>}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------------------------
// TextareaField
// ---------------------------------------------------------------------------------------------

export type TextareaFieldProps = Omit<React.ComponentProps<"textarea">, "value" | "defaultValue"> & {
  label: React.ReactNode;
  /** A reading on the label row's right (e.g. "PLANNER V14"). */
  meta?: React.ReactNode;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Auto-grow bounds (px). Default 96 and 220; past the maximum it scrolls. */
  minHeight?: number;
  maxHeight?: number;
  /** The token meter. Default 2000; estimate is ceil(chars / 4) unless you pass estimateTokens. */
  tokenLimit?: number;
  estimateTokens?: (text: string) => number;
  /** Cost per token (USD), shown per run on the right; omit to hide it. */
  costPerToken?: number;
  helper?: string;
  /** The helper while focused. */
  focusHelper?: string;
  tokensLabel?: string;
};

const DOTS = 24;
const fmt = new Intl.NumberFormat("en-US");

function TextareaField({
  label,
  meta,
  value: valueProp,
  defaultValue,
  onValueChange,
  minHeight = 96,
  maxHeight = 220,
  tokenLimit = 2000,
  estimateTokens = (t) => Math.ceil(t.length / 4),
  costPerToken,
  helper = "",
  focusHelper,
  tokensLabel = "TOKENS",
  disabled,
  className,
  id: idProp,
  onChange,
  onFocus,
  onBlur,
  ...props
}: TextareaFieldProps) {
  const auto = React.useId();
  const id = idProp ?? `${auto}-textarea`;
  const footId = `${auto}-meter`;
  const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange);
  const [focused, setFocused] = React.useState(false);
  const ref = React.useRef<HTMLTextAreaElement>(null);

  // Auto-grow: measure the content, then animate the box between the bounds (no React state).
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const from = el.offsetHeight;
    el.style.transition = "none";
    el.style.height = "0px";
    const content = el.scrollHeight;
    const to = Math.max(minHeight, Math.min(maxHeight, content));
    el.style.height = `${from || to}px`;
    el.style.overflowY = content > maxHeight ? "auto" : "hidden";
    void el.offsetHeight;
    el.style.transition = motionOff(el) ? "none" : "height 200ms cubic-bezier(.16,1,.3,1)";
    el.style.height = `${to}px`;
  }, [value, minHeight, maxHeight]);

  const tokens = estimateTokens(value);
  const lit = Math.min(DOTS, Math.round((tokens / Math.max(1, tokenLimit)) * DOTS));
  const near = tokens > tokenLimit * 0.9;
  const helpText = focused && focusHelper ? focusHelper : helper;

  return (
    <div {...NOTCHSET_ROOT} data-slot="textarea-field-root" className={cn("flex w-full flex-col gap-[9px]", className)}>
      <FieldHead htmlFor={id} label={label} node={disabled ? "off" : focused ? "on" : "off"} reading={meta} />
      <div
        data-slot="textarea-field"
        className={cn(
          "relative border border-solid bg-background transition-[border-color] duration-[140ms]",
          focused ? "border-foreground" : "border-[var(--notchset-control-edge,var(--input))] hover:border-foreground",
          disabled && "border-[var(--notchset-rule,var(--border))] opacity-40",
        )}
      >
        <textarea
          {...props}
          ref={ref}
          id={id}
          data-slot="textarea"
          value={value}
          disabled={disabled}
          aria-describedby={[footId, props["aria-describedby"]].filter(Boolean).join(" ")}
          onChange={(e) => {
            onChange?.(e);
            setValue(e.target.value);
          }}
          onFocus={(e) => {
            onFocus?.(e);
            setFocused(true);
          }}
          onBlur={(e) => {
            onBlur?.(e);
            setFocused(false);
          }}
          style={{ height: minHeight, ...props.style }}
          className={cn(
            "block w-full resize-none border-0 bg-transparent px-3 pt-[11px] pb-1.5 font-sans text-sm leading-[1.5] text-foreground outline-none [scrollbar-width:none] placeholder:text-muted-foreground disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden",
            "caret-[var(--notchset-signal,var(--destructive))] selection:bg-[var(--notchset-tint,color-mix(in_oklab,var(--destructive)_18%,transparent))]",
          )}
        />
        <div
          id={footId}
          data-slot="textarea-footer"
          className="flex h-[30px] items-center gap-2.5 border-t border-solid border-[var(--notchset-rule,var(--border))] pr-2 pl-3 font-mono text-[8.5px] tracking-[0.06em] whitespace-nowrap text-muted-foreground tabular-nums"
        >
          <svg aria-hidden width="94" height="6" viewBox="0 0 94 6" className="flex-none overflow-visible">
            {Array.from({ length: DOTS }, (_, i) => (
              <circle
                key={i}
                cx={2 + i * 3.9}
                cy="3"
                fill={i < lit ? "var(--foreground)" : "var(--notchset-rule, var(--border))"}
                style={{ r: i < lit ? "1.6px" : "1.1px", transition: "r 260ms var(--notchset-ease-bloom), fill 120ms linear" }}
              />
            ))}
          </svg>
          <span className={near ? "text-[var(--notchset-signal-text,var(--destructive))]" : undefined}>
            ≈{fmt.format(tokens)} / {fmt.format(tokenLimit)} {tokensLabel}
          </span>
          {costPerToken !== undefined && <span className="ml-auto">${(tokens * costPerToken).toFixed(5)} / RUN</span>}
          <svg aria-hidden width="10" height="10" viewBox="0 0 10 10" className={cn("flex-none", costPerToken === undefined && "ml-auto")}>
            {[
              [8.5, 1.5],
              [5, 5],
              [8.5, 5],
              [1.5, 8.5],
              [5, 8.5],
              [8.5, 8.5],
            ].map(([x, y]) => (
              <circle key={`${x}-${y}`} cx={x} cy={y} r="1" fill="var(--notchset-control-edge, var(--input))" />
            ))}
          </svg>
        </div>
        <FieldTrace on={focused} />
      </div>
      {helpText && (
        <span data-slot="textarea-helper" className="font-mono text-[9px] tracking-[0.04em] text-muted-foreground">
          <FlipText className="!whitespace-normal">{helpText}</FlipText>
        </span>
      )}
    </div>
  );
}

export { InputField, TextareaField, inputClass, textareaClass };
```

### lib/beautiful-ui/notchset/field-classes.ts

```tsx
import { cn } from "@/lib/utils";

/*
 * Notchset field classes, server-safe (no client directive), so the bare Input and Textarea keep
 * their look in server components. The focus is a 2px ink trace drawn along the bottom edge from the
 * left, never a ring; invalid keeps a signal trace drawn; forced colors get a system outline.
 */

/** The focus trace and states, as utilities on the element itself (no wrapper needed). */
const TRACE =
  "bg-no-repeat [background-image:linear-gradient(var(--foreground),var(--foreground))] [background-position:left_bottom] [background-size:0_2px] focus:[background-size:100%_2px] aria-invalid:[background-image:linear-gradient(var(--notchset-signal,var(--destructive)),var(--notchset-signal,var(--destructive)))] aria-invalid:[background-size:100%_2px] forced-colors:focus:outline forced-colors:focus:outline-2 forced-colors:focus:outline-offset-2 forced-colors:focus:outline-[Highlight] transition-[border-color,background-size] duration-[140ms,200ms] ease-[linear,var(--notchset-ease-travel)] focus:duration-[140ms,300ms]";

export const inputClass = cn(
  "flex h-10 w-full min-w-0 border border-solid border-[var(--notchset-control-edge,var(--input))] bg-background px-3 py-0 font-mono text-[13px] tracking-[0.01em] text-foreground outline-none",
  "caret-[var(--notchset-signal,var(--destructive))] selection:bg-[var(--notchset-tint,color-mix(in_oklab,var(--destructive)_18%,transparent))] placeholder:text-muted-foreground",
  "hover:border-foreground focus:border-foreground aria-invalid:border-[var(--notchset-signal-text,var(--destructive))]",
  "disabled:cursor-not-allowed disabled:border-[var(--notchset-rule,var(--border))] disabled:opacity-40",
  "file:inline-flex file:h-full file:border-0 file:bg-transparent file:font-mono file:text-[11px] file:font-medium file:tracking-[0.07em] file:text-foreground file:uppercase",
  "pointer-coarse:h-11",
  TRACE,
);

export const textareaClass = cn(
  "flex field-sizing-content min-h-24 w-full border border-solid border-[var(--notchset-control-edge,var(--input))] bg-background px-3 pt-[11px] pb-1.5 font-sans text-sm leading-[1.5] text-foreground outline-none",
  "caret-[var(--notchset-signal,var(--destructive))] selection:bg-[var(--notchset-tint,color-mix(in_oklab,var(--destructive)_18%,transparent))] placeholder:text-muted-foreground",
  "hover:border-foreground focus:border-foreground aria-invalid:border-[var(--notchset-signal-text,var(--destructive))]",
  "disabled:cursor-not-allowed disabled:border-[var(--notchset-rule,var(--border))] disabled:opacity-40",
  TRACE,
);
```

### lib/beautiful-ui/notchset/instrument.tsx

```tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * Notchset instrument parts, shared by every member of a pack: the node (a round light that reports
 * state), the legend printed above a control, the plate a key presses onto, the traced check, the
 * five-tick scanner, and the glyph flip that lands a changed label left to right. Nodes and checks
 * are inline SVG (round at every DPR); the CSS lives in the Notchset foundation. Also: the one motion
 * policy (reduced motion or a data-motion="off" ancestor), ref composition, the portal container
 * and the sound attributes every Notchset root carries.
 */

// ---------------------------------------------------------------------------------------------
// Motion, refs, portals, sound
// ---------------------------------------------------------------------------------------------

/** True under prefers-reduced-motion or inside a data-motion="off" scope. Checked when an effect runs. */
/**
 * Whether motion is off for an element, kept live: it follows the OS reduced-motion setting and any
 * data-motion attribute change above it, so a preference switched mid-playback stops decoration at once.
 */
export function useMotionOff(ref: React.RefObject<Element | null>) {
  const [off, setOff] = React.useState(false);
  React.useEffect(() => {
    const read = () => setOff(motionOff(ref.current));
    const r = requestAnimationFrame(read);
    const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
    mq?.addEventListener?.("change", read);
    const mo = new MutationObserver(read);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"], subtree: true });
    return () => {
      cancelAnimationFrame(r);
      mq?.removeEventListener?.("change", read);
      mo.disconnect();
    };
  }, [ref]);
  return off;
}

/**
 * The microphone, or a rejection after `timeoutMs` (a prompt left open, a denied permission). A stream
 * that arrives after the timeout is stopped at once, so a late "Allow" never leaves the mic on.
 */
export function requestMic(timeoutMs = 5000): Promise<MediaStream> {
  if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) return Promise.reject(new Error("no mic"));
  const ask = navigator.mediaDevices.getUserMedia({ audio: true });
  return new Promise((resolve, reject) => {
    let late = false;
    const t = setTimeout(() => {
      late = true;
      reject(new Error("timeout"));
    }, timeoutMs);
    ask.then(
      (stream) => {
        clearTimeout(t);
        if (late) stream.getTracks().forEach((track) => track.stop());
        else resolve(stream);
      },
      (err) => {
        clearTimeout(t);
        reject(err);
      },
    );
  });
}

export function motionOff(el: Element | null | undefined) {
  if (typeof window === "undefined") return true;
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return true;
  return Boolean(el?.closest('[data-motion="off"]'));
}

function assignRef<T>(ref: React.Ref<T> | undefined, node: T | null): () => void {
  if (typeof ref === "function") {
    const cleanup = ref(node);
    return typeof cleanup === "function" ? cleanup : () => ref(null);
  }
  if (ref) (ref as React.RefObject<T | null>).current = node;
  return () => {
    if (ref) (ref as React.RefObject<T | null>).current = null;
  };
}

/** Composes two refs, keeping React 19 ref cleanups. */
export function useComposedRefs<T>(a: React.Ref<T> | undefined, b: React.Ref<T> | undefined): React.RefCallback<T> {
  return React.useCallback(
    (node: T | null) => {
      const release = [assignRef(a, node), assignRef(b, node)];
      return () => release.forEach((f) => f());
    },
    [a, b],
  );
}

/** Runs ours after theirs, unless theirs called preventDefault. */
export function composeHandler<E extends { defaultPrevented: boolean }>(theirs: ((e: E) => void) | undefined, ours: (e: E) => void) {
  return (e: E) => {
    theirs?.(e);
    if (!e.defaultPrevented) ours(e);
  };
}

/** Keeps the latest value in a ref, for callbacks read inside timers and animation frames. */
export function useLatest<T>(value: T) {
  const ref = React.useRef(value);
  React.useLayoutEffect(() => {
    ref.current = value;
  });
  return ref;
}

/*
 * Where Notchset overlays (menus, popovers, tooltips) portal to. Unset, document.body, as shadcn's
 * do. Provide an element to keep them inside a scoped theme: a preview, a widget, a shadow root.
 */
export const NotchsetPortalContext = React.createContext<HTMLElement | null>(null);
export function useNotchsetPortal(): HTMLElement | undefined {
  return React.useContext(NotchsetPortalContext) ?? undefined;
}

export { NOTCHSET_ROOT, OWN_SOUND } from "@/lib/beautiful-ui/notchset/root";

// ---------------------------------------------------------------------------------------------
// Parts
// ---------------------------------------------------------------------------------------------

export type NodeState = "off" | "on" | "live" | "signal";

/** The node: a 9×9 ring whose dot blooms on (`on`), blinks (`live`) or turns signal. */
function Node({ state = "off", delay, className, style, ...props }: Omit<React.ComponentProps<"svg">, "children"> & { state?: NodeState; delay?: number }) {
  return (
    <svg
      aria-hidden
      data-slot="indicator"
      data-state={state}
      width="9"
      height="9"
      viewBox="0 0 9 9"
      className={cn(`notchset-node`, className)}
      style={delay ? ({ "--notchset-node-delay": `${delay}ms`, ...style } as React.CSSProperties) : style}
      {...props}
    >
      <circle data-slot="indicator-ring" cx="4.5" cy="4.5" r="3.25" fill="none" strokeWidth="1" />
      <circle data-slot="indicator-dot" cx="4.5" cy="4.5" />
    </svg>
  );
}

/** A small solid round mark (the node where a leader meets a popup, a pointer): SVG, round at every DPR. */
function Dot({ size = 7, className, style }: { size?: number; className?: string; style?: React.CSSProperties }) {
  return (
    <svg aria-hidden width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={cn("pointer-events-none absolute overflow-visible", className)} style={style}>
      <circle cx={size / 2} cy={size / 2} r={size / 2} fill="currentColor" />
    </svg>
  );
}

/** The legend: a mono label above a control, with an optional node (or any trailing reading). */
function Legend({ label, node, children, className, ...props }: React.ComponentProps<"div"> & { label?: React.ReactNode; node?: NodeState }) {
  return (
    <div data-slot="legend" className={cn(`notchset-legend`, className)} {...props}>
      <span data-slot="legend-label">{label}</span>
      {(children != null || node) && (
        <span data-slot="legend-reading" className="flex items-center gap-2">
          {children}
          {node && <Node state={node} />}
        </span>
      )}
    </div>
  );
}

/** A key on its plate: the plate is an offset frame behind the child; `--notchset-plate-color` tints it. */
function Plated({ className, children, ...props }: React.ComponentProps<"span">) {
  return (
    <span data-slot="plated" className={cn("relative inline-flex", className)} {...props}>
      <span aria-hidden data-slot="plate" className={`notchset-plate`} />
      {children}
    </span>
  );
}

/**
 * The success check. Mounted, it traces itself in over --notchset-check (instant under reduced
 * motion); pass `drawn` to drive the trace yourself.
 */
function Check({ drawn, size = 16 }: { drawn?: boolean; size?: number }) {
  return (
    <svg aria-hidden data-slot="check" width={size} height={size} viewBox="0 0 24 24">
      <path
        d="M4 12.5 L9.5 18 L20 6"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.75}
        strokeLinecap="square"
        pathLength={1}
        strokeDasharray={1}
        className={drawn === undefined ? `notchset-check-draw` : undefined}
        style={drawn === undefined ? undefined : { strokeDashoffset: drawn ? 0 : 1, transition: "stroke-dashoffset var(--notchset-check) var(--notchset-ease-travel)" }}
      />
    </svg>
  );
}

/** The scanner: five 1px ticks fading in turn while something works (static, centre lit, when motion is off). */
function Scanner({ height = 12 }: { height?: number }) {
  return (
    <span aria-hidden data-slot="scanner" className={cn(`notchset-scanner`, "inline-flex items-end gap-[2px]")} style={{ height }}>
      {[0.82, 0.66, 1, 0.66, 0.82].map((h, i) => (
        <span key={i} className="w-px bg-current" style={{ height: height * h, animationDelay: `${i * 140 - 700}ms` }} />
      ))}
    </span>
  );
}

// ---------------------------------------------------------------------------------------------
// Glyph flip: each character cycles through glyphs and lands at 110 + i·14ms, left to right
// ---------------------------------------------------------------------------------------------

const GLYPHS = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789#/+=";
const STILL = new Set([" ", "·", "/", ".", ":"]);

export function scramble(text: string, elapsed: number) {
  let out = "";
  for (let i = 0; i < text.length; i++) {
    const ch = text[i]!;
    out += STILL.has(ch) || elapsed >= 110 + i * 14 ? ch : GLYPHS[(Math.floor(elapsed / 36) * 5 + i * 11) % GLYPHS.length];
  }
  return out;
}

/**
 * The text to show for `text`: when it changes, a few frames of glyphs that land left to right.
 * Runs on requestAnimationFrame only while a flip is active; the settled text is never state. Off
 * under reduced motion, inside data-motion="off" (pass the element) or with `off`.
 */
export function useGlyphFlip(text: string, { off = false, el }: { off?: boolean; el?: React.RefObject<Element | null> } = {}) {
  const [frame, setFrame] = React.useState<{ for: string; shown: string } | null>(null);
  const first = React.useRef(true);
  React.useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    if (off || motionOff(el?.current)) return;
    let raf = 0;
    const t0 = performance.now();
    const end = 110 + text.length * 14 + 40;
    const step = (now: number) => {
      const elapsed = now - t0;
      if (elapsed >= end) return setFrame(null);
      setFrame({ for: text, shown: scramble(text, elapsed) });
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [text, off, el]);
  return frame && frame.for === text ? frame.shown : text;
}

/** A label that flips when its text changes; screen readers read the final text only. */
function FlipText({ children, off, className, ...props }: Omit<React.ComponentProps<"span">, "children"> & { children: string; off?: boolean }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const shown = useGlyphFlip(children, { off, el: ref });
  return (
    <span ref={ref} data-slot="flip-text" className={cn("relative whitespace-nowrap", className)} {...props}>
      <span aria-hidden>{shown}</span>
      <span className="sr-only">{children}</span>
    </span>
  );
}

// ---------------------------------------------------------------------------------------------
// Sound: Notchset components announce cues; the page's beautiful-ui-sound layer (opt-in) plays them.
// ---------------------------------------------------------------------------------------------

/** The Notchset cues, in the shared sound layer's names (played in the mechanical voice). */
export const NOTCHSET_CUES = { tap: "tap", latch: "lock", signal: "destructive", confirm: "halt", done: "success", tick: "tick" } as const;
export type NotchsetCue = keyof typeof NOTCHSET_CUES;

export function playCue(el: Element | null, cue: NotchsetCue) {
  if (!el || typeof CustomEvent === "undefined" || el.closest('[data-sound="off"]')) return;
  el.dispatchEvent(new CustomEvent("beautiful-ui:sound", { bubbles: true, detail: { cue: NOTCHSET_CUES[cue] } }));
}

// ---------------------------------------------------------------------------------------------
// Shortcuts: "⌘↵" → "Meta+Enter Control+Enter" (aria-keyshortcuts)
// ---------------------------------------------------------------------------------------------

const KEY_NAMES: Record<string, string> = { "⌘": "Meta", "⌃": "Control", "⌥": "Alt", "⇧": "Shift", "↵": "Enter", "⏎": "Enter", "⎋": "Escape", "⌫": "Backspace", "⇥": "Tab", "␣": "Space" };

export function kbdToShortcut(kbd: string | undefined): string | undefined {
  if (!kbd) return undefined;
  const parts = [...kbd.replace(/\s+/g, "")].map((c) => KEY_NAMES[c] ?? c.toUpperCase());
  if (!parts.length) return undefined;
  const combo = parts.join("+");
  // ⌘ on a Mac is Ctrl elsewhere: announce both.
  return combo.includes("Meta") ? `${combo} ${combo.replace("Meta", "Control")}` : combo;
}

/**
 * A number that rolls: each digit is a 0–9 strip; the ones digit moves first and each digit to its left
 * 40ms later (620ms, a small overshoot). `delay` offsets the whole number (rows 60ms apart). Non-digits
 * (",", "$", "%") sit still. Screen readers get the plain value.
 */
function RollingNumber({ value, height = 32, delay = 0, className }: { value: string | number; height?: number; delay?: number; className?: string }) {
  const text = String(value);
  const digits = text.split("");
  const lastDigit = digits.reduce((n, d, i) => (/\d/.test(d) ? i : n), -1);
  return (
    <span className={cn("relative inline-flex overflow-hidden tabular-nums", className)} style={{ height, lineHeight: `${height}px` }}>
      <span className="sr-only">{text}</span>
      <span aria-hidden className="flex">
        {digits.map((d, i) =>
          /\d/.test(d) ? (
            <span
              key={digits.length - i}
              className="flex flex-col transition-transform duration-[620ms] ease-[cubic-bezier(.34,1.15,.5,1)]"
              style={{ transform: `translateY(${-Number(d) * height}px)`, transitionDelay: `${delay + (lastDigit - i) * 40}ms` }}
            >
              {Array.from({ length: 10 }, (_, n) => (
                <span key={n} style={{ height }}>
                  {n}
                </span>
              ))}
            </span>
          ) : (
            <span key={`s${digits.length - i}`}>{d}</span>
          ),
        )}
      </span>
    </span>
  );
}

export { Check, Dot, FlipText, Legend, Node, Plated, RollingNumber, Scanner };
```

### lib/beautiful-ui/notchset/root.ts

```tsx
/*
 * Notchset's root attributes, server-safe (no client directive), so server components can spread
 * them too. Every Notchset root (and portalled popup) carries data-notchset (the motion policy's
 * scope), plays its own cues in the mechanical voice, and keeps the page's click layer out.
 */
export const NOTCHSET_ROOT = { "data-notchset": "", "data-sound": "none", "data-sound-voice": "analogue" } as const;

/** On every interactive element: it plays its own cues, so the page's click layer adds no tap. */
export const OWN_SOUND = { "data-sound": "none" } as const;
```

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