# Command Palette (Lumen Halo): prompt.md (v1.2.0)

- id: `glass-command` · version 1.2.0 · component · pro (All-Access)
- category: Navigation
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add @beautiful-ui-pro/glass-command`
- npm dependencies: @web-kits/audio@^0.2
- registry dependencies: https://beautiful-ui.dev/r/beautiful-ui.json
- docs: https://beautiful-ui.dev/components/glass-command
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A liquid-glass command palette in dark and light: jump anywhere, run actions, read metrics in place with a cell trend, drill into nested pages and ask Lumen, with the family's gliding lens and a phone sheet.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: `@web-kits/audio`.
- Source: `components/beautiful-ui/glass-command.tsx`, shared code in `lib/beautiful-ui/`.
- Exports to keep: `GlassCommand`, and every exported type.
- CSS: none to add. Themes, tokens and keyframes are inlined by the component (a deduped `<style>` built with `lumenThemeCss` from `lib/beautiful-ui/glass.tsx`); retheme through the `--glass-*` variables, never with Tailwind colour classes inside the component.

```tsx
import { GlassCommand } from "@/components/beautiful-ui/glass-command";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `GlassCommand` | — | The command palette: search, grouped results with cell trends, nested pages and ask mode. |

## 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 `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads; `sound={false}` silences one instance.
- This item: Opening (trigger or ⌘K) plays open, closing (Escape, scrim, the × or after a run) plays close; arrow, Home / End and Page moves tick very quietly; running a result plays command (click or Enter), entering a sub-page chirps (click, Enter or Tab), a disabled result plays blocked. Hover alone is silent. Needs a GlassSoundProvider; sound={false} silences this instance.

## Match the original
- Read `components/beautiful-ui/glass-command.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
- command palette, cmd+k, ctrl+k, quick search, spotlight, launcher, jump to page, command menu, global search, ask AI
- The ⌘K / Ctrl+K menu of an app: pages, projects, actions and settings in one keyboard-first list
- Next to Glass Sidebar: pass its onSearch to open this palette and give ⌘K to one of the two (see mistakes)
- When people should read a key number without opening a page: metric items show the value, change and a 12-column cell trend
- Embedded search (inline) in docs, onboarding or an empty state

### Not when
- Filtering a single table or list: use a plain search field above it
- A handful of links: use Glass Nav or Glass Sidebar
- Long forms or multi-step flows: pages here are for picking one option, not for input

## Mistakes
- Give every item a stable, unique id: Recent, the page path and the lens track rows by id
- Put search synonyms in keywords ("billing" for Settings) instead of stuffing titles
- Pass onNavigate for client-side routing (router.push); without it an href item does a full page load via window.location.assign
- With Glass Sidebar on the same page, let ONE of them own ⌘K: either <GlassSidebar onSearch={() => setOpen(true)} hotkey={false} /> with <GlassCommand open={open} onOpenChange={setOpen} trigger={false} /> (⌘K toggles the palette), or <GlassSidebar onSearch={() => setOpen(true)} /> with <GlassCommand open={open} onOpenChange={setOpen} trigger={false} hotkey={false} /> (⌘K only opens it; Esc closes). Both bound is still safe (the palette handles ⌘K first and the sidebar skips an event that is already handled), but one owner is clearer
- With trigger={false}, the hotkey still toggles the palette; pass hotkey={false} if your app owns ⌘K
- brand defaults to Lumen: pass brand={{ name: "Kitelabs", mark: <Logo /> }} or brand={null} so the demo name doesn't ship in your placeholder, trigger, Ask row and footer
- The default Change theme… page only retints the palette unless you pass onThemeChange (or theme items of your own)
- Keep metric values pre-formatted and short; the row is one line and the title truncates first on phones
- In overlay mode className/style/ref go to the dialog panel (not the trigger any more): style the default trigger with triggerClassName
- Inline mode sets container-type on its wrapper (for the narrow-panel rules): inside a shrink-to-fit parent (inline-block, float, absolutely positioned without a width) give it a width
- Desktop page scroll stays live behind the overlay (the scroll lock only applies to the phone sheet); the scrim blocks clicks
- A custom filter is called with an empty query too: return { score: 0, idx: [] } to keep items visible before typing

## Usage

```tsx
import { useRouter } from "next/navigation";
import { GlassCommand } from "@/components/beautiful-ui/glass-command";
import { GlassSidebar } from "@/components/beautiful-ui/glass-sidebar";

