---
assumes: [framework/nextjs]
packages:
  runtime: []
  dev: [tailwindcss, "@tailwindcss/postcss"]
files: []
env:
  server: []
  client: []
ci_placeholders: {}
clean:
  files: [components.json, tailwind.config.ts, .eslintrc.json, eslint.config.mjs, postcss.config.mjs]
  dirs: []
gitignore: []
---
# UI: shadcn/ui
> Used when experiment.yaml has `stack.ui: shadcn`

## Packages (devDependencies)
```bash
npm install -D tailwindcss @tailwindcss/postcss
```

## Setup Commands
Run in order:
1. Create prerequisites that `shadcn init` expects to exist (Tailwind CSS v4):
   - `postcss.config.mjs`:
     ```js
     const config = { plugins: { "@tailwindcss/postcss": {} } };
     export default config;
     ```
   - `src/app/globals.css`:
     ```css
     @import "tailwindcss";
     ```
2. `npx shadcn@latest init --yes --defaults` — configures component paths and theme
3. `npx shadcn@latest add -y button input card label separator badge tabs accordion avatar tooltip dialog alert progress sheet` — install base components (add more later as needed)

## Post-Setup Verification
After running setup commands, verify:
- `postcss.config.mjs` uses `@tailwindcss/postcss` — fix if shadcn generated a different configuration
- `src/app/globals.css` contains `@import "tailwindcss"` — fix if missing
- `package.json` still has `dev`, `build`, `start`, and `lint` scripts — re-add if shadcn overwrote them
- `eslint.config.mjs` includes `src/components/ui/` in the `ignores` array — add if missing. Auto-generated shadcn components contain `@ts-nocheck` pragmas and patterns that ESLint flags as errors

## Component Directory
- `src/components/ui/` — auto-generated by shadcn
- Do not manually edit files in this directory

## Effect Components (Magic UI)

Magic UI components are shadcn-compatible — same CLI, same `src/components/magicui/`
directory, same import pattern. They bring `motion` (Framer Motion) as a peer
dependency for scroll-triggered and physics-based animations.

### Installation — per-component, NEVER bulk

DO NOT install the full MagicUI catalog (74 components). Bulk install ships ~6,000 LOC of unused TSX into `src/components/magicui/`, which:

1. Causes AI agents (`design-critic`, `visual-implementer`) to draw uncritically from the resulting catalog — components like `<Marquee>`, `<Globe>`, `<NeonGradientCard>` get dropped into landings whose visual lineage is warm, restrained, photography-driven (an aesthetic mismatch the lead has to prune post-hoc).
2. Slows `tsc` and `eslint` on every build (every file is parsed even when unused).
3. Creates drift exposure when MagicUI publishes breaking changes in components the project doesn't even use.

Install ONLY the components the design brief calls for:

```bash
# Examples — install only what the brief actually needs
npx shadcn@latest add "https://magicui.design/r/marquee"
npx shadcn@latest add "https://magicui.design/r/animated-shiny-text"
npx shadcn@latest add "https://magicui.design/r/sparkles-text"
```

Typical projects use **5-15** of the 74 available components. The brief / design tokens / visual lineage drives the choice — if the brief calls for "warm-cream parchment with restrained photography", `<NeonGradientCard>` is automatically off-lineage and should not be installed.

### Component Reference

See `.claude/patterns/design.md` → CSS Technique Catalog → Magic UI Component
Selection Guide for the full categorized reference of MagicUI components by purpose
(text effects, backgrounds, cards, buttons, layout, reveal, delight, mockups, etc.).
Pick components that match the project's visual lineage; ignore the rest.

### Import Example
```tsx
import { BlurFade } from "@/components/ui/blur-fade";
import { NumberTicker } from "@/components/ui/number-ticker";
import { Marquee } from "@/components/ui/marquee";
import { ShimmerButton } from "@/components/ui/shimmer-button";
```

## Adding Components Later
```bash
npx shadcn@latest add -y <component>
```

## Theme Setup

After `shadcn init` completes and post-setup checks pass, apply these customizations:

### 1. Font (in layout.tsx — created in Step 3)
Import the chosen font(s) from `next/font/google` (ships with Next.js — no extra package). Choose a font that matches the product domain — distinctive and intentional, not generic.
- Example: `const displayFont = YourChosenFont({ subsets: ["latin"], variable: "--font-sans" })`
- Apply `className={displayFont.variable}` to the `<html>` tag (NOT `<body>`) so the CSS variable is bound at `:root` scope — this is the only scope where Tailwind v4's `@theme inline { --font-sans: ...; }` and any `:root` / `html` CSS rules can resolve it. Binding on `<body>` causes `:root`-scoped rules to resolve to empty and text falls back to Times.
- If using a display + body font pair, apply both variables: `className={\`${displayFont.variable} ${bodyFont.variable}\`}` on `<html>`.
- **Tailwind v4 + next/font footgun:** prefer writing `font-family: var(--font-<chosen-name>), ui-sans-serif, system-ui, ...;` directly against the concrete variable name on `html, body, input, textarea, button, select` rules in `globals.css`. The `@theme inline { --font-sans: var(--font-sans), ... }` indirection only works if the referenced variable is bound at the rule's scope — which requires `<html className={displayFont.variable}>` above. If the variable is bound on `<body>` instead, `:root` / `html` rules resolve empty and every page renders in Times.

