# FAQ (Lumen Halo): prompt.md (v1.1.0)

- id: `glass-faq` · version 1.1.0 · block · pro (All-Access)
- category: Marketing
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add @beautiful-ui-pro/glass-faq`
- npm dependencies: @web-kits/audio@^0.2
- registry dependencies: https://beautiful-ui.dev/r/beautiful-ui.json
- docs: https://beautiful-ui.dev/components/glass-faq
- 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 FAQ block in dark and light: glass accordion rows with a cell glyph, search with highlights, categories with the gliding lens, direct links to every question and rich answers with code, lists and a docs link.

## 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-faq.tsx`, shared code in `lib/beautiful-ui/`.
- Exports to keep: `GlassFaq`, 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 { GlassFaq, type GlassFaqItem } from "@/components/beautiful-ui/ui/glass-faq";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `GlassFaq` | — | The FAQ: search with a rolling count, topic tabs and the accordion. |

## 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: open / close on questions, select (pitched by position) on categories, toggleOn / toggleOff on expand / collapse all, copy (or error) when a link or code copy finishes, a very quiet tick on arrow moves, close when the search clears. Nothing on hover or mount. Needs a GlassSoundProvider; sound={false} silences this instance; motion settings do not affect sound.

## Match the original
- Read `components/beautiful-ui/glass-faq.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
- faq, frequently asked questions, accordion of questions, help section, pricing questions, objections before buying, support answers, searchable faq, knowledge base section
- The questions that stop people buying, on a landing or pricing page: pricing, security, data, team and product
- Long FAQs (more than 10 questions): search with highlights, IN ANSWER tags and category tabs appear on their own
- Answers that need code, lists or a docs link, and questions support wants to link to directly (#faq-where-is-our-data-stored)
- Short FAQs (10 or fewer): only the glass list shows, with the headline beside it

### Not when
- A full help centre with articles and navigation: link to your docs instead
- A single disclosure (one expandable section): use an accordion or a details element
- Command-style search across the whole app: use glass-command

## Mistakes
- Ids come from the question text: changing a question changes its link. Give items a stable id when people share links to them
- Two FAQs with the same questions on one page produce the same DOM ids: pass getItemId={(item, i) => `pricing-${i}`} (or ids) on one of them
- A node answer is invisible to search unless you pass searchText
- Set scrollOffset to your sticky header's height (+16) or deep-linked questions land under it
- syncHash rewrites the address with replaceState: turn it off when the FAQ lives inside a modal or a page that owns the hash (tabs, a router using hashes)
- The layout follows the block's width, not the viewport: in a narrow column it stacks and opens one question at a time on purpose
- The root is a size container: inside a shrink-to-fit parent (inline-block, width: fit-content) give it a width
- It is a client component: pass callbacks (onOpenChange, renderCode, filter) from a client component

## Usage

```tsx
import { GlassFaq, type GlassFaqItem } from "@/components/beautiful-ui/ui/glass-faq";

// Zero props: the 30-question Lumen FAQ
<GlassFaq />

// Your questions
const items: GlassFaqItem[] = [
  { category: "billing", popular: true, question: "Can I cancel anytime?", answer: [{ type: "p", text: "Yes, from Settings → Billing." }] },
  {
    category: "api",
    question: "How do I authenticate?",
    docsHref: "/docs/auth",
    answer: [
      { type: "p", text: "Send your key in the Authorization header." },
      { type: "code", lang: "bash", code: "curl -H 'Authorization: Bearer $KEY' https://api.acme.dev/v1/me" },
      { type: "list", items: ["Keys are per workspace.", "Rotate them in Settings."] },
    ],
  },
  // Rich content: any node, with searchText so search still finds it
  { category: "api", question: "Which regions?", answer: <RegionTable />, searchText: "eu us frankfurt virginia" },
];

<GlassFaq
  items={items}
  categories={[{ id: "billing", label: "Billing" }, { id: "api", label: "API" }]}
  title={["Questions?", "We have answers."]}
  subline="Everything teams ask before switching to Kitelabs."
  contactHref="/contact"
  linkComponent={Link}
  scrollOffset={88} // your sticky header + 16
  onOpenChange={(open, { id, reason }) => reason === "toggle" && id && open.includes(id) && track("faq_open", { id })}
  labels={{ eyebrow: "FAQ", stillUnsure: "Need a hand?" }}
/>