// Drop in: a Search ⌘K well that opens the Lumen demo palette
<GlassCommand />

// Your app: the palette owns ⌘K; the sidebar's search well opens it.
const router = useRouter();
const { setTheme } = useTheme();
const [open, setOpen] = useState(false);

<GlassSidebar onSearch={() => setOpen(true)} hotkey={false} />
<GlassCommand
  open={open}
  onOpenChange={setOpen}
  trigger={false}
  brand={{ name: "Kitelabs", mark: <KitelabsLogo className="size-3.5" /> }}
  onNavigate={(href, { newTab }) => (newTab ? window.open(href, "_blank") : router.push(href))}
  onThemeChange={setTheme}
  groups={[
    { id: "pages", label: "Pages", items: [
      { id: "home", title: "Home", type: "Page", href: "/" },
      { id: "billing", title: "Billing", type: "Page", href: "/settings/billing", keywords: ["invoice", "plan"] },
    ] },
    { id: "metrics", label: "Metrics", items: [
      { id: "mrr", title: "MRR", href: "/metrics/mrr",
        metric: { value: formatUsd(mrr.current), delta: formatPct(mrr.change), tone: mrr.change >= 0 ? "good" : "bad", trend: mrr.last12Months } },
    ] },
    { id: "actions", label: "Actions", items: [
      { id: "invite", title: "Invite teammate", onSelect: () => openInvite() },
    ] },
  ]}
  recent={recentIds}
  onAsk={(q) => askAssistant(q)}
  labels={{ recent: "Recently viewed" }}