### 2. CSS overrides (in globals.css — exists after shadcn init)
Override CSS custom properties to match the product domain. There are no hardcoded default values — choose a color palette, border radius, and other theme tokens that feel distinctive and intentional.

### 3. Post-theme verification
- globals.css contains `--primary` override
- layout.tsx imports the display font and applies variable to `<html>`

## Patterns
- Always use library components (`<Button>`, `<Input>`, `<Card>`, etc.) — don't mix with raw HTML elements (`<button>`, `<input>`)
- Follow `.claude/patterns/design.md` for quality invariants.
- Import components from `@/components/ui/<component>` — these are auto-generated by shadcn
- When a new component is needed, add it with `npx shadcn@latest add -y <component>` rather than building from scratch

## Composition Patterns

> **Issue #1146:** Recent shadcn-init versions ship Base UI primitives (`@base-ui/react/button`) instead of Radix Slot-based primitives. Base UI Button does NOT accept an `asChild` prop, and Base UI's `render` prop REPLACES the default rendering rather than merging children. The patterns below are forward-compatible with both libraries.

**`buttonVariants()` + bare `<Link>` (canonical, works on both Radix and Base UI)**: Style a raw `<Link>` with `buttonVariants()` directly. This is the recommended Button-as-link form because it does not depend on `asChild` support:

```tsx
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";

export function GoButton() {
  return (
    <Link href="/foo" className={buttonVariants({ variant: "outline" })}>
      Go
    </Link>
  );
}
```

**`<Button asChild>` for Button + Link (Radix-only fallback)**: When the shipped `src/components/ui/button.tsx` imports from `@radix-ui/react-slot` (Slot-based), `asChild` composition works — the Button's full render, refs, and styling composition pass through to the child element:

```tsx
import Link from "next/link";
import { Button } from "@/components/ui/button";

export function GoButton() {
  // Radix-only: requires asChild support on the shipped Button primitive.
  return (
    <Button asChild>
      <Link href="/foo">Go</Link>
    </Button>
  );
}
```

This pattern compiles only when the shipped Button accepts an `asChild` prop. On Base UI-shipped projects (`@base-ui/react/button`) it produces a TypeScript error. Use `buttonVariants()` + bare `<Link>` instead for projects on Base UI.

BG2 check 18 (`Restricted asChild` in gate-keeper.md) allows the Button+Link form on Radix-shipped projects and blocks `asChild` when the child is any other element (e.g., `<Button asChild><div>`), which is the anti-pattern.

**Trigger + interactive element (callback `render` form, works on both libraries)**: When a trigger component (`TooltipTrigger`, `DialogTrigger`, etc.) wraps an interactive element like `<Button>`, use the **callback form** of the `render` prop so children are merged into the rendered element. This works on both Radix (where `render` accepts a callback per the asChild contract) and Base UI (where the callback returns the merged element). The element-form `render={<Button .../>}` REPLACES the children on Base UI — labels get dropped silently.

```tsx
import { Button } from "@/components/ui/button";
import { TooltipTrigger } from "@/components/ui/tooltip";

// WRONG (nested <button> — invalid HTML):
<TooltipTrigger><Button>Click</Button></TooltipTrigger>

// WRONG (Base UI: text children silently dropped — Trigger renders an empty button):
<TooltipTrigger render={<Button variant="ghost" />}>
  Click
</TooltipTrigger>

// CORRECT (callback form merges children with the rendered element on both libraries):
<TooltipTrigger render={(props) => <Button variant="ghost" {...props}>Click</Button>}>
  Click
</TooltipTrigger>
```

For controlled dialogs that don't fit the callback form cleanly, drive the dialog with explicit state and a separate Button trigger:

```tsx
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";

export function ControlledDialog() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button onClick={() => setOpen(true)}>Open dialog</Button>
      <Dialog open={open} onOpenChange={setOpen}>
        …
      </Dialog>
    </>
  );
}
```

**Opacity modifier values**: Tailwind v4 opacity modifiers (the `/N` suffix on color utilities) must use values defined in the default opacity scale: **5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95**. Values outside this scale (e.g., `/8`, `/08`, `/12`) are silently dropped — the entire color declaration is removed with no error or warning, producing invisible borders, backgrounds, or text. This applies to all color utilities (`bg-*`, `text-*`, `border-*`, `ring-*`, `divide-*`, `outline-*`, `shadow-*`, etc.).

```tsx
// WRONG (8 is not a valid opacity step — color silently dropped):
<div className="border border-white/08 bg-black/08">

// CORRECT (10 is the nearest valid step):
<div className="border border-white/10 bg-black/10">
```

## Fake Door Component

> Used by bootstrap-lead (`.claude/skills/bootstrap/state-12-externals-decisions.md`) and change-lead (`.claude/procedures/change-feature.md`) when `stack.ui: shadcn` AND a non-core external feature is classified as Fake Door (per `.claude/procedures/scaffold-externals.md` § Intent Capture Contract).
>
> **Do NOT edit this section in a project — `/upgrade` will overwrite it.** Customize at the callsite via the documented props (`feature`, `service`, `actionLabel`, `pageName`, `variant`, `triggerLabel?`, `successHeadline?`, `successBody?`). If a novel customization vector is needed, file a template observation via `/observe` rather than editing the stack file in the project.
>
> **Instantiation workflow:**
> 1. Copy the TSX template below verbatim to `src/app/<page>/<component>.tsx` (project-owned).
> 2. Fill in feature-specific props at the callsite (not inside the file).
> 3. If `stack.analytics` is absent, delete the `@/lib/analytics` import line AND the `track("activate", ...)` call line. The remaining code compiles and the click→success transition still works.
>
> **PII boundary (Rule 4 of the Intent Capture Contract).** The template is button-only intent capture: do NOT collect email or any other PII. The `track()` call MUST NOT include an `email`, `phone`, or `name` property. Lead-capture features (collect emails for later outreach) require a separate `/change`-declared Feature with route + table + RLS + rate-limit + email-service integration — see `.claude/procedures/scaffold-externals.md` § Intent Capture Contract Rule 4 + Lead-capture extension.

