# Hero orb (Notchset): prompt.md (v2.0.0)

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

The Orb at hero size for a landing page: it plays a conversation as soon as it is seen, its light following a voice through a spring, then hands over at the first touch. Four knobs tune glow, grain, warmth and response. No sound; still with less motion..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: none beyond React.
- Files: `components/ui/notchset/blocks/hero-orb.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `orb`, `segmented-control`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `HeroOrb`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-control-sm`, `--ns-font-display`, `--ns-font-mono`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-line-soft`, `--ns-panel`, `--ns-radius-xl`, `--ns-signal`, `--ns-space-4`, `--ns-space-6`, `--ns-space-8`, `--ns-text-meta`, `--ns-text-title`, `--ns-tracking-display`, `--ns-weight-medium`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { HeroOrb } from "@/components/ui/notchset/blocks/hero-orb";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/blocks/hero-orb.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when

### Not when

## Built from
- orb (`orb`), installed with it: reuse it, don't rebuild it
- segmented-control (`segmented-control`), installed with it: reuse it, don't rebuild it

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import { HeroOrb } from "@/components/ui/notchset/blocks/hero-orb";

export default function HeroOrbDemo() {
  return <HeroOrb caption="A lamp that listens" className="mx-auto max-w-[560px]" />;
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `autoplay` | `boolean` | `true` | play the conversation until the first touch |
| `knobs` | `boolean` | `true` | show the four knobs |
| `size` | `number` | `200` |  |
| `caption` | `string` | `"A lamp that listens"` | the caption, six words or fewer |

Full docs: https://notchset.dev/blocks/hero-orb

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/hero-orb.json
```

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

## Source

### components/ui/notchset/blocks/hero-orb.tsx

