# Rigging

> Settings page block: schema-driven sections with a sliding section nav (tabs or scroll-spy), validated inputs, a counted bio, avatar uploader, spring switches, theme radio cards and Quorum segmented controls. Any change slides up a dirty-state save bar — async onSave drives pending, error and a fading Saved chip; Reset springs every field back. Danger zone actions use Ratchet hold-to-confirm.

A shadcn/ui block from the [@crucible](https://crucibleui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/crucible/rigging).

- Page, with a live preview: https://shadcn.exchange/r/crucible/rigging
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/crucible/rigging-light-c55c3bddc14c9b7a.png
- Registry item: https://crucibleui.com/r/rigging.json
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/crucible/rigging.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://crucibleui.com/r/quorum.json`, `https://crucibleui.com/r/ratchet.json`, `https://crucibleui.com/r/use-reduced-motion.json`

## Files

### `registry/default/app/rigging/rigging.tsx`

```tsx
"use client";

import { AnimatePresence, motion, useInView, type Transition } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { Ratchet } from "@/components/crucible/ratchet";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { Quorum } from "@/components/crucible/quorum";

/* ---------------------------------------------------------------- types */

/** A single stored setting value. Avatars store an image URL (or `null`). */
export type RiggingValue = string | boolean | null;

/** All settings values, keyed by field `name`. */
export type RiggingValues = Record<string, RiggingValue>;

/** One choice in a select, segmented control or radio-card group. */
export interface RiggingOption {
  /** Stored value. */
  value: string;
  /** Visible label. */
  label: string;
  /** Optional secondary line (radio cards only). */
  description?: string;
}

interface RiggingFieldBase {
  /** Key in the values object. Must be unique across all sections. */
  name: string;
  /** Visible label. */
  label: string;
  /** Helper copy under the label. */
  description?: string;
}

/** Single-line text or email input with inline validation. */
export interface RiggingTextField extends RiggingFieldBase {
  kind: "text" | "email";
  placeholder?: string;
  /** Empty value is an error. */
  required?: boolean;
  /** Static adornment inside the input, e.g. `"@"` or a URL base. */
  prefix?: string;
  autoComplete?: string;
  /** Return an error message (or nothing) for the current value. */
  validate?: (value: string, values: RiggingValues) => string | null | undefined;
}

/** Multi-line text with a live character counter. */
export interface RiggingTextareaField extends RiggingFieldBase {
  kind: "textarea";
  placeholder?: string;
  /** Hard cap; the counter warms up as it approaches. @default 200 */
  maxLength?: number;
  /** Visible rows. @default 3 */
  rows?: number;
  required?: boolean;
  validate?: (value: string, values: RiggingValues) => string | null | undefined;
}

/** Circular avatar with click / drop to upload and remove. Stores a data URL. */
export interface RiggingAvatarField extends RiggingFieldBase {
  kind: "avatar";
  /** Name of a text field whose value supplies the fallback initials. */
  initialsFrom?: string;
  /** Reject files larger than this. @default 2 */
  maxSizeMb?: number;
}

/** On/off switch with a spring knob. */
export interface RiggingSwitchField extends RiggingFieldBase {
  kind: "switch";
}

/** Large selectable cards; `preview` draws a mini theme tile. */
export interface RiggingRadioCardsField extends RiggingFieldBase {
  kind: "radio-cards";
  options: (RiggingOption & { preview?: "system" | "light" | "dark" })[];
}

/** Native select (best-in-class a11y and mobile pickers). */
export interface RiggingSelectField extends RiggingFieldBase {
  kind: "select";
  options: RiggingOption[];
}

/** Segmented control (composes Quorum). Best with 2–4 short options. */
export interface RiggingSegmentedField extends RiggingFieldBase {
  kind: "segmented";
  options: RiggingOption[];
}

/** A hold-to-confirm action (composes Ratchet). Not a value — fires `onAction`. */
export interface RiggingActionField extends RiggingFieldBase {
  kind: "action";
  /** Button label while idle. @default "Hold to confirm" */
  actionLabel?: string;
  /** Label after the hold completes. @default "Done" */
  confirmedLabel?: string;
  /** `danger` is rust, `neutral` is graphite. @default "danger" */
  tone?: "danger" | "neutral";
  /** Hold duration in ms. @default 1400 */
  holdMs?: number;
}

export type RiggingField =
  | RiggingTextField
  | RiggingTextareaField
  | RiggingAvatarField
  | RiggingSwitchField
  | RiggingRadioCardsField
  | RiggingSelectField
  | RiggingSegmentedField
  | RiggingActionField;

/** A group of fields; one nav entry. */
export interface RiggingSection {
  /** Unique id (also used for the DOM anchor). */
  id: string;
  /** Nav label and section heading. */
  title: string;
  /** Copy under the section heading. */
  description?: string;
  /** `danger` tints the nav item and frames the fields in red. @default "default" */
  tone?: "default" | "danger";
  /** Small glyph in the nav (16px, `currentColor`). */
  icon?: React.ReactNode;
  fields: RiggingField[];
}

/** Imperative verbs exposed through the `ref` prop. */
export interface RiggingHandle {
  /** Spring every field back to the last saved values and hide the save bar. */
  reset: () => void;
  /** Replay the intro cascade. */
  replay: () => void;
  /** Run the save flow (validation → `onSave`) as if Save was pressed. */
  save: () => Promise<void>;
  /** Current values snapshot. */
  getValues: () => RiggingValues;
}

export interface RiggingProps
  extends Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title" | "onChange" | "defaultValue"> {
  /**
   * The settings schema. Each section becomes a nav entry.
   * @default Profile, Account, Notifications, Appearance and Danger zone sample sections
   */
  sections?: RiggingSection[];
  /**
   * Initial (and "last saved") values. Missing keys fall back per field kind
   * (empty string, `false`, first option, `null`).
   * @default seeded sample values when `sections` is omitted
   */
  defaultValues?: RiggingValues;
  /** Controlled values. Omit for uncontrolled use. */
  values?: RiggingValues;
  /** Fires on every edit with the full next values and the field that changed. */
  onChange?: (values: RiggingValues, name: string) => void;
  /**
   * Called with all values when Save is pressed (or ⌘/Ctrl+S) and validation
   * passes. Return a Promise to show the pending state; resolve shows the
   * "Saved" chip and makes these the new baseline, reject shows its message
   * in the bar. Note: navigating away with unsaved changes is NOT blocked —
   * wire `beforeunload` / your router guard yourself if you need that.
   */
  onSave?: (values: RiggingValues) => void | Promise<unknown>;
  /** Fires when a danger-zone (`action`) field's hold completes. */
  onAction?: (name: string) => void;
  /**
   * `"tabs"` swaps one section at a time; `"stacked"` shows every section in
   * one scroll column with scroll-spy nav (give the block a bounded height,
   * e.g. `h-[640px]`, so the column scrolls inside it).
   * @default "tabs"
   */
  layout?: "tabs" | "stacked";
  /** Page heading. @default "Settings" */
  title?: React.ReactNode;
  /** Copy under the heading. */
  description?: React.ReactNode;
  /** Accent hex for the nav indicator, focus rings, switches and Save. @default "#93c5fd" */
  accentColor?: string;
  /** Imperative handle (`reset()`, `replay()`, `save()`, `getValues()`). React 19 ref-as-prop. */
  ref?: React.Ref<RiggingHandle>;
  /** Extra classes for the root. Set a height to make the content column scroll internally. */
  className?: string;
}

/* ---------------------------------------------------------------- constants */

const DEFAULT_ACCENT = "#93c5fd";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SPRING: Transition = { type: "spring", stiffness: 420, damping: 36, mass: 0.8 };
const INSTANT: Transition = { duration: 0 };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const SAVED_DWELL = 1900;

/* ---------------------------------------------------------------- icons */

const svgProps = {
  viewBox: "0 0 24 24",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.7,
  strokeLinecap: "round" as const,
  strokeLinejoin: "round" as const,
  "aria-hidden": true,
  className: "size-4",
};

const ICONS = {
  profile: (
    <svg {...svgProps}>
      <circle cx="12" cy="8.5" r="3.5" />
      <path d="M5 19.5c1.2-3.3 3.8-5 7-5s5.8 1.7 7 5" />
    </svg>
  ),
  account: (
    <svg {...svgProps}>
      <circle cx="8" cy="12" r="3.5" />
      <path d="M11.5 12H20M17 12v3M20 12v2.5" />
    </svg>
  ),
  bell: (
    <svg {...svgProps}>
      <path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 1.5h-14z" />
      <path d="M10 20.5a2.2 2.2 0 0 0 4 0" />
    </svg>
  ),
  appearance: (
    <svg {...svgProps}>
      <circle cx="12" cy="12" r="8" />
      <path d="M12 4a8 8 0 0 1 0 16z" fill="currentColor" stroke="none" />
    </svg>
  ),
  danger: (
    <svg {...svgProps}>
      <path d="M12 4.5 20.5 19h-17z" />
      <path d="M12 10v4M12 16.6v.1" />
    </svg>
  ),
};

/* ---------------------------------------------------------------- sample data */

const SAMPLE_SECTIONS: RiggingSection[] = [
  {
    id: "profile",
    title: "Profile",
    description: "How you appear to teammates across the workspace.",
    icon: ICONS.profile,
    fields: [
      {
        kind: "avatar",
        name: "photo",
        label: "Photo",
        description: "PNG or JPG up to 2 MB. Click or drop an image.",
        initialsFrom: "displayName",
      },
      {
        kind: "text",
        name: "displayName",
        label: "Display name",
        description: "Shown on comments, mentions and shared docs.",
        required: true,
        autoComplete: "name",
      },
      {
        kind: "email",
        name: "email",
        label: "Email",
        description: "Used for sign-in and notifications.",
        required: true,
        autoComplete: "email",
      },
      {
        kind: "textarea",
        name: "bio",
        label: "Bio",
        description: "A sentence or two about what you work on.",
        maxLength: 160,
        placeholder: "What are you working on?",
      },
    ],
  },
  {
    id: "account",
    title: "Account",
    description: "Your handle, language and regional formats.",
    icon: ICONS.account,
    fields: [
      {
        kind: "text",
        name: "username",
        label: "Username",
        description: "Your unique handle for mentions and profile links.",
        prefix: "@",
        required: true,
        autoComplete: "username",
        validate: (v) =>
          /^[a-z0-9-]{3,20}$/.test(v) ? null : "Use 3–20 lowercase letters, numbers or dashes.",
      },
      {
        kind: "select",
        name: "language",
        label: "Language",
        options: [
          { value: "en-US", label: "English (US)" },
          { value: "en-GB", label: "English (UK)" },
          { value: "de", label: "Deutsch" },
          { value: "es", label: "Español" },
          { value: "fr", label: "Français" },
          { value: "ja", label: "日本語" },
        ],
      },
      {
        kind: "select",
        name: "timezone",
        label: "Time zone",
        description: "Used for reminders and due dates.",
        options: [
          { value: "America/Los_Angeles", label: "Pacific Time (UTC−08:00)" },
          { value: "America/New_York", label: "Eastern Time (UTC−05:00)" },
          { value: "Europe/London", label: "London (UTC+00:00)" },
          { value: "Europe/Berlin", label: "Central Europe (UTC+01:00)" },
          { value: "Asia/Singapore", label: "Singapore (UTC+08:00)" },
          { value: "Asia/Tokyo", label: "Tokyo (UTC+09:00)" },
        ],
      },
      {
        kind: "segmented",
        name: "weekStart",
        label: "Week starts on",
        options: [
          { value: "sat", label: "Sat" },
          { value: "sun", label: "Sun" },
          { value: "mon", label: "Mon" },
        ],
      },
    ],
  },
  {
    id: "notifications",
    title: "Notifications",
    description: "Choose what reaches your inbox. Security alerts always do.",
    icon: ICONS.bell,
    fields: [
      {
        kind: "switch",
        name: "mentions",
        label: "Mentions & replies",
        description: "When someone mentions you or replies to your thread.",
      },
      {
        kind: "switch",
        name: "assignments",
        label: "Assignments",
        description: "When a task or review is assigned to you.",
      },
      {
        kind: "switch",
        name: "digest",
        label: "Weekly digest",
        description: "A Monday summary of activity in your projects.",
      },
      {
        kind: "switch",
        name: "productNews",
        label: "Product updates",
        description: "New features and improvements, about once a month.",
      },
      {
        kind: "segmented",
        name: "cadence",
        label: "Email cadence",
        description: "Batch non-urgent notifications.",
        options: [
          { value: "instant", label: "Instant" },
          { value: "hourly", label: "Hourly" },
          { value: "daily", label: "Daily" },
        ],
      },
    ],
  },
  {
    id: "appearance",
    title: "Appearance",
    description: "Tune how the interface looks on this device.",
    icon: ICONS.appearance,
    fields: [
      {
        kind: "radio-cards",
        name: "theme",
        label: "Theme",
        description: "System follows your OS setting.",
        options: [
          { value: "system", label: "System", preview: "system" },
          { value: "light", label: "Light", preview: "light" },
          { value: "dark", label: "Dark", preview: "dark" },
        ],
      },
      {
        kind: "segmented",
        name: "density",
        label: "Density",
        description: "Spacing in lists and tables.",
        options: [
          { value: "compact", label: "Compact" },
          { value: "default", label: "Default" },
          { value: "roomy", label: "Roomy" },
        ],
      },
      {
        kind: "switch",
        name: "keyboardHints",
        label: "Keyboard hints",
        description: "Show shortcut badges next to menu items.",
      },
    ],
  },
  {
    id: "danger",
    title: "Danger zone",
    description: "Irreversible actions. Take a breath first.",
    tone: "danger",
    icon: ICONS.danger,
    fields: [
      {
        kind: "action",
        name: "signOutEverywhere",
        label: "Sign out everywhere",
        description: "End every session except this one.",
        actionLabel: "Hold to sign out",
        confirmedLabel: "Signed out",
        tone: "neutral",
      },
      {
        kind: "action",
        name: "deleteAccount",
        label: "Delete account",
        description: "Permanently erase your account, projects and history. This can't be undone.",
        actionLabel: "Hold to delete",
        confirmedLabel: "Deletion queued",
        tone: "danger",
      },
    ],
  },
];

const SAMPLE_VALUES: RiggingValues = {
  photo: null,
  displayName: "Rowan Ellery",
  email: "rowan.ellery@example.com",
  bio: "Design engineer on the platform team. Mostly interfaces, occasionally typefaces.",
  username: "rowan-e",
  language: "en-US",
  timezone: "Europe/London",
  weekStart: "mon",
  mentions: true,
  assignments: true,
  digest: false,
  productNews: false,
  cadence: "hourly",
  theme: "dark",
  density: "default",
  keyboardHints: true,
};

/* ---------------------------------------------------------------- helpers */

/** Event-time clock (only called from handlers/effects). */
const nowMs = () => performance.now();

/** Black or white text, whichever reads against the accent fill. */
function readableOn(hex: string): string {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return "#0a0a0a";
  let s = m[1];
  if (s.length === 3) s = s.replace(/./g, (c) => c + c);
  const n = parseInt(s, 16);
  const lum = 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
  return lum > 140 ? "#0a0a0a" : "#ffffff";
}

function fallbackValue(field: RiggingField): RiggingValue {
  switch (field.kind) {
    case "switch":
      return false;
    case "avatar":
      return null;
    case "select":
    case "segmented":
    case "radio-cards":
      return field.options[0]?.value ?? "";
    default:
      return "";
  }
}

function buildValues(sections: RiggingSection[], defaults: RiggingValues | undefined): RiggingValues {
  const out: RiggingValues = {};
  for (const section of sections) {
    for (const field of section.fields) {
      if (field.kind === "action") continue;
      out[field.name] = defaults && field.name in defaults ? defaults[field.name] : fallbackValue(field);
    }
  }
  return out;
}

function validateField(field: RiggingField, value: RiggingValue, values: RiggingValues): string | null {
  if (field.kind === "text" || field.kind === "email" || field.kind === "textarea") {
    const v = typeof value === "string" ? value : "";
    if (field.required && !v.trim()) return `${field.label} is required.`;
    if (field.kind === "email" && v.trim() && !EMAIL_RE.test(v.trim())) return "Enter a valid email address.";
    if (field.kind === "textarea" && v.length > (field.maxLength ?? 200)) return "Too long.";
    if (field.validate && v.trim()) return field.validate(v, values) ?? null;
  }
  return null;
}

function initialsOf(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return "?";
  return ((parts[0][0] ?? "") + (parts.length > 1 ? (parts[parts.length - 1][0] ?? "") : "")).toUpperCase();
}

/* ---------------------------------------------------------------- small pieces */

function Spinner() {
  return (
    <svg aria-hidden viewBox="0 0 24 24" className="size-3.5 motion-safe:animate-spin" fill="none">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}

function CheckGlyph({ className }: { className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 24 24" className={className} fill="none">
      <path d="M5 12.5l4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/** Tiny app window drawn in the given theme, for the Appearance cards. */
function MiniWindow({ tone }: { tone: "light" | "dark" }) {
  const c =
    tone === "light"
      ? { bg: "#f4f4f5", side: "#e4e4e7", line: "#d4d4d8", strong: "#a1a1aa" }
      : { bg: "#18181b", side: "#232327", line: "#34343a", strong: "#52525b" };
  return (
    <div className="absolute inset-0 flex" style={{ background: c.bg }}>
      <div className="flex w-[28%] flex-col gap-1 p-1.5" style={{ background: c.side }}>
        <span className="h-1 w-3/4 rounded-full" style={{ background: c.strong }} />
        <span className="h-1 w-1/2 rounded-full" style={{ background: c.line }} />
        <span className="h-1 w-2/3 rounded-full" style={{ background: c.line }} />
      </div>
      <div className="flex flex-1 flex-col gap-1.5 p-2">
        <span className="h-1.5 w-1/2 rounded-full" style={{ background: c.strong }} />
        <span className="h-1 w-5/6 rounded-full" style={{ background: c.line }} />
        <span className="h-1 w-2/3 rounded-full" style={{ background: c.line }} />
        <span className="mt-auto h-2.5 w-8 rounded-[3px]" style={{ background: "var(--rg-accent)" }} />
      </div>
    </div>
  );
}

function ThemePreview({ preview }: { preview: "system" | "light" | "dark" }) {
  if (preview === "system") {
    return (
      <>
        <MiniWindow tone="light" />
        <div className="absolute inset-0" style={{ clipPath: "polygon(62% 0, 100% 0, 100% 100%, 38% 100%)" }}>
          <MiniWindow tone="dark" />
        </div>
      </>
    );
  }
  return <MiniWindow tone={preview} />;
}

/* ---------------------------------------------------------------- field controls */

interface ControlCtx {
  id: string;
  labelId: string;
  descId?: string;
  errId: string;
  error: string | null;
  showError: boolean;
  reduced: boolean;
  accent: string;
  ink: string;
  values: RiggingValues;
  setValue: (name: string, value: RiggingValue) => void;
  touch: (name: string) => void;
  onAction?: (name: string) => void;
  /** Report a non-value error (avatar file too large). */
  setLocalError: (name: string, msg: string | null) => void;
}

const inputBase = cn(
  "w-full rounded-lg border bg-white/[0.03] px-3 text-sm text-white placeholder:text-white/30",
  "transition-[border-color,box-shadow,background-color] duration-150 outline-none",
  "hover:bg-white/[0.045] focus:bg-white/[0.04]",
  "focus:border-[var(--rg-accent)] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--rg-accent)_22%,transparent)]"
);

function TextControl({ field, ctx }: { field: RiggingTextField; ctx: ControlCtx }) {
  const value = String(ctx.values[field.name] ?? "");
  const invalid = ctx.showError && !!ctx.error;
  return (
    <div
      className={cn(
        inputBase,
        "flex h-9 items-center gap-0 px-0 focus-within:border-[var(--rg-accent)] focus-within:bg-white/[0.04]",
        "focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--rg-accent)_22%,transparent)]",
        invalid ? "border-red-400/60" : "border-white/10"
      )}
    >
      {field.prefix && (
        <span aria-hidden className="flex h-full items-center border-r border-white/[0.08] pl-3 pr-2.5 text-sm text-white/35">
          {field.prefix}
        </span>
      )}
      <input
        id={ctx.id}
        type={field.kind === "email" ? "email" : "text"}
        value={value}
        placeholder={field.placeholder}
        autoComplete={field.autoComplete}
        spellCheck={field.kind === "email" ? false : undefined}
        aria-required={field.required || undefined}
        aria-invalid={invalid || undefined}
        aria-describedby={cn(ctx.descId, invalid && ctx.errId) || undefined}
        onChange={(e) => ctx.setValue(field.name, e.target.value)}
        onBlur={() => ctx.touch(field.name)}
        className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm text-white outline-none placeholder:text-white/30"
      />
    </div>
  );
}

function TextareaControl({ field, ctx }: { field: RiggingTextareaField; ctx: ControlCtx }) {
  const value = String(ctx.values[field.name] ?? "");
  const max = field.maxLength ?? 200;
  const ratio = value.length / max;
  const invalid = ctx.showError && !!ctx.error;
  const counterId = `${ctx.id}-count`;
  return (
    <div className="relative">
      <textarea
        id={ctx.id}
        value={value}
        rows={field.rows ?? 3}
        maxLength={max}
        placeholder={field.placeholder}
        aria-required={field.required || undefined}
        aria-invalid={invalid || undefined}
        aria-describedby={cn(ctx.descId, counterId, invalid && ctx.errId) || undefined}
        onChange={(e) => ctx.setValue(field.name, e.target.value)}
        onBlur={() => ctx.touch(field.name)}
        className={cn(inputBase, "block resize-none py-2 pb-6 leading-relaxed", invalid ? "border-red-400/60" : "border-white/10")}
      />
      <span
        id={counterId}
        aria-label={`${value.length} of ${max} characters`}
        className={cn(
          "pointer-events-none absolute bottom-1.5 right-2.5 text-[11px] tabular-nums transition-colors duration-200",
          ratio >= 1 ? "text-red-300" : ratio >= 0.85 ? "text-amber-200/90" : "text-white/30"
        )}
      >
        {value.length}
        <span className="text-white/20"> / {max}</span>
      </span>
    </div>
  );
}

function AvatarControl({ field, ctx }: { field: RiggingAvatarField; ctx: ControlCtx }) {
  const src = (ctx.values[field.name] as string | null) ?? null;
  const inputRef = React.useRef<HTMLInputElement>(null);
  const [dragging, setDragging] = React.useState(false);
  const initials = initialsOf(String((field.initialsFrom && ctx.values[field.initialsFrom]) || ""));
  const maxMb = field.maxSizeMb ?? 2;

  const accept = (file: File | undefined) => {
    if (!file) return;
    if (!file.type.startsWith("image/")) {
      ctx.setLocalError(field.name, "That file isn't an image.");
      return;
    }
    if (file.size > maxMb * 1024 * 1024) {
      ctx.setLocalError(field.name, `Images must be under ${maxMb} MB.`);
      return;
    }
    const reader = new FileReader();
    reader.onload = () => {
      ctx.setLocalError(field.name, null);
      ctx.setValue(field.name, typeof reader.result === "string" ? reader.result : null);
    };
    reader.readAsDataURL(file);
  };

  const fade: Transition = ctx.reduced ? { duration: 0.15 } : { duration: 0.35, ease: EASE };

  return (
    <div className="flex items-center gap-4">
      <button
        type="button"
        id={ctx.id}
        aria-label={src ? "Change photo" : "Upload photo"}
        aria-describedby={cn(ctx.descId, ctx.error && ctx.errId) || undefined}
        onClick={() => inputRef.current?.click()}
        onDragOver={(e) => {
          e.preventDefault();
          setDragging(true);
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragging(false);
          accept(e.dataTransfer.files?.[0]);
        }}
        className={cn(
          "group/av relative size-16 shrink-0 overflow-hidden rounded-full border bg-neutral-900 outline-none",
          "focus-visible:ring-2 focus-visible:ring-[var(--rg-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
          dragging ? "border-[var(--rg-accent)]" : "border-white/10"
        )}
      >
        <AnimatePresence initial={false}>
          {src ? (
            <motion.img
              key={src.slice(-48)}
              src={src}
              alt=""
              initial={{ opacity: 0, scale: ctx.reduced ? 1 : 1.08 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0 }}
              transition={fade}
              className="absolute inset-0 size-full object-cover"
            />
          ) : (
            <motion.span
              key="initials"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={fade}
              className="absolute inset-0 flex items-center justify-center text-lg font-semibold tracking-wide"
              style={{
                color: "var(--rg-accent)",
                background:
                  "radial-gradient(120% 120% at 30% 20%, color-mix(in oklab, var(--rg-accent) 26%, #18181b), #121214 70%)",
              }}
            >
              {initials}
            </motion.span>
          )}
        </AnimatePresence>
        <span
          aria-hidden
          className="absolute inset-0 flex items-center justify-center bg-black/55 text-[11px] font-medium text-white opacity-0 transition-opacity duration-150 group-hover/av:opacity-100 group-focus-visible/av:opacity-100"
        >
          {src ? "Change" : "Upload"}
        </span>
      </button>
      <div className="flex flex-wrap items-center gap-2">
        <button
          type="button"
          onClick={() => inputRef.current?.click()}
          className="h-8 rounded-lg border border-white/10 bg-white/[0.04] px-3 text-[13px] font-medium text-white/85 transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--rg-accent)]"
        >
          {src ? "Replace" : "Upload"}
        </button>
        {src && (
          <button
            type="button"
            onClick={() => {
              ctx.setLocalError(field.name, null);
              ctx.setValue(field.name, null);
            }}
            className="h-8 rounded-lg px-3 text-[13px] font-medium text-white/50 transition-colors hover:text-white/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--rg-accent)]"
          >
            Remove
          </button>
        )}
      </div>
      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        tabIndex={-1}
        aria-hidden
        className="sr-only"
        onChange={(e) => {
          accept(e.target.files?.[0]);
          e.target.value = "";
        }}
      />
    </div>
  );
}

function SwitchControl({ field, ctx }: { field: RiggingSwitchField; ctx: ControlCtx }) {
  const on = ctx.values[field.name] === true;
  return (
    <button
      type="button"
      role="switch"
      id={ctx.id}
      aria-checked={on}
      aria-labelledby={ctx.labelId}
      aria-describedby={ctx.descId}
      onClick={() => ctx.setValue(field.name, !on)}
      className={cn(
        "relative inline-flex h-6 w-[42px] shrink-0 items-center rounded-full border outline-none transition-[background-color,border-color] duration-200",
        "focus-visible:ring-2 focus-visible:ring-[var(--rg-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
      )}
      style={{
        background: on ? "var(--rg-accent)" : "rgba(255,255,255,0.08)",
        borderColor: on ? "var(--rg-accent)" : "rgba(255,255,255,0.1)",
      }}
    >
      <motion.span
        aria-hidden
        className="block size-[18px] rounded-full"
        initial={false}
        animate={{ x: on ? 20 : 2 }}
        transition={ctx.reduced ? INSTANT : { type: "spring", stiffness: 560, damping: 34 }}
        style={{
          background: on ? ctx.ink : "#e4e4e7",
          boxShadow: "0 1px 2px rgba(0,0,0,0.45), 0 0 0 0.5px rgba(0,0,0,0.2)",
        }}
      />
    </button>
  );
}

function SelectControl({ field, ctx }: { field: RiggingSelectField; ctx: ControlCtx }) {
  const value = String(ctx.values[field.name] ?? "");
  return (
    <div className="relative">
      <select
        id={ctx.id}
        value={value}
        aria-describedby={ctx.descId}
        onChange={(e) => ctx.setValue(field.name, e.target.value)}
        className={cn(inputBase, "h-9 cursor-pointer appearance-none border-white/10 pr-9 [&>option]:bg-neutral-900")}
      >
        {field.options.map((o) => (
          <option key={o.value} value={o.value}>
            {o.label}
          </option>
        ))}
      </select>
      <svg
        aria-hidden
        viewBox="0 0 24 24"
        className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 text-white/40"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d="m7 10 5 5 5-5" />
      </svg>
    </div>
  );
}

function RadioCardsControl({ field, ctx }: { field: RiggingRadioCardsField; ctx: ControlCtx }) {
  const value = String(ctx.values[field.name] ?? "");
  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const selectedIndex = Math.max(0, field.options.findIndex((o) => o.value === value));

  const onKeyDown = (e: React.KeyboardEvent) => {
    const n = field.options.length;
    let next = -1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (selectedIndex + 1) % n;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (selectedIndex - 1 + n) % n;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = n - 1;
    if (next < 0) return;
    e.preventDefault();
    ctx.setValue(field.name, field.options[next].value);
    refs.current[next]?.focus();
  };

  return (
    <div
      role="radiogroup"
      aria-labelledby={ctx.labelId}
      aria-describedby={ctx.descId}
      onKeyDown={onKeyDown}
      className="grid grid-cols-3 gap-2.5"
    >
      {field.options.map((o, i) => {
        const selected = i === selectedIndex;
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            id={i === 0 ? ctx.id : undefined}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            onClick={() => ctx.setValue(field.name, o.value)}
            className={cn(
              "group/rc relative flex flex-col gap-2 rounded-xl border p-1.5 pb-2 text-left outline-none transition-[border-color,background-color] duration-200",
              "focus-visible:ring-2 focus-visible:ring-[var(--rg-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
              selected ? "bg-white/[0.04]" : "border-white/[0.08] hover:border-white/20 hover:bg-white/[0.02]"
            )}
            style={selected ? { borderColor: "color-mix(in oklab, var(--rg-accent) 75%, transparent)" } : undefined}
          >
            {o.preview && (
              <span aria-hidden className="relative block aspect-[16/10] w-full overflow-hidden rounded-lg border border-white/[0.06]">
                <ThemePreview preview={o.preview} />
              </span>
            )}
            <span className="flex items-center justify-between gap-1 px-1">
              <span className={cn("text-[13px] font-medium transition-colors", selected ? "text-white" : "text-white/60")}>
                {o.label}
              </span>
              <span
                aria-hidden
                className={cn(
                  "flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors duration-200",
                  selected ? "border-transparent" : "border-white/20"
                )}
                style={selected ? { background: "var(--rg-accent)", color: ctx.ink } : undefined}
              >
                <AnimatePresence initial={false}>
                  {selected && (
                    <motion.span
                      initial={{ scale: ctx.reduced ? 1 : 0.4, opacity: 0 }}
                      animate={{ scale: 1, opacity: 1 }}
                      exit={{ opacity: 0 }}
                      transition={ctx.reduced ? { duration: 0.12 } : SPRING}
                      className="flex"
                    >
                      <CheckGlyph className="size-2.5" />
                    </motion.span>
                  )}
                </AnimatePresence>
              </span>
            </span>
            {o.description && <span className="px-1 text-[11px] leading-snug text-white/40">{o.description}</span>}
          </button>
        );
      })}
    </div>
  );
}

function SegmentedControl({ field, ctx }: { field: RiggingSegmentedField; ctx: ControlCtx }) {
  const value = String(ctx.values[field.name] ?? field.options[0]?.value ?? "");
  return (
    <Quorum
      id={ctx.id}
      options={field.options.map((o) => ({ value: o.value, label: o.label }))}
      value={value}
      onChange={(v) => ctx.setValue(field.name, v)}
      aria-labelledby={ctx.labelId}
      aria-describedby={ctx.descId}
      accent={ctx.accent}
      size="sm"
      paused={ctx.reduced}
    />
  );
}

function ActionControl({ field, ctx }: { field: RiggingActionField; ctx: ControlCtx }) {
  return (
    <Ratchet
      id={ctx.id}
      tone={field.tone ?? "danger"}
      holdMs={field.holdMs ?? 1400}
      confirmedLabel={field.confirmedLabel ?? "Done"}
      onConfirm={() => ctx.onAction?.(field.name)}
      aria-labelledby={`${ctx.labelId} ${ctx.id}`}
      className="h-12 pr-5 text-[13px]"
    >
      {field.actionLabel ?? "Hold to confirm"}
    </Ratchet>
  );
}

/* ---------------------------------------------------------------- field row */

function FieldRow({
  field,
  ctx,
  dirty,
  flashKey,
  index,
  intro,
  inView,
}: {
  field: RiggingField;
  ctx: ControlCtx;
  dirty: boolean;
  flashKey: number;
  index: number;
  intro: boolean;
  inView: boolean;
}) {
  const inline = field.kind === "switch";
  const labelEl =
    field.kind === "switch" || field.kind === "radio-cards" || field.kind === "segmented" || field.kind === "action" ? (
      <span id={ctx.labelId} className="text-sm font-medium text-white/90">
        {field.label}
      </span>
    ) : (
      <label id={ctx.labelId} htmlFor={ctx.id} className="text-sm font-medium text-white/90">
        {field.label}
      </label>
    );

  const control = (() => {
    switch (field.kind) {
      case "text":
      case "email":
        return <TextControl field={field} ctx={ctx} />;
      case "textarea":
        return <TextareaControl field={field} ctx={ctx} />;
      case "avatar":
        return <AvatarControl field={field} ctx={ctx} />;
      case "switch":
        return <SwitchControl field={field} ctx={ctx} />;
      case "select":
        return <SelectControl field={field} ctx={ctx} />;
      case "radio-cards":
        return <RadioCardsControl field={field} ctx={ctx} />;
      case "segmented":
        return <SegmentedControl field={field} ctx={ctx} />;
      case "action":
        return <ActionControl field={field} ctx={ctx} />;
    }
  })();

  const showErr = ctx.showError && !!ctx.error;

  return (
    <motion.div
      initial={intro && !ctx.reduced ? { opacity: 0, y: 8 } : false}
      animate={inView || !intro || ctx.reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 8 }}
      transition={ctx.reduced ? INSTANT : { duration: 0.45, ease: EASE, delay: intro ? 0.12 + index * 0.045 : 0 }}
      className={cn(
        "relative py-4",
        inline
          ? "flex items-center justify-between gap-6"
          : field.kind === "action"
            ? "flex flex-wrap items-center justify-between gap-x-6 gap-y-3"
            : "grid gap-3 @lg/panel:grid-cols-[minmax(0,1fr)_minmax(0,1.45fr)] @lg/panel:gap-8"
      )}
    >
      {flashKey > 0 && (
        <motion.span
          key={flashKey}
          aria-hidden
          className="pointer-events-none absolute -inset-x-3 inset-y-1 rounded-lg"
          style={{ background: "color-mix(in oklab, var(--rg-accent) 12%, transparent)" }}
          initial={{ opacity: 1 }}
          animate={{ opacity: 0 }}
          transition={{ duration: 0.9, ease: "easeOut" }}
        />
      )}
      <div className={cn("relative flex min-w-0 flex-col gap-1", field.kind === "action" && "flex-[1_1_240px]")}>
        <div className="flex items-center gap-2">
          {labelEl}
          <AnimatePresence initial={false}>
            {dirty && (
              <motion.span
                aria-hidden
                initial={{ scale: 0, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                exit={{ scale: 0, opacity: 0 }}
                transition={ctx.reduced ? { duration: 0.12 } : SPRING}
                className="size-1.5 rounded-full"
                style={{ background: "var(--rg-accent)" }}
              />
            )}
          </AnimatePresence>
          {dirty && <span className="sr-only">(modified)</span>}
        </div>
        {field.description && (
          <p id={ctx.descId} className="text-[13px] leading-snug text-white/45">
            {field.description}
          </p>
        )}
      </div>
      <div className={cn("relative min-w-0", (inline || field.kind === "action") && "shrink-0")}>
        {control}
        <AnimatePresence initial={false}>
          {(showErr || (field.kind === "avatar" && ctx.error)) && (
            <motion.p
              id={ctx.errId}
              initial={ctx.reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
              animate={ctx.reduced ? { opacity: 1 } : { opacity: 1, height: "auto" }}
              exit={ctx.reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
              transition={{ duration: 0.2, ease: EASE }}
              className="overflow-hidden"
            >
              <span className="flex items-center gap-1.5 pt-1.5 text-[12.5px] text-red-300">
                <svg aria-hidden viewBox="0 0 24 24" className="size-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2">
                  <circle cx="12" cy="12" r="9" />
                  <path d="M12 7.5v5.5M12 16.4v.1" strokeLinecap="round" />
                </svg>
                {ctx.error}
              </span>
            </motion.p>
          )}
        </AnimatePresence>
      </div>
    </motion.div>
  );
}

/* ---------------------------------------------------------------- main */

type SaveStatus = "idle" | "saving" | "saved" | "error";

/**
 * Rigging — a settings page block. A schema of sections renders as a left
 * section nav (a horizontal tab strip under 640px) with a spring-sliding
 * active pill, and either one section at a time (`layout="tabs"`, a real
 * tablist with roving arrow keys) or every section stacked with scroll-spy.
 * Fields: validated text/email, a counted textarea, an avatar uploader
 * (click or drop → crossfading preview circle), spring switches, theme radio
 * cards with mini window previews, native selects and Quorum segmented
 * controls. Any edit marks the field and its nav entry with an accent dot and
 * slides up a floating save bar; Save (or ⌘/Ctrl+S) validates, awaits
 * `onSave`, then collapses the bar into a "Saved" chip that fades; Reset
 * springs every field back with a brief highlight. Danger-zone actions use
 * Ratchet hold-to-confirm. Leaving with unsaved changes is not blocked.
 * Reduced motion: final layout, crossfades only.
 */
export function Rigging({
  sections: sectionsProp,
  defaultValues,
  values: valuesProp,
  onChange,
  onSave,
  onAction,
  layout = "tabs",
  title = "Settings",
  description = "Manage your profile, notifications and preferences.",
  accentColor = DEFAULT_ACCENT,
  ref,
  className,
  style,
  onKeyDown,
  ...rest
}: RiggingProps) {
  const reduced = useReducedMotion();
  const uid = React.useId();
  const sections = sectionsProp ?? SAMPLE_SECTIONS;
  const defaults = defaultValues ?? (sectionsProp ? undefined : SAMPLE_VALUES);
  const ink = readableOn(accentColor);

  const [baseline, setBaseline] = React.useState<RiggingValues>(() => buildValues(sections, defaults));
  const [inner, setInner] = React.useState<RiggingValues>(baseline);
  const values = React.useMemo(
    () => (valuesProp ? { ...baseline, ...valuesProp } : inner),
    [valuesProp, baseline, inner]
  );
  const valuesRef = React.useRef(values);
  React.useEffect(() => {
    valuesRef.current = values;
  }, [values]);

  const [touched, setTouched] = React.useState<Set<string>>(() => new Set());
  const [showAll, setShowAll] = React.useState(false);
  const [localErrors, setLocalErrors] = React.useState<Record<string, string | null>>({});
  const [status, setStatus] = React.useState<SaveStatus>("idle");
  const [saveError, setSaveError] = React.useState<string | null>(null);
  const [announce, setAnnounce] = React.useState("");
  const [flash, setFlash] = React.useState<{ tick: number; names: Set<string> }>({ tick: 0, names: new Set() });
  const [activeId, setActiveId] = React.useState(sections[0]?.id ?? "");
  const active = sections.find((s) => s.id === activeId) ?? sections[0];

  // Intro: cascade once when in view; replay() re-keys it.
  const rootRef = React.useRef<HTMLElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { once: true, amount: 0.15 });
  const [introKey, setIntroKey] = React.useState(0);
  const [intro, setIntro] = React.useState(true);
  React.useEffect(() => {
    if (!inView || !intro) return;
    const t = window.setTimeout(() => setIntro(false), 950);
    return () => window.clearTimeout(t);
  }, [inView, intro, introKey]);

  /* ----- derived */
  const allFields = React.useMemo(() => sections.flatMap((s) => s.fields), [sections]);
  const dirtyNames = React.useMemo(() => {
    const out = new Set<string>();
    for (const f of allFields) {
      if (f.kind === "action") continue;
      if (values[f.name] !== baseline[f.name]) out.add(f.name);
    }
    return out;
  }, [allFields, values, baseline]);
  const dirty = dirtyNames.size > 0;
  const errors = React.useMemo(() => {
    const out: Record<string, string | null> = {};
    for (const f of allFields) out[f.name] = localErrors[f.name] ?? validateField(f, values[f.name], values);
    return out;
  }, [allFields, values, localErrors]);
  const invalidCount = allFields.filter((f) => f.kind !== "avatar" && errors[f.name]).length;

  /* ----- mutations */
  const setValue = React.useCallback(
    (name: string, value: RiggingValue) => {
      const next = { ...valuesRef.current, [name]: value };
      valuesRef.current = next;
      if (!valuesProp) setInner(next);
      onChange?.(next, name);
      setStatus((s) => (s === "saved" || s === "error" ? "idle" : s));
    },
    [valuesProp, onChange]
  );

  const touch = React.useCallback((name: string) => {
    setTouched((t) => (t.has(name) ? t : new Set(t).add(name)));
  }, []);

  const setLocalError = React.useCallback((name: string, msg: string | null) => {
    setLocalErrors((e) => ({ ...e, [name]: msg }));
  }, []);

  const pendingFocus = React.useRef<string | null>(null);

  const reset = React.useCallback(() => {
    const changed = new Set<string>();
    for (const f of allFields) {
      if (f.kind !== "action" && valuesRef.current[f.name] !== baseline[f.name]) changed.add(f.name);
    }
    valuesRef.current = baseline;
    if (!valuesProp) setInner(baseline);
    if (changed.size) onChange?.(baseline, "");
    setTouched(new Set());
    setShowAll(false);
    setLocalErrors({});
    setStatus("idle");
    setSaveError(null);
    setFlash((f) => ({ tick: f.tick + 1, names: changed }));
    if (changed.size) setAnnounce("Changes discarded");
  }, [allFields, baseline, valuesProp, onChange]);

  const save = React.useCallback(async () => {
    if (status === "saving") return;
    const snapshot = valuesRef.current;
    const firstBad = allFields.find((f) => f.kind !== "avatar" && validateField(f, snapshot[f.name], snapshot));
    if (firstBad) {
      setShowAll(true);
      const count = allFields.filter((f) => f.kind !== "avatar" && validateField(f, snapshot[f.name], snapshot)).length;
      setAnnounce(`Fix ${count} ${count === 1 ? "field" : "fields"} before saving`);
      const owner = sections.find((s) => s.fields.includes(firstBad));
      if (owner && layout === "tabs") setActiveId(owner.id);
      pendingFocus.current = firstBad.name;
      return;
    }
    setStatus("saving");
    setSaveError(null);
    setAnnounce("Saving…");
    try {
      await onSave?.(snapshot);
      setBaseline(snapshot);
      setTouched(new Set());
      setShowAll(false);
      setStatus("saved");
      setAnnounce("Settings saved");
    } catch (err) {
      const msg = err instanceof Error && err.message ? err.message : "Couldn't save. Try again.";
      setSaveError(msg);
      setStatus("error");
      setAnnounce(msg);
    }
  }, [status, allFields, sections, layout, onSave]);

  // Focus the first invalid field once its section is on screen.
  React.useEffect(() => {
    const name = pendingFocus.current;
    if (!name) return;
    pendingFocus.current = null;
    const id = `${uid}-f-${name}`;
    const raf = requestAnimationFrame(() => {
      const el = document.getElementById(id);
      el?.focus();
      el?.scrollIntoView({ block: "center", behavior: reduced ? "auto" : "smooth" });
    });
    return () => cancelAnimationFrame(raf);
  });

  // "Saved" chip fades on its own.
  React.useEffect(() => {
    if (status !== "saved") return;
    const t = window.setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), SAVED_DWELL);
    return () => window.clearTimeout(t);
  }, [status]);

  React.useImperativeHandle(
    ref,
    () => ({
      reset,
      save,
      replay: () => {
        setIntro(true);
        setIntroKey((k) => k + 1);
      },
      getValues: () => valuesRef.current,
    }),
    [reset, save]
  );

  /* ----- navigation */
  const navRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const spyLock = React.useRef(0);

  const goTo = (id: string, focusTab = false) => {
    setActiveId(id);
    if (layout === "tabs") {
      contentRef.current?.scrollTo({ top: 0 });
      if (focusTab) navRefs.current[sections.findIndex((s) => s.id === id)]?.focus();
      return;
    }
    const sectionEl = document.getElementById(`${uid}-s-${id}`);
    const scroller = contentRef.current;
    if (!sectionEl || !scroller) return;
    spyLock.current = nowMs() + 700;
    const behavior: ScrollBehavior = reduced ? "auto" : "smooth";
    if (scroller.scrollHeight > scroller.clientHeight + 1) {
      const top = sectionEl.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 8;
      scroller.scrollTo({ top, behavior });
    } else {
      sectionEl.scrollIntoView({ block: "start", behavior });
    }
    if (focusTab) navRefs.current[sections.findIndex((s) => s.id === id)]?.focus();
  };

  const onNavKeyDown = (e: React.KeyboardEvent, index: number) => {
    const n = sections.length;
    let next = -1;
    if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (index + 1) % n;
    else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (index - 1 + n) % n;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = n - 1;
    if (next < 0) return;
    e.preventDefault();
    goTo(sections[next].id, true);
  };

  // Keep the active tab visible in the narrow horizontal strip.
  React.useEffect(() => {
    const el = navRefs.current[sections.findIndex((s) => s.id === activeId)];
    const strip = el?.parentElement;
    if (!el || !strip || strip.scrollWidth <= strip.clientWidth + 1) return;
    const left = el.offsetLeft - (strip.clientWidth - el.offsetWidth) / 2;
    strip.scrollTo({ left: Math.max(0, left), behavior: reduced ? "auto" : "smooth" });
  }, [activeId, sections, reduced]);

  // Scroll-spy for the stacked layout.
  React.useEffect(() => {
    if (layout !== "stacked") return;
    const scroller = contentRef.current;
    if (!scroller) return;
    let raf = 0;
    const measure = () => {
      raf = 0;
      if (nowMs() < spyLock.current) return;
      const top = Math.max(scroller.getBoundingClientRect().top, 0);
      const line = top + 96;
      let current = sections[0]?.id;
      for (const s of sections) {
        const el = document.getElementById(`${uid}-s-${s.id}`);
        if (el && el.getBoundingClientRect().top <= line) current = s.id;
      }
      const atEnd = scroller.scrollHeight > scroller.clientHeight + 1 && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
      if (atEnd) current = sections[sections.length - 1]?.id;
      if (current) setActiveId(current);
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(measure);
    };
    document.addEventListener("scroll", onScroll, { capture: true, passive: true });
    window.addEventListener("resize", onScroll);
    onScroll();
    return () => {
      document.removeEventListener("scroll", onScroll, { capture: true });
      window.removeEventListener("resize", onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [layout, sections, uid]);

  /* ----- render helpers */
  const ctxFor = (field: RiggingField): ControlCtx => {
    const id = `${uid}-f-${field.name}`;
    return {
      id,
      labelId: `${id}-label`,
      descId: field.description ? `${id}-desc` : undefined,
      errId: `${id}-err`,
      error: errors[field.name] ?? null,
      showError: showAll || touched.has(field.name) || !!localErrors[field.name],
      reduced,
      accent: accentColor,
      ink,
      values,
      setValue,
      touch,
      onAction,
      setLocalError,
    };
  };

  const renderSection = (section: RiggingSection) => {
    const danger = section.tone === "danger";
    const headingId = `${uid}-h-${section.id}`;
    return (
      <div className="@container/panel">
        <motion.div
          initial={intro && !reduced ? { opacity: 0, y: 6 } : false}
          animate={inView || !intro || reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }}
          transition={reduced ? INSTANT : { duration: 0.45, ease: EASE, delay: intro ? 0.08 : 0 }}
          className="mb-2"
        >
          <h3
            id={headingId}
            className={cn("text-base font-semibold tracking-tight", danger ? "text-red-200" : "text-white")}
          >
            {section.title}
          </h3>
          {section.description && <p className="mt-1 text-[13px] text-white/45">{section.description}</p>}
        </motion.div>
        <div
          className={cn(
            "divide-y divide-white/[0.06]",
            danger && "mt-4 rounded-xl border border-red-400/20 bg-red-500/[0.03] px-4"
          )}
        >
          {section.fields.map((field, i) => (
            <FieldRow
              key={field.name}
              field={field}
              ctx={ctxFor(field)}
              dirty={dirtyNames.has(field.name)}
              flashKey={flash.names.has(field.name) ? flash.tick : 0}
              index={i}
              intro={intro}
              inView={inView}
            />
          ))}
        </div>
      </div>
    );
  };

  const sectionDirty = (s: RiggingSection) => s.fields.some((f) => dirtyNames.has(f.name));
  const isTabs = layout === "tabs";

  /* ----- save bar */
  const barMode: "dirty" | "saving" | "error" | "saved" | null =
    status === "saving" ? "saving" : dirty ? (status === "error" ? "error" : "dirty") : status === "saved" ? "saved" : null;
  const barSpring: Transition = reduced ? { duration: 0.18 } : { type: "spring", stiffness: 380, damping: 34, mass: 0.9 };

  return (
    <section
      ref={rootRef}
      aria-labelledby={`${uid}-title`}
      data-crucible="rigging"
      onKeyDown={(e) => {
        onKeyDown?.(e);
        if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
          e.preventDefault();
          if (dirty) void save();
        }
      }}
      className={cn(
        "@container relative isolate flex w-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#0b0b0d] text-white",
        "shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_24px_60px_-30px_rgba(0,0,0,0.8)]",
        className
      )}
      style={{ ...style, ["--rg-accent" as string]: accentColor } as React.CSSProperties}
      {...rest}
    >
      <div className="flex min-h-0 flex-1 flex-col @min-[640px]:flex-row">
        {/* ------------------------------------------------ nav column */}
        <div className="flex shrink-0 flex-col border-b border-white/[0.06] @min-[640px]:w-56 @min-[640px]:border-b-0 @min-[640px]:border-r @min-[640px]:bg-white/[0.012]">
          <motion.div
            key={`head-${introKey}`}
            initial={intro && !reduced ? { opacity: 0, y: 4 } : false}
            animate={inView || !intro || reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 4 }}
            transition={reduced ? INSTANT : { duration: 0.4, ease: EASE }}
            className="px-5 pb-3 pt-5 @min-[640px]:pb-5 @min-[640px]:pt-6"
          >
            <h2 id={`${uid}-title`} className="text-[15px] font-semibold tracking-tight text-white">
              {title}
            </h2>
            {description && <p className="mt-1 text-[12.5px] leading-snug text-white/40">{description}</p>}
          </motion.div>
          <div
            role={isTabs ? "tablist" : "navigation"}
            aria-orientation={isTabs ? "vertical" : undefined}
            aria-label="Settings sections"
            className={cn(
              "relative flex gap-1 overflow-x-auto px-3 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
              "[mask-image:linear-gradient(90deg,transparent,#000_16px,#000_calc(100%-24px),transparent)]",
              "@min-[640px]:flex-col @min-[640px]:overflow-visible @min-[640px]:pb-4 @min-[640px]:[mask-image:none]"
            )}
          >
            {sections.map((s, i) => {
              const current = s.id === active?.id;
              const danger = s.tone === "danger";
              return (
                <motion.button
                  key={`${s.id}-${introKey}`}
                  ref={(el) => {
                    navRefs.current[i] = el;
                  }}
                  type="button"
                  role={isTabs ? "tab" : undefined}
                  id={`${uid}-tab-${s.id}`}
                  aria-selected={isTabs ? current : undefined}
                  aria-controls={isTabs ? `${uid}-panel` : `${uid}-s-${s.id}`}
                  aria-current={!isTabs && current ? "true" : undefined}
                  tabIndex={isTabs ? (current ? 0 : -1) : 0}
                  onClick={() => goTo(s.id)}
                  onKeyDown={isTabs ? (e) => onNavKeyDown(e, i) : undefined}
                  initial={intro && !reduced ? { opacity: 0, x: -6 } : false}
                  animate={inView || !intro || reduced ? { opacity: 1, x: 0 } : { opacity: 0, x: -6 }}
                  transition={reduced ? INSTANT : { duration: 0.4, ease: EASE, delay: intro ? 0.05 + i * 0.04 : 0 }}
                  className={cn(
                    "group/nav relative flex shrink-0 items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-[13px] font-medium outline-none transition-colors duration-150",
                    "focus-visible:ring-2 focus-visible:ring-[var(--rg-accent)]",
                    current
                      ? danger
                        ? "text-red-200"
                        : "text-white"
                      : danger
                        ? "text-red-300/60 hover:text-red-200"
                        : "text-white/50 hover:text-white/85"
                  )}
                >
                  {current && (
                    <motion.span
                      layoutId={`${uid}-nav-pill`}
                      aria-hidden
                      className="absolute inset-0 -z-10 rounded-lg border border-white/[0.07] bg-white/[0.06]"
                      transition={reduced ? INSTANT : SPRING}
                    >
                      <span
                        className={cn(
                          "absolute rounded-full",
                          "bottom-0 left-3 right-3 h-[2px] @min-[640px]:inset-y-2 @min-[640px]:left-0 @min-[640px]:right-auto @min-[640px]:h-auto @min-[640px]:w-[2px]"
                        )}
                        style={{ background: danger ? "#f87171" : "var(--rg-accent)" }}
                      />
                    </motion.span>
                  )}
                  {s.icon && (
                    <span className={cn("flex shrink-0 transition-opacity", current ? "opacity-100" : "opacity-70")}>{s.icon}</span>
                  )}
                  <span>{s.title}</span>
                  <AnimatePresence initial={false}>
                    {sectionDirty(s) && (
                      <motion.span
                        initial={{ scale: 0, opacity: 0 }}
                        animate={{ scale: 1, opacity: 1 }}
                        exit={{ scale: 0, opacity: 0 }}
                        transition={reduced ? { duration: 0.12 } : SPRING}
                        className="size-1.5 shrink-0 rounded-full @min-[640px]:ml-auto"
                        style={{ background: "var(--rg-accent)" }}
                      >
                        <span className="sr-only">, unsaved changes</span>
                      </motion.span>
                    )}
                  </AnimatePresence>
                </motion.button>
              );
            })}
          </div>
        </div>

        {/* ------------------------------------------------ content column */}
        <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
          <div
            ref={contentRef}
            className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-28 pt-5 @min-[640px]:px-8 @min-[640px]:pt-7 [scrollbar-color:rgba(255,255,255,0.12)_transparent] [scrollbar-width:thin]"
          >
            <div key={introKey} className="mx-auto w-full max-w-2xl">
              {isTabs ? (
                <AnimatePresence mode="wait" initial={false}>
                  <motion.div
                    key={active?.id}
                    role="tabpanel"
                    id={`${uid}-panel`}
                    aria-labelledby={`${uid}-tab-${active?.id}`}
                    initial={{ opacity: 0, y: reduced ? 0 : 6 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: reduced ? 0 : -4, transition: { duration: 0.1 } }}
                    transition={reduced ? { duration: 0.15 } : { duration: 0.28, ease: EASE }}
                  >
                    {active && renderSection(active)}
                  </motion.div>
                </AnimatePresence>
              ) : (
                <div className="flex flex-col gap-12">
                  {sections.map((s) => (
                    <section key={s.id} id={`${uid}-s-${s.id}`} aria-labelledby={`${uid}-h-${s.id}`} className="scroll-mt-4">
                      {renderSection(s)}
                    </section>
                  ))}
                </div>
              )}
            </div>
          </div>

          {/* ------------------------------------------------ save bar */}
          <div className="pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center px-3">
            <AnimatePresence>
              {barMode && (
                <motion.div
                  key="bar"
                  layout={!reduced}
                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.98 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={reduced ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98, transition: { duration: 0.22, ease: EASE } }}
                  transition={barSpring}
                  className={cn(
                    "pointer-events-auto flex items-center overflow-hidden border border-white/10 bg-[#141417]/95 shadow-[0_18px_40px_-12px_rgba(0,0,0,0.85),0_0_0_1px_rgba(0,0,0,0.4)] backdrop-blur-md",
                    barMode === "saved" ? "gap-2 rounded-full py-2 pl-2.5 pr-4" : "w-full max-w-[520px] gap-3 rounded-2xl py-2 pl-4 pr-2"
                  )}
                  style={{ borderRadius: barMode === "saved" ? 999 : 16 }}
                >
                  <AnimatePresence mode="popLayout" initial={false}>
                    {barMode === "saved" ? (
                      <motion.span
                        key="saved"
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: 0.18 }}
                        className="flex items-center gap-2 whitespace-nowrap text-[13px] font-medium text-white"
                      >
                        <span className="flex size-5 items-center justify-center rounded-full" style={{ background: "var(--rg-accent)", color: ink }}>
                          <CheckGlyph className="size-3" />
                        </span>
                        Saved
                      </motion.span>
                    ) : (
                      <motion.div
                        key="dirty"
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: 0.18 }}
                        className="flex min-w-0 flex-1 items-center gap-3"
                      >
                        <span className="flex min-w-0 flex-1 items-center gap-2 text-[13px]">
                          <span
                            aria-hidden
                            className="size-1.5 shrink-0 rounded-full"
                            style={{ background: barMode === "error" || (showAll && invalidCount) ? "#f87171" : "var(--rg-accent)" }}
                          />
                          <span className={cn("truncate", barMode === "error" || (showAll && invalidCount) ? "text-red-200" : "text-white/80")}>
                            {barMode === "error" ? (
                              saveError
                            ) : showAll && invalidCount ? (
                              <>
                                Fix {invalidCount} {invalidCount === 1 ? "field" : "fields"}
                                <span className="hidden @min-[480px]:inline"> to save</span>
                              </>
                            ) : (
                              <>
                                <span className="hidden @min-[480px]:inline">You have unsaved changes</span>
                                <span className="@min-[480px]:hidden">Unsaved</span>
                                <span className="ml-1.5 tabular-nums text-white/35">· {dirtyNames.size}</span>
                              </>
                            )}
                          </span>
                        </span>
                        <button
                          type="button"
                          onClick={reset}
                          disabled={barMode === "saving"}
                          className="h-8 shrink-0 rounded-lg px-3 text-[13px] font-medium text-white/60 transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--rg-accent)] disabled:opacity-40"
                        >
                          Reset
                        </button>
                        <button
                          type="button"
                          onClick={() => void save()}
                          aria-busy={barMode === "saving" || undefined}
                          disabled={barMode === "saving"}
                          className="inline-flex h-8 min-w-[76px] shrink-0 items-center justify-center gap-1.5 rounded-lg px-3.5 text-[13px] font-semibold transition-[filter,transform] duration-150 hover:brightness-110 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#141417] disabled:cursor-progress"
                          style={{ background: "var(--rg-accent)", color: ink }}
                        >
                          {barMode === "saving" ? (
                            <>
                              <Spinner />
                              Saving
                            </>
                          ) : barMode === "error" ? (
                            "Retry"
                          ) : (
                            "Save"
                          )}
                        </button>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>
      </div>

      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </section>
  );
}
```