### Variant mapping (Rule 6 of the Intent Capture Contract)

| Intent Capture variant | shadcn Button variant | Class augmentation |
|------------------------|-----------------------|--------------------|
| `primary` (default)    | `default`             | (none) |
| `ghost`                | `ghost`               | `border border-border/40` (keeps the trigger legible on backgrounds where `ghost` alone disappears) |

### Template

```tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
// If stack.analytics is absent: delete this import AND the trackCall below.
import { track } from "@/lib/analytics";

type Variant = "primary" | "ghost";
type Status = "idle" | "submitting" | "success" | "error";

export interface FakeDoorIntentModalProps {
  feature: string;             // e.g. "SMS notifications"
  service: string;             // e.g. "Twilio"
  actionLabel: string;         // e.g. "sms_intent"
  pageName: string;            // e.g. "Dashboard"
  variant?: Variant;           // default: "primary"
  triggerLabel?: string;       // default: `Enable ${feature}`
  successHeadline?: string;    // default: "We've noted your interest."
  successBody?: string;        // default: "We'll launch this when there's enough interest."
}

export function FakeDoorIntentModal({
  feature, service, actionLabel, pageName,
  variant = "primary", triggerLabel,
  successHeadline, successBody,
}: FakeDoorIntentModalProps) {
  const [open, setOpen] = useState(false);
  const [status, setStatus] = useState<Status>("idle");
  const [errorMessage, setErrorMessage] = useState("");
  const successRef = useRef<HTMLDivElement>(null);

  // Rule 2: focus moves to success region on click→success transition.
  useEffect(() => {
    if (status === "success") successRef.current?.focus();
  }, [status]);

  // Rule 4: button-only intent capture. The click is the activation signal.
  // Do NOT collect email or any other PII — the track() call below MUST NOT
  // include user PII as event properties (the analytics provider stores event
  // data indefinitely; GDPR/CCPA exposure). For projects that need lead capture
  // (collect emails for outreach), add via /change as a separately declared Feature.
  async function onActivate() {
    setStatus("submitting");
    try {
      // Rule 4: if stack.analytics is absent, delete this line AND the import.
      track("activate", { fake_door: true, action: actionLabel, service });
      setStatus("success");
    } catch {
      setStatus("error");
      setErrorMessage("Something went wrong. Please try again.");
    }
  }

  // Rule 5: trigger wired via DialogTrigger callback `render` form per shadcn §
  // Trigger + interactive element. The callback merges {triggerLabel} into the rendered
  // Button — the element form `render={<Button .../>}` would silently drop the label on
  // Base UI primitives (#1146). Both Radix and Base UI accept the callback form.
  // The primitive handles focus return on Esc / overlay click / explicit close.
  //
  // Reset semantics: status resets ONLY on explicit "Back to {pageName}" click.
  // Closing via Esc / overlay leaves status alone — reopening shows the success panel if previously
  // submitted. This prevents double-firing of track on reopen via onActivate.
  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger
        render={(props) => (
          <Button
            type="button"
            {...props}
            className={cn(
              buttonVariants({ variant: variant === "primary" ? "default" : "ghost" }),
              variant === "ghost" && "border border-border/40"
            )}
          >
            {triggerLabel ?? `Enable ${feature}`}
          </Button>
        )}
      />

      <DialogContent>
        <DialogHeader>
          <DialogTitle>{feature} — coming soon</DialogTitle>
        </DialogHeader>

        {status !== "success" ? (
          <div className="space-y-3">
            <p className="text-sm text-muted-foreground">
              We are building {feature} with {service}. Click below to record your interest.
            </p>

            {/* Rule 1: live region is unconditionally mounted. Text toggles; container stays.
                Errors here surface analytics-call failures (track throws) or future network errors. */}
            <p role="alert" aria-live="assertive" className="min-h-[1.25rem] text-xs text-destructive">
              {status === "error" ? errorMessage : ""}
            </p>

            <Button type="button" onClick={onActivate} disabled={status === "submitting"}>
              {status === "submitting" ? "Sending..." : "Confirm interest"}
            </Button>
          </div>
        ) : (
          // Rule 2 + Rule 3: focusable success region with forward CTA.
          <div
            ref={successRef}
            tabIndex={-1}
            aria-live="polite"
            className="space-y-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <h3 className="text-base font-medium">
              {successHeadline ?? "We've noted your interest."}
            </h3>
            <p className="text-sm text-muted-foreground">
              {successBody ?? "We'll launch this when there's enough interest."}
            </p>
            <div className="flex items-center gap-2">
              {/* Rule 3 + Rule 5: forward CTA. Resets status AND closes the dialog;
                  Radix returns focus to the trigger via render-prop composition. */}
              <Button type="button" onClick={() => { setStatus("idle"); setOpen(false); }}>
                Back to {pageName}
              </Button>
              <span className="text-xs text-muted-foreground">
                Keep exploring — the rest of the app is live.
              </span>
            </div>
          </div>
        )}
      </DialogContent>
    </Dialog>
  );
}
```