// Controlled search and category (e.g. from the URL)
const [query, setQuery] = React.useState("");
<GlassFaq query={query} onQueryChange={setQuery} category="billing" onCategoryChange={setCategory} />
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `items` | `GlassFaqItem[]` | `defaultGlassFaqItems (30 Lumen questions)` | { id?, category?, question, answer, popular?, docsHref?, searchText? }. answer is a GlassFaqBlock[] ({ type: "p", text } \| { type: "list", items } \| { type: "code", lang?, code } \| { type: "custom", content, searchText? }) or any ReactNode (give it searchText so search finds it). New arrays re-filter in place; open ids that disappear are ignored. |
| `categories` | `{ id; label }[]` | `the items' categories, capitalised` | Tab order. "Popular" (when any item is popular) and "All" are added first. Items whose category is not listed still show under All. |
| `title` | `string \| [lead, muted?] \| null` | `["Questions, answered.", "Before you commit."]` | The headline; the second half is muted. null hides it (then give the root an aria-label). |
| `subline` | `ReactNode \| null` | `"What teams ask most before moving their analytics to Lumen."` | The sentence under the headline (max 360px). |
| `contactHref` | `string \| null` | `"#contact"` | Still unsure? Talk to our team → and the empty state's "ask us directly". null hides both. |
| `allowMultiple` | `boolean` | `true` | More than one question open at a time. Always single below singleBelow; EXPAND ALL hides when only one can open. |
| `open / defaultOpen / onOpenChange` | `string[] / string[] / (open, { id, reason }) => void` |  | Controlled or uncontrolled open question ids. reason: "toggle", "expand-all", "collapse-all" or "hash". |
| `query / defaultQuery / onQueryChange` | `string / string / (q) => void` |  | Controlled or uncontrolled search. Plain case-insensitive substring (regex characters are literal; ’ and ' match each other). |
| `category / defaultCategory / onCategoryChange` | `string / string / (id) => void` | `"popular" (or "all" when nothing is popular)` | The category tab: "popular", "all" or a category id. An unknown id shows All. Searching always searches everything (the lens moves to All). |
| `searchThreshold` | `number` | `10` | Search and categories show when there are more items than this. |
| `filter` | `(item, query) => "question" \| "answer" \| null` | `matchGlassFaq` | Swap the matcher (e.g. fuzzy or synonyms). "question" highlights the query in the question; "answer" shows the IN ANSWER tag. |
| `getItemId` | `(item, index) => string` | `faqSlug(question)` | Ids for items without one. Ids are de-duplicated (-2, -3…); a question with no Latin letters falls back to faq-<n>. Ids are DOM ids and #hashes: prefix them when two FAQs share questions on one page. |
| `renderCode` | `(block, { item, id }) => ReactNode` |  | Render a code block's contents (e.g. Shiki or Prism output). The glass header, COPY and the sideways scroll stay. |
| `onCopyCode / onCopyLink` | `(code, item) => void / (url, item) => void` |  | Called after a successful copy (analytics). Copying uses the Clipboard API, falls back to execCommand, and shows FAILED / Couldn’t copy when both fail. |
| `syncHash` | `boolean` | `true` | Opening writes #id with history.replaceState (closing clears it); on load and on hashchange a matching #id switches to All, opens and scrolls to it. false turns all of it off. |
| `scrollOffset` | `number` | `32` | Space above a deep-linked question (px), e.g. the height of your sticky header + 16. |
| `stickyTop` | `number \| string` | `32` | The left column's sticky top on wide layouts. |
| `stackBelow / singleBelow` | `number / number` | `900 / 700` | Container widths (the block's own width, not the viewport): below stackBelow the left column moves above the list and categories become a sideways row; below singleBelow only one question opens at a time. |
| `hotkey` | `boolean` | `true` | "/" focuses the search (ignored while typing in a field). With several FAQs on a page it goes to the one holding focus, else the one nearest the middle of the screen, else the first. |
| `linkComponent` | `ElementType` | `"a"` | Renders the docs and contact links, e.g. Next's Link. |
| `titleAs / questionAs` | `"h1"–"h4" / "h2"–"h6"` | `"h2" / "h3"` | Heading levels for the title and for each question (the button sits inside the heading). |
| `structuredData` | `boolean` | `false` | Adds schema.org FAQPage JSON-LD for text answers. glassFaqStructuredData(items) returns the same object for your own <script>. |
| `background` | `boolean` | `true` | Paints the block's own background and indigo/cyan glows. false keeps it transparent over your page. |
| `labels` | `Partial<GlassFaqLabels>` | `defaultGlassFaqLabels` | Every string: eyebrow, searchPlaceholder(total), search, clearSearch, categories, popular, all, stillUnsure, contact, countWord(n, searching), countAnnouncement(n, searching), expandAll, collapseAll, inAnswer, inAnswerHint, docs, copyLink, linkCopied, linkCopyFailed, copyCode, codeCopied, codeCopyFailed, copyCodeAria(lang), code(lang), emptyTitle(query), emptyHint(contactLink), emptyContact, noItems, emptyCategory. |
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | full: the designed motion. subtle: calm curve, 30% shorter, no stagger. off: instant. prefers-reduced-motion always wins: instant layout, 150ms fades. |
| `theme` | `"system" \| "dark" \| "light"` | `"system"` | system follows a .dark / .light class or data-theme on an ancestor, else the OS. |
| `sound` | `boolean \| "subtle"` | `true` | Built-in sound: true plays Lumen cues when a GlassSoundProvider (glass-sound) enables sound; false silences this instance (its cues and its clicks); "subtle" plays at 55%. Without a provider it is silent. |
| `className / style / ref / …section props` | `HTML section props` |  | Spread on the root <section> (labelled by the title), which is also the container the layout queries. |
| `CSS variables` | `CSS` |  | On the root or any ancestor: --glass-accent (number, glyph, bullets, lens tints; -dark / -light per theme), --glass-good / --glass-good-text (copied), --glass-faq-accent-text (-dark / -light; links, IN ANSWER, the open number in light), --glass-faq-highlight-text (light search highlight), --glass-faq-padding (default 64px 20px 120px), --glass-faq-max-width (1120px), --glass-faq-code-max-height (480px). |

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

## 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.
- 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 |
|---|---|
| / | Focus the search (unless you are typing in a field) |
| Escape (search) | Clear the search |
| ↓ (search) | Move to the first question |
| ↑ / ↓ (question) | Previous / next question (↑ on the first returns to the search) |
| Home / End (question) | First / last question |
| Enter / Space | Open or close the question |
| ↑ / ↓ (categories) | Move between categories in the column (the list follows) |
| ← / → (categories) | Move between categories in the sideways row, mirrored in RTL (the list follows) |
| Home / End (categories) | First / last category |

## Motion inventory

| Interaction | What moves |
|---|---|
| Sound | open / close on questions, select (pitched by position) on categories, toggleOn / toggleOff on expand / collapse all, copy (or error) when a link or code copy finishes, a very quiet tick on arrow moves, close when the search clears. Nothing on hover or mount. Needs a GlassSoundProvider; sound={false} silences this instance; motion settings do not affect sound |
| Open / close | The row lifts into glass (background and ring .45s); the answer grows through grid-template-rows 0fr→1fr (.6s cubic-bezier(.22,1,.36,1)) and its content rises from −8px with an 80ms delay (no delay when closing) |
| Glyph | The + made of cells: the top and bottom cells slide into the centre and shrink away (.5s spring, cubic-bezier(.34,1.5,.64,1)), leaving the − row, and the glyph tints indigo |
| Filter and search | Rows collapse and expand through grid-template-rows with a 22ms stagger (at most 10 steps); the count rolls its digits (.7s, 40ms apart); the empty state rises in |
| Category lens | Glides between tabs (.6s, the Nav curve); while searching it rests on All and the other tabs dim to .45 |
| Entrance | When first on screen the left column and the list fade up 14px (.8s / 1s, 90ms apart) |
| motion="subtle" / "off" / reduced motion | Subtle: calm curve, 30% shorter, no stagger or delays. Off: instant. Reduced motion: instant layout changes and 150ms opacity fades |

## Accessibility contract (preserve when editing)
- A <section> labelled by its <h2> title; each question is a button inside an <h3> (titleAs / questionAs change the levels)
- Question buttons carry aria-expanded and aria-controls; each answer is role=region labelled by its question, and the region is inert while closed (button and panel ids are per instance, so item ids never collide with them)
- Categories are role=tablist with aria-selected, roving focus and the list as their tabpanel; the search input is labelled and its / shortcut is exposed with aria-keyshortcuts
- The result count is announced politely after filtering; copies announce Link copied / COPIED (or the failure)
- The rolling digits are hidden from screen readers (the count is also real text); IN ANSWER reads as “(match in the answer)”
- Rows filtered out are inert, so Tab and arrow keys only reach visible questions; focus inside a closing answer returns to its question
- Code blocks are focusable scroll regions; all targets are at least 40px (64px rows; the smaller pills have padded hit areas)
- Visible focus rings; secondary text meets 4.5:1 and the muted headline half 3:1 (large text) in dark and light; windows high contrast mode outlines open rows

## Install

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

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)
