---
name: create-ui-matrix
description: Use when building granular per-page, per-role UI-element regression tests — asserting every button is clickable/disabled when it should be, every input/textarea/table/form renders and behaves per role, catching small UI regressions a flow test misses. Complements create-journeys (flows); this is the spatial element-state matrix.
---

# Create UI Element Matrix

audience: AI coding agents first.

Build a **granular per-page × per-role UI-element regression matrix**: assert each element's expected state (present/absent, enabled/disabled, required, editable, empty/populated) on each page, for each role. Catches small UI bugs — a button enabled when it must be disabled, a table that stops rendering, a textarea that lost its maxlength — that flow tests skip.

**Distinct from `create-journeys`.** Journeys = temporal, cross-page, one observable outcome, happy + failure branches (catch FLOW bugs). This = spatial, per-page, per-role element-state inventory (catch UI REGRESSIONS). Do NOT merge them; do NOT put element-state assertions in journey files. They run as separate passes.

## Never hand-write a test per element

At scale (pages × elements × roles) bespoke tests are unmaintainable and rot. Build **three layers, strongest leverage first** — stop at the layer that covers the need.

### Layer 1 — Component-contract tests (do FIRST, highest leverage)

If the project mandates a shared component library (all UI from `@/components/ui/**` or equivalent), test each shared component's behavior contract ONCE, at component level (drive the design-system gallery): Button (enabled/disabled/loading/click fires), Textarea (types/maxlength/required/error), Table (populated/empty/loading render), Select, Dialog, etc. Get these right and every instance everywhere inherits correct behavior. Then per-page work collapses to "the RIGHT component is present in the RIGHT state" — not re-testing button mechanics on 100 pages.

### Layer 2 — Generic per-page × per-role invariant sweep

One data-driven runner iterates `pages × roles`, logs in per role, visits each page, asserts **real invariants** — NEVER `body visible` / `toHaveURL(/.*​/)`:

```ts
// DO NOT — indistinguishable from the hollow smoke suite, catches nothing:
await page.goto(route); await expect(page.locator('body')).toBeVisible();

// DO — invariants that fail on real regressions:
await checkA11y(page);                                   // axe-core: labels, roles, contrast
expect(consoleErrors, route).toHaveLength(0);            // zero console errors
expect(failedRequests, route).toHaveLength(0);           // zero failed XHR/fetch
await expect(page).toHaveScreenshot(`${route}-${role}.png`); // pixel-stable render (mask dynamic)
```

Covers the long tail cheaply. Add per-page × role only as data rows.

### Layer 3 — Declarative element manifest (high-value pages only)

For pages where exact element state per role matters (checkout, deal form, admin tables), declare expected states as DATA; one generic runner asserts each row. Manifest is the single source of truth — adding a page = a data entry, not a new test.

```ts
// DO — declarative page × role element states:
const MATRIX = [
  { route: '/vendor/deals/new', role: 'vendor', elements: [
    { testid: 'deal-submit',  state: 'disabled' },   // empty form
    { testid: 'deal-title',   state: 'editable', required: true },
    { testid: 'deal-price',   state: 'editable' },
  ]},
  { route: '/vendor/deals/new', role: 'user', elements: [] , expect: 'redirect:/login' }, // wrong role denied
  { route: '/admin/settlements', role: 'admin', elements: [
    { testid: 'settlements-table', state: 'present' },
    { testid: 'mark-paid',         state: 'enabled' },
  ]},
];
// runner: for each row → login as role → goto route → assert each element's state.
```

Reserve Layer 3 for money/complex surfaces. Layers 1+2 already cover the rest — do NOT manifest all pages.

## Role matrix

Two distinct role axes — keep separate:

- **Which page/route a role may reach** (authz): assert wrong-role → redirect/403. Owned partly by journey `Permissions and boundaries`; here it gates the sweep.
- **Same page, different element states per role** (the matrix): e.g. admin sees `mark-paid` enabled; user never reaches the page. This is Layer 3's core.

Drive roles via per-role `storageState` fixtures (one authenticated context per role), iterated by the runner. Reuse the project's existing auth-injection helper — never re-mint sessions ad hoc.

## Harness fit

Playwright + axe-core + a visual-regression baseline + per-role auth fixtures. If the project already has these (most do), this needs a manifest + a generic runner, not new infrastructure. Assert selectors by stable `data-testid`/route/accessible-name — never by DOM ordinal (`.first()`/`.nth()`), which shifts across renders.

## Quality bar

Every assertion MUST fail on a real regression. Reject any check that passes for a broken page: `body` visible, `toHaveURL(/.*​/)`, range status (`toBeLessThan(500)`), or `if (visible) {...}` branches that silently skip. One expected state → one exact assertion. Multiple legit states → one row each.