### Callsite examples

```tsx
// src/app/dashboard/page.tsx — dashboard already has a primary CTA on the hero,
// so the Fake Door uses the subordinate ghost variant.
import { FakeDoorIntentModal } from "./sms-fake-door";

export function DashboardPrompt() {
  return (
    <FakeDoorIntentModal
      feature="SMS notifications"
      service="Twilio"
      actionLabel="sms_intent"
      pageName="Dashboard"
      variant="ghost"
    />
  );
}
```

```tsx
// src/app/settings/page.tsx — settings page has no competing primary CTA,
// so the default `primary` variant is appropriate. Demonstrates the
// optional `successHeadline` copy override.
import { FakeDoorIntentModal } from "./weekly-digest-fake-door";

export function WeeklyDigestPrompt() {
  return (
    <FakeDoorIntentModal
      feature="Weekly digest email"
      service="Mailchimp"
      actionLabel="weekly_digest_intent"
      pageName="Settings"
      successHeadline="Got it — interest noted."
    />
  );
}
```

## Stack Knowledge

### When scaffolding interactive buttons (remove, add, icon-only controls), enforce 44px mobile touch target floor

WCAG 2.5.5 (Target Size) requires touch targets to be at least 44×44 CSS pixels on mobile. shadcn's default icon button sizes (`h-7 w-7` = 28px, `h-9` = 36px) are below this floor — they look right on desktop but are difficult or impossible to tap on mobile. Always set the mobile floor to `h-11 w-11` (44px) and use the `md:` breakpoint to keep a tighter desktop aesthetic.

```tsx
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";

// WRONG — icon-only remove button at 28px, untappable on mobile,
// hover-only opacity-0 makes it invisible until focused
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100">
  <X className="h-4 w-4" />
</Button>

// CORRECT — 44px mobile floor with tighter desktop aesthetic,
// always-visible on mobile (no hover-only opacity)
<Button
  variant="ghost"
  size="icon"
  className="h-11 w-11 md:h-8 md:w-8 md:opacity-0 md:group-hover:opacity-100"
>
  <X className="h-4 w-4" />
</Button>

// CORRECT — Add button: 44px mobile, smaller desktop
<Button className="h-11 md:h-9">Add row</Button>
```

Applies to all interactive controls that may appear on mobile: remove (X) buttons in list rows, add buttons in form arrays, icon-only toolbar buttons, sort-direction toggles. Hover-reveal (`opacity-0 group-hover:opacity-100`) is acceptable on desktop (`md:` and above) but **never** on mobile — touch devices have no hover state, so the control becomes invisible AND untappable.

### When using role=alert or aria-live for error/status announcements

Always mount the live region UNCONDITIONALLY; toggle visibility via class swap, not via DOM insertion. Some screen readers miss announcements from regions inserted into the DOM after page load (WCAG 4.1.3 Status Messages).

```tsx
// WRONG — conditional mount; the live region is added to the DOM after the
// error appears, and screen readers that snapshot the accessibility tree at
// load time may miss the announcement:
{error && <p role="alert" aria-live="assertive">{error}</p>}

// CORRECT — unconditional mount; the live region exists from page load,
// only its visibility flips. Empty text content keeps the region inert
// (nothing announced) until an error is set.
<p
  role="alert"
  aria-live="assertive"
  className={error ? "text-destructive text-sm" : "sr-only"}
>
  {error ?? ""}
</p>
```

The `sr-only` class keeps the region in the accessibility tree but visually hidden when empty. When an error is set, remove `sr-only` to make it visible. Empty text keeps the region inert without announcing anything. This applies to every form-level error region, every status announcer (saved / submitted / pending), and any toast container that renders into a fixed position. Bootstrap-emitted forms that ship the conditional-mount form will be flagged by `accessibility-scanner` against WCAG 4.1.3.

### When using SheetContent, always include SheetTitle (visible or sr-only)
shadcn `<SheetContent>` (used for mobile navigation drawers, side panels, search overlays) renders a dialog-role element. WCAG 4.1.2 (Name, Role, Value) requires every dialog to have an accessible name. `SheetContent` does NOT provide a default accessible name — bootstrap-scaffolded mobile nav drawers that omit `<SheetTitle>` fail axe-core's `dialog-name` rule. Screen readers announce the drawer as "dialog" with no context.