```tsx
// Notchset hero-orb 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Orb, type OrbState } from "@/components/ui/notchset/orb";
import { SegmentedControl } from "@/components/ui/notchset/segmented-control";

/*
 * Hero orb: the assistant's lamp at hero size, for a landing page. It plays a conversation by itself as soon as it
 * is seen (Ready, Listening to a voice, Thinking in the notch rhythm, Speaking, Done), its levels following a voice
 * envelope through a spring, so the light leans and settles with weight rather than tracking a timeline. The first
 * touch, click or key hands it over: the states are then yours to pick, and four knobs (Glow, Grain, Warmth,
 * Response) tune the same parameters the app's orb takes. Sound is never made. Off screen or in a hidden tab it
 * stops; with less motion it shows each state's still and doesn't play itself.
 *
 * The marketing build of a pair: the app's Orb is the one a template uses (no self-play, no knobs). Same renderer.
 */
type HeroOrbProps = {
  /** play the conversation until the first touch */
  autoplay?: boolean | undefined;
  /** show the four knobs */
  knobs?: boolean | undefined;
  size?: number | undefined;
  /** the caption, six words or fewer */
  caption?: string | undefined;
  className?: string | undefined;
};

const SCRIPT: { state: OrbState; ms: number }[] = [
  { state: "idle", ms: 2200 },
  { state: "listening", ms: 3400 },
  { state: "thinking", ms: 2600 },
  { state: "speaking", ms: 3800 },
  { state: "done", ms: 2000 },
];
const OPTIONS = SCRIPT.map((s) => ({ value: s.state, label: { idle: "Ready", listening: "Listen", thinking: "Think", speaking: "Speak", done: "Done" }[s.state] }));

/* a voice, roughly: syllables (~5 a second) inside phrases (~0.4 a second), with a little roughness */
function envelope(t: number) {
  const syll = Math.max(0, Math.sin(t * 2 * Math.PI * 4.6) * 0.5 + Math.sin(t * 2 * Math.PI * 7.1 + 1) * 0.3);
  const phrase = Math.max(0, Math.sin(t * 2 * Math.PI * 0.42) + 0.35);
  return Math.min(1, syll * phrase * 0.9 + 0.04);
}

function Knob({ label, value, onChange, min = 0, max = 1.5 }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
  const id = React.useId();
  return (
    <div className="flex min-w-0 flex-col gap-1.5">
      <label htmlFor={id} className="flex items-baseline justify-between text-[length:var(--ns-text-meta)] text-[var(--ns-ink-2)]">
        {label}
        <span className="font-[family-name:var(--ns-font-mono)] text-[var(--ns-ink-3)] tabular-nums">{value.toFixed(2)}</span>
      </label>
      <input
        id={id}
        type="range"
        min={min}
        max={max}
        step={0.05}
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
        className="h-[var(--ns-control-sm)] w-full cursor-pointer accent-[var(--ns-signal)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ns-signal)]"
      />
    </div>
  );
}

function HeroOrb({ autoplay = true, knobs = true, size = 200, caption = "A lamp that listens", className }: HeroOrbProps) {
  const root = React.useRef<HTMLDivElement>(null);
  const [state, setState] = React.useState<OrbState>("idle");
  const [mine, setMine] = React.useState(!autoplay);
  const [lv, setLv] = React.useState({ input: 0, output: 0 });
  const [glow, setGlow] = React.useState(1);
  const [grain, setGrain] = React.useState(1);
  const [warmth, setWarmth] = React.useState(0.5);
  const [response, setResponse] = React.useState(1);
  const [seen, setSeen] = React.useState(false);
  const [less, setLess] = React.useState(false);
  /* without an IntersectionObserver (an old browser, a test) it counts as seen */
  const visible = seen || typeof IntersectionObserver === "undefined";
  /* the script restarts from the state it was in */
  const at = React.useRef(state);
  React.useEffect(() => {
    at.current = state;
  }, [state]);

  React.useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)");
    const read = () => setLess(q.matches || document.documentElement.dataset.motion === "reduced");
    read();
    q.addEventListener("change", read);
    return () => q.removeEventListener("change", read);
  }, []);
  React.useEffect(() => {
    const el = root.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver((es) => setSeen(es.some((e) => e.isIntersecting)));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  /* the self-play: the script's states in turn; any input ends it for good */
  React.useEffect(() => {
    if (mine || less || !visible) return;
    let i = SCRIPT.findIndex((s) => s.state === at.current);
    let t: ReturnType<typeof setTimeout>;
    const next = () => {
      i = (i + 1) % SCRIPT.length;
      setState(SCRIPT[i]!.state);
      t = setTimeout(next, SCRIPT[i]!.ms);
    };
    t = setTimeout(next, SCRIPT[Math.max(0, i)]!.ms);
    return () => clearTimeout(t);
  }, [mine, less, visible]);

  const voiced = !less && visible && (state === "listening" || state === "speaking");
  /* the levels: a voice envelope through a damped spring, by elapsed time (the same at 60 and 120 Hz) */
  React.useEffect(() => {
    if (!voiced) return;
    let raf = 0, last = performance.now(), x = 0, v = 0;
    const t0 = last;
    const k = 120 * response, c = 2 * Math.sqrt(k) * 0.55;
    const frame = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      if (!document.hidden) {
        const target = envelope((now - t0) / 1000 + (state === "speaking" ? 2 : 0));
        v += (k * (target - x) - c * v) * dt;
        x += v * dt;
        const l = Math.max(0, Math.min(1, x));
        setLv(state === "listening" ? { input: l, output: 0 } : { input: 0, output: l });
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [state, voiced, response]);

  const handOver = () => {
    if (!mine) setMine(true);
  };

  return (
    <div
      ref={root}
      data-slot="hero-orb"
      onPointerDown={handOver}
      onKeyDown={handOver}
      className={cn("flex min-w-0 flex-col items-center gap-6 rounded-[var(--ns-radius-xl)] bg-[var(--ns-panel)] px-[var(--ns-space-6)] pt-[var(--ns-space-8)] pb-[var(--ns-space-6)] shadow-[inset_0_0_0_1px_var(--ns-line-soft)] max-[600px]:px-[var(--ns-space-4)]", className)}
    >
      <p className="m-0 font-[family-name:var(--ns-font-display)] text-[length:var(--ns-text-title)] font-[number:var(--ns-weight-medium)] tracking-[var(--ns-tracking-display)] text-[var(--ns-ink)]">{caption}</p>
      {/* room round the lamp: its halo and settled ring reach past the sphere, and the tile never cuts them */}
      <div className="flex w-full justify-center px-[var(--ns-space-8)] py-[var(--ns-space-4)]">
        <Orb state={state} size={size} input={voiced ? lv.input : 0} output={voiced ? lv.output : 0} glow={glow} grain={grain} warmth={warmth} hint={mine ? "Pick a state, or tune the light" : "Playing by itself · touch to take over"} />
      </div>
      <SegmentedControl
        label="State"
        value={state}
        onValueChange={(v) => {
          handOver();
          setState(v);
        }}
        options={OPTIONS}
        className="max-w-full overflow-x-auto"
      />
      {knobs && (
        <div className="grid w-full max-w-[420px] grid-cols-2 gap-x-6 gap-y-3">
          <Knob label="Glow" value={glow} onChange={(v) => (handOver(), setGlow(v))} />
          <Knob label="Grain" value={grain} onChange={(v) => (handOver(), setGrain(v))} />
          <Knob label="Warmth" value={warmth} max={1} onChange={(v) => (handOver(), setWarmth(v))} />
          <Knob label="Response" value={response} min={0.3} max={2} onChange={(v) => (handOver(), setResponse(v))} />
        </div>
      )}
    </div>
  );
}

export { HeroOrb, type HeroOrbProps };
```

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