/>
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `groups` | `GlassCommandGroup[]` | `the Lumen workspace` | Groups in display order ({ id; label; items }). Items: id, title, subtitle?, icon?, keywords?, shortcut?, type?, metric? ({ value; delta?; tone?: "good" \| "bad" \| "flat"; trend?: number[] }), children? (a nested page), pageTitle?, pagePlaceholder?, checked?, disabled?, href?, onSelect?. |
| `recent` | `string[]` | `["meridian", "active-teams", "roadmap"]` | Item ids listed under Recent while the query is empty. Pass [] to hide the group. |
| `onSelect` | `(item, ctx) => void` |  | Called for every selected item. ctx.newTab is true for ⌘/Ctrl+Enter; ctx.close() closes; ctx.setTheme() retints the palette and calls onThemeChange. An item's own onSelect runs first; return false from either to keep the palette open. |
| `onNavigate` | `(href: string, ctx: { newTab; item }) => void` |  | Navigation for items with href and no onSelect. Default: window.location.assign (window.open for a new tab). Pass your router here for client-side navigation. |
| `onAsk` | `(query: string) => void` |  | Called by the Ask row (shown from 3 characters; first when the query reads as a question). Return false to keep the palette open. |
| `ask` | `boolean` | `true` | false hides the Ask row. |
| `brand` | `{ name: ReactNode; mark?: ReactNode } \| null` | `{ name: "Lumen", mark: <EclipseMark /> }` | The footer brand. A string name is also woven into the default placeholder ("Search Kitelabs, run a command, or ask…"), trigger and Ask row. null hides the footer brand and drops the name from those strings. A custom brand's Ask row uses a sparkle icon. |
| `labels` | `Partial<GlassCommandLabels>` | `defaultGlassCommandLabels` | Every visible and announced string: placeholder, trigger, triggerAria(shortcut), ask, asked(query, brand), opened(title), dialog, input, inputPage(page), results, recent, pageFilter, current, empty, noResults(query), close, cancel, back, navigate, open, enter, askHint, resultCount(n), pageCount(page, n). placeholder / trigger / ask take a string or (brand) => string. |
| `inline` | `boolean` | `false` | Renders the panel in place, always open, with no scrim or hotkey. The keyboard hints hide when the panel is narrower than 440px. |
| `open / defaultOpen / onOpenChange` | `boolean / boolean / (open) => void` |  | Controlled or uncontrolled open state (overlay mode). |
| `query / defaultQuery / onQueryChange` | `string / string / (q) => void` |  | Controlled or uncontrolled query. |
| `page / defaultPage / onPageChange` | `string[] / string[] / (page: string[]) => void` |  | The nested page as the path of entered item ids (e.g. ["theme"]; [] is the root). Uncontrolled pages reset to defaultPage on open; a controlled page is left to you, so you can open straight into one. |
| `onActiveChange` | `(item \| null, { ask }) => void` |  | The row under the lens changed (arrow keys, pointer or new results): use it for a preview pane. item is null for the Ask row (ask: true) or when nothing is active or the palette closes. |
| `onThemeChange` | `(theme: "system" \| "dark" \| "light") => void` |  | Called when a theme option is picked (the default Change theme… page). Wire it to your site theme; the palette itself only retints when theme is uncontrolled. |
| `filter` | `(query, item) => { score; idx } \| null` | `matchCommand` | Swap the matcher (also called with an empty query: return a match to show the item). Higher scores rank first within a group; idx lists title characters to highlight. |
| `trigger` | `boolean \| ReactNode \| ({ open, setOpen, toggle }) => ReactNode` | `true` | Overlay mode: the Search Lumen… ⌘K well that opens it. false hides it (open it from your own button, a sidebar or the hotkey); a node replaces it (wire its onClick yourself); a function receives { open, setOpen, toggle }. |
| `hotkey` | `boolean` | `true` | Overlay mode: ⌘K / Ctrl+K toggles the palette (document keydown). Ignores events another handler already preventDefault-ed. Pass false when something else owns ⌘K. |
| `width` | `number \| string` | `640` | Panel width. Overlay: min(width, 100vw − 32px). Inline: the max width (the panel fills its container up to it). |
| `height` | `number \| string` | `392` | Max height of the results list before it scrolls; the list still animates to fit shorter results. |
| `breakpoint` | `number` | `720` | Viewport width (px) below which layout="auto" turns the overlay into the phone bottom sheet. |
| `layout` | `"auto" \| "desktop" \| "mobile"` | `"auto"` | auto uses the phone bottom sheet (drag to dismiss) below breakpoint. |
| `portal` | `boolean` | `true` | Overlay mode: render into a portal so no parent clips it. |
| `portalContainer` | `HTMLElement \| null` | `document.body` | Overlay mode: the portal target, e.g. your modal root or a shadow-root host. |
| `zIndex` | `number` | `60` | Overlay mode: z-index of the fixed layer (scrim and panel). |
| `autoFocus` | `boolean` | `true` | Overlay mode: focus the input when the palette opens. |
| `modal` | `boolean` | `autoFocus` | Overlay mode: aria-modal and focus kept inside the panel. Follows autoFocus unless set, so autoFocus={false} alone gives a non-modal panel. |
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | full: the designed motion. subtle: calm curve, shorter, no staggers. off: instant. prefers-reduced-motion is always respected. |
| `sound` | `boolean \| "subtle"` | `true` | true plays Lumen cues when a GlassSoundProvider enables sound; false silences this instance; "subtle" plays at 55%. Without a provider nothing plays and the audio engine never loads. |
| `theme` | `"system" \| "dark" \| "light"` | `"system"` | system follows a .dark / .light class or data-theme on an ancestor, else the OS. |
| `className / style / ref` | `string / CSSProperties / Ref<HTMLDivElement>` |  | The component root: the wrapper in inline mode, the dialog panel in overlay mode. Other HTML attributes (id, data-*, aria-*, on* handlers) land there too; aria-label names the dialog (default labels.dialog). |
| `triggerClassName / contentClassName` | `string / string` |  | Class names for the default trigger well, and for the glass panel in both modes. |
| `placeholder / askLabel` | `string / string` |  | Deprecated aliases for labels.placeholder and labels.ask; still work. |
| `CSS variables` | `CSS` |  | Set on the root or any ancestor: --glass-good, --glass-good-text, --glass-bad (metric tones and the check; -dark / -light suffixes per theme), --glass-accent, --glass-command-top (overlay panel top, default 14vh), --glass-safe-bottom (phone sheet safe area). The glass surface uses the family --lg-* tokens. |