For navigation drawers where a visible heading would be redundant (the drawer's purpose is obvious from its content), use a visually-hidden title:

```tsx
import { Menu } from "lucide-react";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { buttonVariants } from "@/components/ui/button";

// CORRECT — sr-only title satisfies dialog-name without competing with the visible content
<Sheet>
  <SheetTrigger aria-label="Open menu" className={buttonVariants({ variant: "ghost", size: "icon" })}>
    <Menu className="h-5 w-5" />
  </SheetTrigger>
  <SheetContent side="right">
    <SheetTitle className="sr-only">Site navigation</SheetTitle>
    {/* nav links */}
  </SheetContent>
</Sheet>
```

For content sheets (search panels, cart drawers, settings panels), use a **visible** `<SheetTitle>` — it doubles as the accessible name and the user-facing heading:

```tsx
import { SheetContent, SheetTitle } from "@/components/ui/sheet";

<SheetContent side="right">
  <SheetTitle>Cart</SheetTitle>
  {/* cart content */}
</SheetContent>
```

Both forms satisfy WCAG 4.1.2. The `sr-only` form is appropriate ONLY when no visible heading is warranted (pure navigation drawers, supplemental panels). When in doubt, prefer the visible form — accessibility users and sighted users get the same context.

### When a shadcn component variant class string is used in multiple page callsites
Export the class string from a shared constants file (e.g., `src/components/<feature>/variants.ts`) and import it in each callsite. Duplicating the class string inline causes callsites to drift independently — one callsite may use `border border-ink/30` while another uses `ring-1 ring-ink/30`, producing inconsistent rendering that only surfaces during a dedicated consistency audit.

```ts
// src/components/<feature>/variants.ts
export const FEATURE_GHOST_TRIGGER =
  "variant-ghost border border-ink/30 hover:bg-ink/5 text-ink";
```

```tsx
// In each callsite:
import { DialogTrigger } from "@/components/ui/dialog";
import { twMerge } from "tailwind-merge";
import { FEATURE_GHOST_TRIGGER } from "@/components/<feature>/variants";
<DialogTrigger className={twMerge(FEATURE_GHOST_TRIGGER, className)} />
```

This applies to any non-default visual variant (ghost/outline/subordinate triggers, inverted cards, condensed list rows) used on 2+ pages. Hoist on the second callsite — don't wait for the third.

### When ESLint reports errors in auto-generated UI components
Add `src/components/ui/` and `src/components/magicui/` to the `ignores` array in `eslint.config.mjs`. Auto-generated shadcn and MagicUI files contain `@ts-nocheck` pragmas and useless-assignment patterns that ESLint flags — these directories must be excluded from linting since their contents are managed by the CLI, not by hand.

```js
{ ignores: [".next/", "out/", "node_modules/", "src/components/ui/", "src/components/magicui/"] }
```

### Skeleton Hydration Warning

The `<Skeleton>` component renders as a `<div>`. Do not nest it inside `<p>` tags — this causes React hydration errors because `<div>` (block element) is invalid inside `<p>` (phrasing content). The browser auto-closes the `<p>` before the `<div>`, creating a DOM mismatch with the server-rendered HTML. Use `<div>` or `<span>` containers instead when showing loading states for text content.

```tsx
import { Skeleton } from "@/components/ui/skeleton";

// WRONG (hydration error — div inside p):
<p className="text-lg font-bold">
  {loading ? <Skeleton className="h-6 w-16" /> : value}
</p>

// CORRECT (div container):
<div className="text-lg font-bold">
  {loading ? <Skeleton className="h-6 w-16" /> : value}
</div>
```

### When a shadcn Button (or any Tailwind element) has an inline `style` prop, hover and focus-visible states silently stop working
When a project sets a custom background or color via an inline `style` object (e.g., `style={{ backgroundColor: "#fbfaf5" }}` on an OAuth provider button), inline styles take precedence over class-based rules — so `hover:bg-*` and `focus-visible:ring-*` Tailwind utilities applied via `className` have no effect at runtime. The button appears non-interactive: no hover background change, no focus ring.

**Fix:** Move the color to a scoped CSS class in `globals.css` that explicitly declares the base, `:hover`, and `:focus-visible` states, then apply the class (instead of `style={}`):

```css
/* src/app/globals.css */
.oauth-button-google {
  background-color: #fbfaf5;
}
.oauth-button-google:hover {
  background-color: #f3f1e8;
}
.oauth-button-google:focus-visible {
  outline: 2px solid #f59e0b;
  outline-offset: 2px;
}
```

```tsx
import { Button } from "@/components/ui/button";

// WRONG — inline style kills hover states
<Button variant="outline" style={{ backgroundColor: "#fbfaf5" }}>Continue with Google</Button>

// CORRECT — scoped class preserves interactive states
<Button variant="outline" className="oauth-button-google">Continue with Google</Button>
```

This applies to any shadcn component (Button, Card, Input) that needs a branded custom color outside the `--primary` / `--secondary` theme tokens. If the custom color is reused in 2+ places, promote to a Tailwind `@theme` token in `globals.css` rather than a named class — see Theme Setup above.

### MagicUI per-component post-install fixes
After installing a MagicUI component via `npx shadcn@latest add`, some components require manual fixes (apply only to components you actually installed):

- `client-tweet-card.tsx`: change import from `@/components/tweet-card` to `@/components/ui/tweet-card`
- `globe.tsx`: add `// @ts-nocheck` at the top of the file (`COBEOptions` type incompatibility with the installed version)
- Missing runtime dependencies: run `npm install @shikijs/transformers` (required by `code-comparison.tsx`) or `npm install @radix-ui/react-accordion` (required by `file-tree.tsx`) — only when the corresponding component was installed
- Missing shadcn component: run `npx shadcn@latest add -y scroll-area` (only required when `file-tree.tsx` was installed)

These fixes are needed because MagicUI CLI generates components with import paths and dependency assumptions that don't match the shadcn/ui project structure. Apply per-component after install, not as a batch.

### When text-foreground opacity on dark backgrounds drops below /60, WCAG 1.4.3 contrast fails
On dark-theme projects (`--background` near slate-950, warm-obsidian, or any custom dark token), `text-foreground/30`, `/40`, and `/50` utilities produce text that falls below the WCAG 1.4.3 Minimum Contrast ratio of 4.5:1. axe-core flags these as violations during accessibility-scanner runs. As a starting heuristic, keep `text-foreground` at **/60 or higher** for body and secondary text on dark backgrounds.

```tsx
// WRONG on dark bg (contrast ratio < 4.5:1 — axe-core flags as WCAG 1.4.3)
<p className="text-foreground/30">Secondary label</p>

// CORRECT — /60 is the next-higher valid opacity step and clears 4.5:1 on most dark tokens
<p className="text-foreground/60">Secondary label</p>
```

The minimum valid step is enforced by Tailwind's default opacity scale (see `Opacity modifier values` above — `/60` is in the scale; `/55` is NOT and would be silently dropped). The exact minimum ratio depends on the specific background color — run an axe-core scan (accessibility-scanner agent) after any opacity change on dark backgrounds to verify the actual ratio. Heuristic /60 is safe for most dark themes; lighter tokens may tolerate /50.

### When custom oklch CSS tokens for secondary or annotation text fail WCAG 1.4.3
Custom theme tokens defined in `globals.css` using `oklch()` can fall below the WCAG 1.4.3 Minimum Contrast ratio of 4.5:1 against the project's background even when the lightness value looks adequate on paper. axe-core measures the actual rendered hex equivalent against the rule, and oklch lightness does NOT map linearly to perceived contrast — a token at `oklch(0.42)` that reads as "mid-lightness" can render around 3.95:1 against a parchment-tone background and fail AA. Tokens commonly affected: `--annotation`, `--chart-2`, `--color-5`, and any custom token used for secondary, marginal, or chart-label text.

```css
/* WRONG — looks mid-lightness but renders 3.95:1 against a parchment background */
:root {
  --annotation: oklch(0.42 0.03 80);
}

/* CORRECT — darken until axe-core reports ≥ 4.5:1 (project-specific; verify) */
:root {
  --annotation: oklch(0.36 0.03 80);
}
```

After defining or editing any custom oklch token used for text, run the accessibility-scanner agent (or `axe-core` directly) to measure the actual contrast ratio against the project background. Darken the token in 0.04–0.06 lightness steps until the ratio clears 4.5:1. **Combining opacity modifiers** (e.g., `text-[var(--annotation)]/60`) on already-borderline tokens compounds the failure — the entry above on `text-foreground` opacity is the same defect mechanism applied to a custom token. Verify post-change.

### When styling input placeholder text on warm/cream/light backgrounds, never apply opacity modifiers
On warm-palette projects with cream or parchment backgrounds (common for editorial / lifestyle / hospitality products), placeholder color utilities like `placeholder:text-[var(--ink)]/40` drop below the WCAG 1.4.3 Minimum Contrast ratio of 4.5:1. The opacity modifier compounds an already-warm token against a low-contrast background; placeholder text is also visually de-emphasized by the browser's default opacity, multiplying the effect.

```tsx
import { Input } from "@/components/ui/input";

// WRONG — /40 IS in the Tailwind default scale, but compounds to <4.5:1 against a cream background
<Input
  placeholder="you@example.com"
  className="placeholder:text-[var(--ink)]/40"
/>

// CORRECT — drop the modifier entirely; use a solid pre-calibrated muted-ink token
<Input
  placeholder="you@example.com"
  className="placeholder:text-[var(--ink-soft)]"
/>
```

Define `--ink-soft` (or your project's equivalent) in `globals.css` as a solid token that has been verified against the project's background in DevTools / axe-core to clear 4.5:1. Do not apply any opacity step — the right answer on light backgrounds is "use a solid token", not "use a higher step".

This entry is distinct from two related ones above:
- "When text-foreground opacity on dark backgrounds drops below /60" — on dark backgrounds the fix is to use a higher in-scale step (`/60`+); on light backgrounds the fix is to drop the modifier entirely.
- "When custom oklch CSS tokens for secondary or annotation text fail WCAG 1.4.3" — on borderline custom oklch tokens the fix is to darken the token; the placeholder + light-bg combination is a distinct case because the `placeholder:` variant adds the browser's own placeholder de-emphasis on top.

Out-of-scale modifiers like `/55` and `/65` hit a separate failure mode — they are silently dropped per "Opacity modifier values" earlier in this file, so the rendered placeholder gets the FULL token (no opacity at all). That's a different defect; this entry covers in-scale modifiers like `/40` that DO render but produce <4.5:1 contrast.

### When using shadcn CardTitle on a page where the surrounding hierarchy is not h2-deep
shadcn `CardTitle` (in `src/components/ui/card.tsx`) renders a fixed heading element regardless of page heading context — recent versions render as `<div>`, earlier versions as `<h3>`. This is hard-coded in the scaffolded file, not configurable via an `as` prop or render prop. On a page with a single `<h1>` and a row of cards, the rank jumps from 1 → 3 (skipping `<h2>`) and axe-core fires `heading-order` (see the paired entry in `.claude/stacks/framework/nextjs.md` → `When card or list-item titles skip a heading level`).

Because `src/components/ui/card.tsx` is scaffolded into the project tree (not imported from a package), the right fix is to **edit the scaffolded file directly** to render the rank that matches the surrounding hierarchy on the pages that use it. Per-project, not per-component-instance.

```tsx
// src/components/ui/card.tsx — pick ONE rank per project that matches the dominant card-page hierarchy.
// On pages with a single <h1> and top-level cards, h2 is correct. On pages with <h2> sections containing
// sub-cards, h3 is correct. If the project mixes both, hoist a rank prop OR maintain two card components.
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
  return (
    <h2  /* was <div> or <h3>; choose the rank that matches the surrounding hierarchy */
      data-slot="card-title"
      className={cn("font-semibold", className)}
      {...props}
    />
  );
}
```

Verify at every usage site that the rank chosen produces a contiguous outline (`<h1>` → `<h2>` → `<h3>` → `<h4>`, no gaps). Re-run axe-core after the edit.

### When a `<Tabs>` component renders more tabs than fit on a mobile viewport
shadcn `<TabsList>` natural width expands with each trigger; on mobile viewports (iPhone 12-class ≈375px) a 3-tab list with moderate label lengths easily exceeds the viewport. When the parent container has `overflow-x-hidden` (a common horizontal-overflow prevention pattern), the rightmost tab(s) clip off-screen and become unreachable — users cannot scroll horizontally to access them.

Wrap `<TabsList>` in a negative-margin horizontal-scroll container scoped to mobile:

```tsx
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

<div className="-mx-6 overflow-x-auto px-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden md:mx-0 md:overflow-visible md:px-0">
  <TabsList>
    <TabsTrigger value="tab1">Tab 1</TabsTrigger>
    <TabsTrigger value="tab2">Tab 2</TabsTrigger>
    <TabsTrigger value="tab3">Tab 3</TabsTrigger>
  </TabsList>
</div>
```

The `-mx-6 px-6` bleed the scroll region to the page edge while keeping content gutters aligned. `[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden` removes the visible scrollbar (preserves scrollability via touch/trackpad). `md:overflow-visible md:px-0` restores normal layout on desktop where the list fits naturally. Without this wrapper, ancestor `overflow-x-hidden` clips off-viewport tabs with no scroll affordance (fix #1073).

### When scaffolding custom `role="radio"` clusters, use roving tabindex + Arrow/Home/End keyboard nav

Custom `<button role="radio">` clusters (e.g., a state-picker card grid, claimant-type cards, plan-tier toggles) MUST implement the WAI-ARIA radiogroup keyboard contract. Without it, every option ships with `tabIndex=0` (so Tab stops on every radio), and Arrow keys do nothing — failing WCAG 2.1.2 (No Keyboard Trap) AND the WAI-ARIA radiogroup contract. axe-core, scaffold-pages, and visual-implementer all assume this contract; native `<input type="radio">` gets it for free, but custom button clusters do not.

The contract:

- Container has `role="radiogroup"` AND `aria-label` (or `aria-labelledby` referencing visible text) — see "When labeling a radiogroup" entry below for the labelling contract.
- Each option `<button role="radio" aria-checked={active}>`.
- **Roving tabindex**: exactly ONE option per group has `tabIndex=0` (the selected option; or option 0 when nothing is selected); siblings are `tabIndex=-1`. This is the "roving" focus pattern — only one option is in the natural tab order at any time.
- `onKeyDown` handler maps: `ArrowRight` / `ArrowDown` → next (wrap to first after last); `ArrowLeft` / `ArrowUp` → previous (wrap to last after first); `Home` → first; `End` → last.
- Arrow keys ALSO select (matches native `<input type="radio">` semantics), and the handler MUST move DOM focus to the new option (this is what makes it "roving").

Reference implementation (paste into your component file alongside the radio cluster):

```tsx
function handleRadioGroupKey<T extends string>(
  event: KeyboardEvent<HTMLElement>,
  values: ReadonlyArray<T>,
  current: T | null,
  onChange: (v: T) => void,
): void {
  const last = values.length - 1;
  if (last < 0) return;
  const currentIdx = current === null ? 0 : Math.max(0, values.indexOf(current));
  let nextIdx: number | null = null;
  switch (event.key) {
    case "ArrowRight":
    case "ArrowDown": nextIdx = currentIdx === last ? 0 : currentIdx + 1; break;
    case "ArrowLeft":
    case "ArrowUp":   nextIdx = currentIdx === 0 ? last : currentIdx - 1; break;
    case "Home":      nextIdx = 0; break;
    case "End":       nextIdx = last; break;
    default: return;
  }
  if (nextIdx === null) return;
  event.preventDefault();
  onChange(values[nextIdx]);
  const container = (event.currentTarget as HTMLElement).parentElement;
  (container?.children[nextIdx] as HTMLElement | undefined)?.focus();
}

function rovingTabIndex<T extends string>(
  values: ReadonlyArray<T>,
  current: T | null,
  index: number,
): 0 | -1 {
  if (current === null) return index === 0 ? 0 : -1;
  return values[index] === current ? 0 : -1;
}
```

Usage at the call site:

```tsx
<div role="radiogroup" aria-label="Delivery preference">
  {OPTIONS.map((value, index) => (
    <button
      key={value}
      role="radio"
      aria-checked={current === value}
      tabIndex={rovingTabIndex(OPTIONS, current, index)}
      onClick={() => setCurrent(value)}
      onKeyDown={(e) => handleRadioGroupKey(e, OPTIONS, current, setCurrent)}
    >
      {labels[value]}
    </button>
  ))}
</div>
```

Applies to every custom button cluster that semantically behaves as a radio group. For text-only / linear option lists, prefer the shadcn `<RadioGroup>` primitive (which implements the contract internally).

Agents that scaffold these clusters MUST apply the contract: see `.claude/agents/scaffold-pages.md` and `.claude/agents/visual-implementer.md` — both reference this entry when generating role=radio component code.

### When labeling a radiogroup, use aria-label on the group — not a bare shadcn Label
shadcn `<Label>` renders an HTML `<label>` element. A `<label>` requires a `htmlFor` pointing to a *labelable* element. `<RadioGroup>` renders `<div role="radiogroup">` — a div is NOT a labelable element — so a floating `<Label>` placed visually above a `<RadioGroup>` creates a dangling label: it has no programmatic association with any control, and screen readers announce the group with no name. axe-core fires `label` (WCAG 1.3.1 Info and Relationships).

```tsx
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";

// WRONG — <Label> with no htmlFor: dangling label, not associated with the group
<Label>Delivery preference</Label>
<RadioGroup defaultValue="email">
  <RadioGroupItem value="email" id="opt-email" />
  <Label htmlFor="opt-email">Email</Label>
  <RadioGroupItem value="sms" id="opt-sms" />
  <Label htmlFor="opt-sms">SMS</Label>
</RadioGroup>

// CORRECT — visible group heading as <span> or <p>; group named via aria-label
<span className="block text-sm font-medium mb-2">Delivery preference</span>
<RadioGroup aria-label="Delivery preference" defaultValue="email">
  <RadioGroupItem value="email" id="opt-email" />
  <Label htmlFor="opt-email">Email</Label>
  <RadioGroupItem value="sms" id="opt-sms" />
  <Label htmlFor="opt-sms">SMS</Label>
</RadioGroup>
```

If the heading element is a distant sibling (or a real heading like `<h3>` higher in the layout), give it an `id` and reference it with `aria-labelledby` on the `<RadioGroup>` instead of `aria-label`. Individual radio options inside the group still use their own `<Label htmlFor="...">` pointing to the radio `<input id="...">` — that pairing is correct because the radio input IS a labelable element.

### When using dl for structured data (stat blocks, label-value pairs)

All direct children of `<dl>` must be `<div>` wrappers containing ONLY `<dt>` and `<dd>` pairs — no sibling `<p>` or bare `<span>` elements. axe-core's `definition-list` rule under WCAG 1.3.1 (Info and Relationships) rejects any non-dt/dd descendants.

Two common failure patterns:

1. **Sibling `<p>` outside `<dd>`** — when adding a sub-label or annotation, putting it as a sibling of the `<dd>` violates the rule:

   ```tsx
   // WRONG — sibling <p> outside <dd>
   <dl>
     <div>
       <dt>Revenue</dt>
       <dd>$1.2M</dd>
       <p className="text-sm text-muted-foreground">YoY +14%</p>
     </div>
   </dl>

   // CORRECT — move sub-label inside <dd> as a block-display span
   <dl>
     <div>
       <dt>Revenue</dt>
       <dd>
         $1.2M
         <span className="block text-sm text-muted-foreground">YoY +14%</span>
       </dd>
     </div>
   </dl>
   ```

2. **No dt/dd at all (decorative spans)** — stat blocks rendered as direct `<dl>` children emitting decorative `<span>` elements:

   ```tsx
   // WRONG — no <dt>/<dd>, just decorative spans
   <dl>
     <span className="stat-label">Revenue</span>
     <span className="stat-value">$1.2M</span>
   </dl>

   // CORRECT — replace decorative spans with <dt>/<dd>
   <dl>
     <div>
       <dt className="stat-label">Revenue</dt>
       <dd className="stat-value">$1.2M</dd>
     </div>
   </dl>
   ```

Applies to every stat grid, key-metric block, and label-value list rendered with `<dl>`. If the structured pattern is genuinely a list of unrelated items rather than label-value pairs, use `<ul>` / `<ol>` instead of `<dl>`.

### When a Button renders a loading state with non-text content, add aria-label

A loading-state `<Button>` that uses `&nbsp;`, a spinner icon, or animated dots as its only visible content has no accessible name — axe-core fires `button-name` (critical, WCAG 4.1.2 Name, Role, Value). Screen readers announce it as an anonymous "button" with no context.

Add `aria-label` describing the action in progress, and wrap the decorative placeholder in `aria-hidden`:

```tsx
import { Button } from "@/components/ui/button";
import { Loader } from "lucide-react";

// WRONG — no accessible name
<Button disabled>
  &nbsp;
</Button>

// CORRECT — aria-label names the action; non-text content hidden from a11y tree
<Button disabled aria-label="Loading account status">
  <span aria-hidden="true">&nbsp;</span>
</Button>

// CORRECT — same pattern for spinner icon
<Button disabled aria-label="Saving changes">
  <Loader className="h-4 w-4 animate-spin" aria-hidden="true" />
</Button>
```

Applies to any loading-state button that substitutes non-text content (spinner, dots, `&nbsp;`) for the normal button label. Common locations: nav bars rendering an auth-status placeholder before client-side hydration, save buttons during form submission, payment buttons during checkout. When the same component is rendered on multiple pages, the violation cascades — fixing it once covers all callsites.

## Import Example
```tsx
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
```

## PR Instructions
- No additional setup needed — shadcn components are committed to the repository
