Skip to content
Back to skills

State Matrix

ASecurity

Write or lint a feature's UI state matrix (G12).

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 20, 2026
ai-agentspythongobashreactvueawsfrontendbackend

Works with

  • cli

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill state-matrix --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of State Matrix?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for State Matrix
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-state-matrix/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-state-matrix)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: state-matrix
description: Write or lint a feature's UI state matrix (G12).
kind: skill
pack: ui-ux
allowed-tools: [Read, Write, Edit, Grep, Glob, Bash]
---

# Skill: state-matrix

## Premise

A UI feature is designed one state at a time, or it is designed for the happy path only. The state matrix is the design-time contract for every surface the feature touches. It has one row per (surface × state). Each row says what the user sees in that state: the message, the action, and where focus goes. Each row names the story or test that will force the state and prove it. A row may be `n-a` only with a reason that says why the state cannot occur.

The matrix is the input of three later steps:

- **P5:** the acceptance tests come from its rows.
- **P8–P11:** `@ux-reviewer` reviews the surfaces against it.
- **Gate G12 (usability and state coverage):** G12 has a computational half, `scripts/state-matrix-lint.py` (this skill's lint mode), and an inferential half, `@ux-reviewer`.

Its rows are the defects `ui-state-scan` (this pack) looks for afterwards. A matrix row that holds is a class that scan reports as covered.

**Two modes:**

- **Design** writes the matrix at P3, before code.
- **Lint** checks it at P3 (`--phase=design`) and again at the gate, once the stories and tests exist.

## Where the matrix lives

| Context | File | Writer | Notes |
|---|---|---|---|
| Delivery run, `@ux-designer` installed | `ai/runs/<run-id>/ux.md` § `## State matrix` | `@ux-designer` (ui-ux) | [`run-ledger.md`](../../../../snippets/run-ledger.md) § 2: P3, UI lanes, ≤ 150 lines, checked by `@ux-reviewer` |
| Delivery run, before `@ux-designer` ships | `ai/runs/<run-id>/design.md` § `## State matrix`, inside the UI slice | `@architect` (delivery) | `/add-feature` P3 records `skips[]: {phase: P3, covered_by: "design.md UI slice (P4)"}` |
| No run (a design pass, an audit follow-up) | `ai/design/state-matrix/<feature>.md` | the session that runs this skill | lint it by path |

**This skill writes only into the file its caller owns.** `artifact-ownership.sh` denies a write to another role's artifact while a run is open. The lint reads `ux.md` first, then `design.md`.

## The states

| State | Required | The row must say |
|---|---|---|
| `loading` | always | what holds the space (a skeleton sized like the result, or a spinner), `aria-busy`, how long before a timeout turns it into `error` |
| `empty` | always | the copy for "nothing yet", and the copy for "nothing matches" when there is a filter; the action that fixes it |
| `error` | always | the message per failure kind (5xx, network), the retry, the way out |
| `partial` | always | which part fails alone and what the rest still shows |
| `permission` | always | 403: the copy and the request-access path, with no retry. 401: where it goes, and the return path |
| `offline` | always | the indicator; whether a write is rejected with a message or queued and shown as queued |
| `stale` | always | what stays on screen during a refetch, and what says the data may be old when the refetch fails |
| `pending-submit` | every write | the disabled control, the progress text, what happens to the inputs on failure |
| `destructive-confirm` | every delete or irreversible action | the confirm dialog, or the undo toast and its window |
| `not-found` | every detail route | the heading, the way back, the HTTP status on SSR |
| `unsaved-changes` | every form a user can lose work in | the leave guard, or the autosave |
| `long-content` | every surface rendering user data | the longest value, how it truncates, and the 320 px layout |
| `validation` | every form | when an error first shows (blur, not keystroke), and where server field errors land |
| `timeout` | a request with a stated budget | the deadline and what replaces the loader |

The **required** set is the seven above, and the conditional set the seven after them: `THR-UI-STATES` in `templates/governance/thresholds.json` (installed at `.claude/templates/governance/thresholds.json`). The lint reads that entry when installed, else this default; a project's `.claude/thresholds.local.json` may add states, never drop a required one. A state outside this vocabulary is an `unknown-state` finding, so a typo cannot pass as an extra row.

## The format

```markdown
## State matrix

Surfaces:
- `orders-list` — src/features/orders/OrdersPage.tsx, src/features/orders/OrderGrid.tsx
- `refund-dialog` — src/features/refunds/RefundModal.tsx

| Surface | State | Source | Expected (message · action · focus) | Proof |
|---|---|---|---|---|
| orders-list | loading | AC-001 | 10 skeleton rows, aria-busy · — · — | src/features/orders/OrdersPage.stories.tsx#Loading |
| orders-list | error | AC-004 | "Couldn't load orders" · Retry · focus stays on the table | e2e/orders/states.spec.ts::error shows retry |
| orders-list | offline | n-a | n-a — back-office surface on the office network; the client has no offline mode | — |
| refund-dialog | pending-submit | AC-006 | "Refunding…" · button disabled · inputs kept on 500 | src/features/refunds/RefundModal.test.tsx::one refund per double click |
```

The block above is an excerpt. A real matrix carries a row for every required state of every surface; the lint fails the excerpt with eleven `state-missing` findings.

- **Surfaces:** one line per surface, `` `id` — path, path ``. A path may be a directory. Each touched file that looks like a surface must fall under one of these paths.
- **Source:** the REQ or AC id the row comes from. One row per `IF … THEN` clause in `requirements.md`; `n-a` rows say `n-a`.
- **Expected:** what the user sees, in the contract's three parts. An `n-a` row starts with `n-a — ` and gives a reason of at least three words that says why the state cannot occur. "Not needed" is not a reason. "One endpoint feeds the whole list, so it cannot half-fail" is.
- **Proof:** one or more of `path#Export` (a story export), `path::<test title>`, `path:<line>`, split by `;`. The path must be a test or story file. A bare path passes, with a `proof-unanchored` advisory.

## Design mode (P3)

1. **Read the inputs.** Read `requirements.md`: every story's criteria, and every `IF … THEN` clause. Read the surfaces the feature touches: the routes or screens in the ask, `design.md` when it exists, and `build-graph.py --who-breaks` output when the graph exists. Read the design system's state components first, and reuse them before inventing one. To find them, run `ui-state-scan`'s grep 0d: the shared wrappers, the empty, error and skeleton components, and the confirm provider.
2. **List the surfaces** with their paths, in the format above.
3. **Write the required rows for each surface.** Each state either occurs or is `n-a` with a reason. A state occurs when the source can produce it (`ui-state-scan` § The rule, point 2): a network read can fail and can be refused; a list can be empty; a write can be sent twice.
4. **Add the conditional rows the surface has:**
   - `pending-submit` for each write;
   - `destructive-confirm` for each delete;
   - `not-found` for a detail route;
   - `unsaved-changes` for a long form;
   - `long-content` where user data renders;
   - `validation` for a form.
5. **Write the interface contract in each Expected cell.** It has three parts: the message (real copy, in the product's language), the action, and focus. Use the copy the product already uses for the same state elsewhere.
6. **Name the proof** for each row. It is the state-matrix test that will force the state (`ui-state-scan` § Proof): an MSW handler per state, a story per state, or a Playwright `page.route`. On mobile, a fake repository or a fake `UiState` flow. The test does not exist yet; name the file and the title it will have.
7. **Lint it:**

   ```bash
   python3 .claude/skills/state-matrix/scripts/state-matrix-lint.py --run=ai/runs/<run-id> --phase=design
   ```

   Fix every finding.
8. **Return** `DONE <path>`. If an open question changes a row (is a 403 possible for this role?), return `NEEDS_INPUT` with the question and its options. Never invent an answer to it.

## Lint mode (gate G12)

```bash
python3 .claude/skills/state-matrix/scripts/state-matrix-lint.py --run=ai/runs/<run-id> --base=<merge-base> --json
python3 .claude/skills/state-matrix/scripts/state-matrix-lint.py ai/design/state-matrix/<feature>.md --touched=<paths>
python3 .claude/skills/state-matrix/scripts/state-matrix-lint.py --repo=. --json      # whole-repo audit: the STATES line
python3 .claude/skills/state-matrix/scripts/state-matrix-lint.py --self-test
```

**Whole-repo audit (`--repo` alone).** The lint pools every `## State matrix` under `ai/runs/*/` (`ux.md`, else `design.md`) and `ai/design/state-matrix/*.md`. No matrix at all while the repo has surface files is one `no-matrix` finding and exit 1: the audit's STATES line is a gap, never a usage error. Surface files that no matrix covers are one `surfaces-uncovered` advisory with the count. Measured 2026-10-15 on a real NestJS + React monorepo with no matrix: before this mode, `--repo` alone printed the usage text and exited 2, and an audit could not tell "no matrix" from "lint broken"; now it exits 1 with `no-matrix` over 478 surface files (187 views, 122 dialogs, 116 pages, 49 panels, 2 routes, 2 sheets) in 0.3 s. A touched `…Dialog.tsx`, `…Sheet`, `…Drawer`, `…Modal` or `…Panel` file is a surface too: a dialog is a surface by `ui-state-scan` § The rule 1, and before this a touched POS dialog got neither `no-matrix` nor `surface-unlisted`. A backend `list-page.ts` no longer counts (`-page` names a surface only in `.vue` and `.svelte`).

**Exit codes:** 0 means no finding (PASS, or N-A with its reason). 1 means at least one finding. 2 means a usage or read error. This is the convention of `test-integrity.py` and `oracle-check.py`. `gate-check.py` reads 0 and 1 as "ran" and anything else as UNVERIFIED.

**`--json` shape:** `{verdict, reason, matrix, surfaces_checked, findings[{file, line, kind, message}], advisories[]}`.

**Findings:**

| Kind | Meaning |
|---|---|
| `no-matrix` | a T2/T3 UI run, or a touched surface, with no `## State matrix` |
| `surface-unlisted` | a touched page, screen, view, route, dialog, sheet, drawer, modal or panel file that no surface's paths cover |
| `state-missing` | a surface with no row for a required state |
| `na-without-reason` | an `n-a` row with no reason, or a placeholder for one |
| `no-proof` | a row that is not `n-a` and names no proof |
| `proof-missing` | a cited file that does not exist, or a `#Export`, `::title` or `:line` that is not in it (skipped in `--phase=design`) |
| `proof-not-a-test` | a proof that cites the component instead of its test or story |
| `unknown-state` | a state outside the vocabulary |
| `unknown-surface` | a row for an undeclared surface |
| `duplicate-row` | the same surface and state twice |
| `source-unknown` | a Source id that `requirements.md` does not define |

**Advisories** are printed but never fail: `clause-unmapped` (an `IF … THEN` criterion that no row cites) and `proof-unanchored` (a proof that names a file only).

**N-A:**

- the run has no frontend or mobile lane;
- a T1 run with no matrix (P3 is skipped at T1);
- no run, no matrix and no touched surface;
- `--repo` alone, when the repo has no matrix and no surface file.

A copy-only change touches catalogs, not surfaces, so it reads N-A.

**What the lint cannot see.** It proves that a cited test exists, not that the test forces the state or fails first. That half of G12 is `@ux-reviewer`'s: it opens each cited test and checks the handler and the assertion against the row. The state-matrix tests themselves are run at P7 like every other test.

**Gate wiring:** the check is `G12 state-matrix` in `.claude/gates.json`, in shadow mode like every new gate; `gate-check.py` runs the lint once per UI run in the change (`run_g12`), and a missing lint on a UI run is UNVERIFIED, never PASS. `@ux-reviewer` also runs it at P4 (`--phase=design`, on the UI slice) and at P8–P11 (on the diff), and reports the result in its review.

## Output

```
state-matrix — design · ai/runs/20261005-refunds-9c1e/ux.md
Surfaces: 2 (orders-list, refund-dialog) · rows 17 (12 proved · 5 n-a with a reason)
Lint (--phase=design): PASS — 2 surface(s), every required state written and proved or n-a with a reason
Advisories: 1 — AC-007 (IF the refund exceeds the order total THEN …) is cited by no row
Open: is a 403 possible for a support agent on /orders? (A) no — n-a with the role matrix as the reason · (B) yes — a permission row
```

## Halt conditions

- Do not write a row whose Expected is a placeholder ("handle error", "show empty state"). A row names the message, the action and the focus.
- Do not mark a state `n-a` because it is unlikely. `n-a` means it cannot occur, and the reason says why. A network read can always fail.
- Do not cite the component as the proof of its own state. Cite the test or the story that forces the state.
- Do not edit `requirements.md` to make a Source id resolve. Return `NEEDS_INPUT` to its writer.
- Do not write into a file another role owns during an open run. Write into the file your caller owns.
- In lint mode, do not report PASS from reading. The script's exit code is the result.

## Related

- `ui-state-scan` (this pack) finds the surfaces that cannot render a state; its § Proof is the test shape every row names.
- `design-canvas` (this pack) draws the empty, loading and error artboards for the same surfaces before code.
- `@ux-reviewer` reviews the matrix at P4 and the surfaces at P8–P11, and signs G12.
- `/add-feature` (delivery pack) P3 and [`run-ledger.md`](../../../../snippets/run-ledger.md) define who writes `ux.md` and `design.md`.

Files in this skill

  • SKILL.md13.5 KB
  • scripts/state-matrix-lint.py27.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.