Full docs: https://beautiful-ui.dev/components/glass-command

## Customising
- Theme: `theme="system"` (default) follows a `.dark` / `.light` class or `data-theme` on an ancestor, else the OS. `"dark"` / `"light"` pin one.
- Motion: `motion="full"` (default) | `"subtle"` | `"off"`. prefers-reduced-motion is always respected.
- Phones: below 720px it switches to its phone layout in CSS. Force one with `layout="desktop"` or `layout="mobile"`.
- Colours: the family variables (`--glass-accent`, `--glass-good`, `--glass-series-1` …) on the component or any ancestor; the neutral glass is `--lg-*` in `lib/beautiful-ui/glass.tsx`.
- 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 |
|---|---|
| ⌘K / Ctrl+K | Open or close the palette (overlay mode; hotkey={false} disables it) |
| ↑ / ↓ | Move the lens (wraps); Home / End jump to the ends, PageUp / PageDown move 5 |
| Enter | Open the active row; enters a nested page for items with › |
| ⌘/Ctrl + Enter | Open in a new tab (ctx.newTab) |
| Tab | Enter the active item's page, or jump to Ask Lumen |
| Backspace (empty input) | Back one page |
| Esc | Back one page, clear the query, then close |

## Motion inventory

| Interaction | What moves |
|---|---|
| Sound | Opening (trigger or ⌘K) plays open, closing (Escape, scrim, the × or after a run) plays close; arrow, Home / End and Page moves tick very quietly; running a result plays command (click or Enter), entering a sub-page chirps (click, Enter or Tab), a disabled result plays blocked. Hover alone is silent. Needs a GlassSoundProvider; sound={false} silences this instance |
| Open | The scrim fades in (240ms) and the panel settles from 97% scale, 6px above and 4px blurred (420ms, the family curve); the first rows follow on a 16ms stagger. On phones the sheet rises from below |
| Move | The glass lens glides between rows (420ms); holding an arrow key switches it to a 120ms follow so it keeps up |
| Type | The list height eases to its new size (320ms), new rows fade in, matched letters light up, and metric rows ripple their cell trend in column by column |
| Pages | Entering or leaving a page slides the list 12px in its direction; the breadcrumb chip scales in |
| Select | The lens brightens for 120ms, then the palette closes (inline: the footer confirms with a check) |
| motion="subtle" / "off" / reduced motion | Subtle: calm curve, 30% shorter, no stagger or blur. Off: instant. Reduced: 150ms fades only and the lens jumps |

## Accessibility contract (preserve when editing)
- Overlay mode is a modal dialog labelled Command menu; focus goes to the input, stays inside, and returns to the trigger on close
- The input is an ARIA combobox that owns a listbox; the active row is exposed with aria-activedescendant, so screen readers follow the lens
- Rows are options inside labelled groups; disabled items are aria-disabled; the current option in a page (e.g. theme) shows a check with a label
- A polite live region announces the result count and page changes
- Tone is never colour alone: metric changes carry their sign and unit

## Install

```bash
npx shadcn@latest add @beautiful-ui-pro/glass-command
```

Pro item: needs the `@beautiful-ui-pro` registry in `components.json` and `BEAUTIFUL_UI_TOKEN` in `.env.local` (https://beautiful-ui.dev/account). Setup: https://beautiful-ui.dev/docs/pro

## Credits
- Sound by @web-kits/audio (https://www.npmjs.com/package/@web-kits/audio)
