---
name: liquid-glass-form-primitives
classification: feature
success_metric: |
  `<Button>`, `<Input>`, `<SearchBar>` primitives ship under
  `components/ui/`; the .btn-primary / .btn-flame / .btn-ember /
  .btn-gold / .input-field / .search-bar utility classes either become
  thin aliases of the new primitives' styling OR are deprecated for #8
  to delete; every consumer of those classes is migrated; focus-rings
  use the new ember-rim tokens; lint + vitest + smoke green.
skip:
  - ia
status: in-progress-brief-1-merged
created: 2026-06-03
conductor_started: 2026-06-03
brief_1_merged: 2026-06-03
depends_on:
  - liquid-glass-design-tokens
umbrella: liquid-glass-redesign
---

# Convoy: liquid-glass-form-primitives

Sub-convoy #3 of the `liquid-glass-redesign` epic. Introduces the
button, input, and search-bar primitives — the second half of the
foundational reusable kit (after the modal + surface primitives of #2).
Parallel-safe with #2 after #1 merges.

## Why

The repo defines five button utility classes (`.btn-primary`,
`.btn-flame`, `.btn-ember`, `.btn-gold`, `.btn-secondary`) and two
input classes (`.input-field`, `.search-bar`) directly in
`styles/globals.css`. Each uses opaque ember/flame gradient fills + a
single drop shadow — i.e. the "warm panel" aesthetic the redesign is
moving away from.

Buttons and inputs are the densest interactive surface in the app. If
every other surface goes glass and these stay opaque, the visual
hierarchy fights itself.

A small primitive set lets callers express *intent* (primary action,
ghost secondary, ember rim-light hover state) without composing
Tailwind class strings or wiring ad-hoc `style={{}}` objects.

## Scope

### In scope — primitives

- `components/ui/Button.js` (new). Props:
  - `variant`: `'primary'` (ember rim-light glass pill), `'ghost'`
    (glass with no accent until hover), `'ember'` (solid ember for
    destructive / high-emphasis CTAs), `'gold'` (celebration / rarity
    CTAs), `'icon'` (square glass for header icon-only buttons).
  - `size`: `'sm' | 'md' | 'lg'`.
  - `loading` (boolean) — shows an inline spinner; disables button.
  - `iconLeft`, `iconRight` — slot for SVG icons.
  - Standard `<button>` props (type, onClick, disabled, aria-label,
    ...rest).
  - Renders a native `<button>` with the glass styling + focus ring +
    the appropriate ARIA when used as icon-only.
- `components/ui/Input.js` (new). Props:
  - `label` (required for a11y; visible by default; can be
    `srOnly={true}`).
  - `id` (auto-generated if absent).
  - `error` (string; renders connected via `aria-describedby` per the
    a11y finding in `.convoys/ship-readiness.md` § Role-a11y-auditor:
    *"login/signup form errors are visually red but not connected to
    inputs via `aria-describedby`"* — this primitive CLOSES that
    finding).
  - `description` (optional hint text).
  - Standard `<input>` props.
- `components/ui/SearchBar.js` (new). Wraps `<Input>` with the
  search-magnifier icon, ⌘K keyboard hint slot, and a dedicated focus
  state (the search bar is currently the most visually distinct input
  in the app).
- Tests under `test/components/`:
  - `Button.test.js` — variants render, `loading` disables, icon-only
    requires `aria-label`, focus ring visible on `:focus-visible`.
  - `Input.test.js` — label association, error -> `aria-describedby`
    wiring, visually-hidden label via `srOnly`.

### In scope — migration sweep

Migrate consumers of the legacy classes to the new primitives:

- Every page under `pages/**/*.js` that uses `className="btn-primary"`
  / `"btn-flame"` / `"btn-ember"` / `"btn-gold"` / `"action-btn-primary"`
  / `"action-btn-secondary"`.
- Every component under `components/**/*.js` that uses those classes.
- `input-field` / `search-bar` / `theme-toggle` / `header-icon`
  consumers.

The legacy utility classes in `styles/globals.css` are **NOT deleted
here** — they remain as thin aliases (or stub no-ops) until sub-convoy
#8 deletes them as a batch. This keeps the diff per PR readable.

### Out of scope

- Modal / Surface primitives — sub-convoy #2.
- Layout shell — sub-convoy #4.
- Card surfaces — sub-convoy #5.
- New form patterns (multi-step wizards, etc.) — orthogonal scope.

## Roles invoked

1. `role-architect` — primitive API, brief decomposition (likely 3–4
   briefs by file cluster).
2. `role-a11y-auditor` — pre-implementation review of Button + Input
   ARIA contracts (especially `<Input>` error association).
3. `role-design-system-auditor` — sign-off on variant shape +
   focus-ring recipe.
4. `role-implementer` — multiple briefs.
5. Post-PR audit fleet.

## Architecture (ratified 2026-06-03)

**Primitives:**
- `components/ui/Button.js` — `forwardRef`. Variants: `primary` (ember gradient w/ ember-rim-pronounced + rim-light-inner; hover scales 1.02; active scales 0.98), `secondary` (glass-surface-high + rim-subtle), `danger` (#dc2626), `ghost` (transparent w/ ember-tinted hover). Sizes: sm/md/lg. Built-in `loading` (aria-busy + spinner replaces leading icon), `disabled` (opacity 0.5 + pointer-events-none), `leadingIcon` + `trailingIcon`, ember focus-visible ring.
- `components/ui/Input.js` — `forwardRef`. Glass-surface-high background, ember focus ring, supports `label` (semantic htmlFor/id pairing), `error` (red border + red message + aria-invalid + aria-describedby), `helperText` (mutually exclusive with error), `leadingIcon` (decorative pointer-events-none), `trailingAction` (interactive). All native input props pass through.
- `components/ui/SearchBar.js` — `forwardRef`. Wraps Input with leading search icon, conditional clear button (renders only when value non-empty AND onClear provided). Defaults type="search", placeholder "Search…".

**Test plan:** `test/components/ui-primitives.test.js` — 10 cases. Button: children/onClick, loading state (aria-busy + disabled), disabled suppresses click, all 4 variants render. Input: label/htmlFor pairing, error sets aria-invalid + describedby + visible message, helperText path with no error. SearchBar: search-type input + placeholder, clear button conditional on value + onClear, no clear when onClear missing.

## Briefs

- **Brief 1 (shipped 2026-06-03):** Primitives + tests + 2 reference page migrations (login.js, signup.js — both smoke-tested critical paths). 7 inputs + 2 submit buttons migrated. Vitest 104/104 green (+10 new primitive tests). Lint 0 errors. Existing `test/pages/login.test.js` assertion ("Sign in to Deck Hearth" button text) preserved.
- **Brief 2 (queued for follow-up):** Sweep remaining form-bearing surfaces — profile/settings pages, deck-builder text inputs, scanner search field, card-editor admin form, all collection-cluster modal forms (Brief 2 here lands AFTER #2's Brief 2 so the modal shell is already in place). Mechanical migration following the login/signup pattern.

## Todos

- [ ] Architect: primitive API + brief decomposition
- [ ] A11y auditor: pre-impl ARIA review
- [ ] Design-system auditor: variant shape + focus-ring recipe
- [ ] Brief 1 — primitives + tests + migrate 2 reference pages
      (`pages/login.js`, `pages/signup.js` — the most form-dense
      auth surfaces, also covered by smoke spec #2)
- [ ] Brief 2 — migrate `components/**` consumers (cluster by neighbor)
- [ ] Brief 3 — migrate `pages/**` consumers (cluster by neighbor)
- [ ] Post-PR audit per brief

## Decisions to ratify (architect)

1. **`<Button variant>` set** — proposal: `primary | ghost | ember |
   gold | icon`. Confirm or trim.
2. **Loading state shape** — inline spinner vs button-shaped
   skeleton. Recommended: inline spinner that replaces `iconLeft` slot.
3. **Focus ring recipe** — `box-shadow: 0 0 0 3px var(--ember-rim-pronounced)`
   on `:focus-visible` (NOT `:focus` — keep mouse-click focus clean).
   Confirm.
4. **Input error wiring** — `aria-invalid="true"` + `aria-describedby`
   pointing at the error `<span>`. Confirm.
5. **`<Input srOnly>` rendering** — `class="sr-only"` on the label,
   not removed from DOM. Required for screen readers.
6. **Migration approach for ad-hoc inline buttons** — many pages
   compose `<button className="bg-[...] text-[...] ...">` directly with
   no utility class. Architect inventories these during architect pass
   and decides whether to fold into briefs or leave for sub-convoy #8's
   hex sweep.

## Acceptance criteria

1. `<Button>`, `<Input>`, `<SearchBar>` exist under `components/ui/`.
2. Tests pass.
3. Every consumer of the 7 legacy utility classes is migrated OR
   marked for #8.
4. `<Input>` error states wire `aria-describedby` (closes a11y
   finding).
5. Lint + vitest + smoke green.
6. Visual-diff baselines re-seeded per brief.

## CI impact

| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires. Smoke spec 2 (`'sign-in page renders'`) defends Button migration on `/login` post-Brief 1. |
| `visual-diff.yml` | **Fires + LOUD** — buttons appear everywhere. |
| `lint` | Fires. |
| `test:` (vitest) | Fires + new Button/Input assertions lock primitive contract. |
| New grep gates | None for this convoy; #8 may add a `forbidden-legacy-btn-class` grep gate post-cleanup. |

## Known constraints

- **Tailwind utility classes inside primitive** are fine — the
  primitive IS the abstraction; nothing outside it cares.
- **No new third-party form library.** Plain `<button>` / `<input>`
  underneath; no Formik, no react-hook-form. Existing forms in the
  codebase manage state with `useState`; that pattern stays.
- **Theme tokens** — primitives consume ONLY tokens from #1; no
  hardcoded hex.

## Multitask dispatch

```yaml
slice_dependencies:
  - brief: 1
    depends_on: []
    files:
      - components/ui/Button.js
      - components/ui/Input.js
      - components/ui/SearchBar.js
      - components/ui/index.js
      - test/components/Button.test.js
      - test/components/Input.test.js
      - pages/login.js
      - pages/signup.js
  - brief: 2
    depends_on: [1]
    files:
      # Architect-curated cluster of component consumers
      - components/CardItem.js
      - components/CardDetailView.js
      - components/CardsPageView.js
      - components/CollectionPageView.js
      - components/CollectionsPageView.js
      - components/ScannerPageView.js
      # ... (architect completes inventory)
  - brief: 3
    depends_on: [1]
    files:
      # Architect-curated cluster of page consumers
      - pages/index.js
      - pages/profile.js
      - pages/settings.js
      - pages/my-cards.js
      - pages/collections.js
      # ... (architect completes inventory)
```

**After Brief 1 merges:** `/multitask role-implementer briefs 2, 3`
(disjoint file sets if architect partitions correctly).

## Out of scope follow-ups

- **`forbidden-legacy-btn-class`** grep gate (P3 hygiene) — surface for
  #8's cleanup. Forbid `className="btn-(primary|flame|ember|gold|
  secondary)"` post-migration.
- **`form-validation-library-adoption`** — react-hook-form vs Zod vs
  homegrown. Out of scope; would be its own architect-led convoy.
