Skip to content

Latest commit

 

History

32 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Component State Matrix

Compare a component-state contract with an exported story inventory and name the states nobody covered — with an exact reference for every gap.

  • Repository: edilec/component-state-matrix
  • Area: Design Systems
  • License: MIT
  • Dependencies: none, at runtime or in development. Node 22+, node:test, node:assert/strict.

What it does

Two JSON documents go in.

The contract says which states each component must have a story for — default, loading, error, empty, disabled, focus — and which variants it must show.

The export is an inventory somebody produced from their story tooling: one record per story, saying which component it belongs to, which state it shows, whether it is skipped, and whether a visual snapshot was recorded for it.

What comes out is the gap between them: which component, which state, which stories exist for that component today, and which document to fix.

Why it exists

A state matrix is the thing everyone agrees on in a design review and nobody checks afterwards. The error state gets a story, the empty state does not, and six months later the empty state ships with the copy from the mock. Meanwhile the one story that did cover loading got skipped during a flaky week and stayed that way — so the matrix is green in a spreadsheet and red in the product.

Both of those are visible in an export somebody already produces. This tool reads it.

What it does not do

It measures nothing. It starts no story runner, opens no browser, renders nothing, takes no screenshot, loads no snapshot image, opens no socket and resolves no host. A story id is an opaque name, not an address. The export is the only evidence there is, and every sentence in the report is a sentence about the two documents it was given.

It writes nothing. There is no --out, it creates no directory and it modifies no file, so no output-destination guard applies to it.

It does not judge what a state looks like. This is the line the tool exists not to cross, so it is worth being exact about:

A covered state means a story exists for it and the export does not mark that story skipped. That is all it means. Nothing here has rendered the state or compared it to anything, so the report never calls a state verified, correct or approved.

Where a contract needs more than existence, it lists the state under visualEvidence. Then a story that exists and runs is deliberately not enough: the export must also record that a snapshot was taken. Even then the claim stays narrow — the tool knows what the export claims was recorded, and it has not looked at the image.

Quick start

node bin/component-state-matrix.mjs --root examples/covered --now 2026-09-15T09:00:00Z
# exit 0 -- every required state has a running story

node bin/component-state-matrix.mjs --root examples/gaps --now 2026-09-15T09:00:00Z
# exit 1 -- five gaps, each naming the component, the state and the stories that do exist

A gap from the second run, as it reaches stdout:

{
  "ruleId": "state-missing",
  "severity": "error",
  "message": "The component \"DataTable\" is required to have a story for the \"error\" state, and stories.json records none.",
  "location": { "file": "component-states.json", "pointer": "/components/1/requiredStates/2" },
  "evidence": "stories recorded for DataTable: data-table--default, data-table--empty, data-table--loading",
  "suggestion": "Write a story that shows \"DataTable\" in its error state, and export the inventory again."
}

The two documents

A state contract:

{
  "schemaVersion": "1",
  "requiredStates": ["default", "disabled", "focus"],
  "components": [
    {
      "id": "Button",
      "requiredStates": ["default", "disabled", "focus", "loading"],
      "variants": ["primary", "secondary"],
      "visualEvidence": ["loading"]
    },
    { "id": "TextField" }
  ]
}

A component with no requiredStates of its own inherits the top-level list, and its findings are anchored at the component rather than at a /requiredStates element the document does not have.

A story export:

{
  "schemaVersion": "1",
  "generated": "2026-09-14T09:00:00Z",
  "stories": [
    { "id": "button--loading", "component": "Button", "state": "loading", "variant": "primary", "visualSnapshot": true },
    { "id": "button--focus", "component": "Button", "state": "focus", "skipped": true }
  ]
}

state is required on every story. A story whose state the export does not record satisfies nothing, and no default is guessed: inventing one is how an unknown becomes a pass. skipped and visualSnapshot default to false when absent, but a value that is neither true nor false is refused rather than reinterpreted.

generated is required too. An export with no instant cannot be told apart from one written last month, and every coverage verdict is only as current as the document it came from.

Rules

The full table, with a sentence on when each rule fires, is in docs/state-rules.md. In outline:

Rule Severity What it means
state-missing error A required state has no story at all.
state-only-skipped error Every story covering it is skipped, so it is exercised by nothing.
variant-missing error A required variant has no story, or only skipped ones.
visual-evidence-missing error The contract asked for a recorded snapshot; a running story exists and no snapshot does.
component-without-stories error The export records no story for a governed component at all.
story-component-unknown warning A story covers a component the contract does not govern.
story-state-unlisted info A story covers a state the contract does not require.
document rules error Either document is malformed, contradictory, stale or governs nothing.
evidence rules error, incomplete A document could not be read, decoded, parsed, or was cut short by a limit, or a record in it was refused.

Severity lives in one frozen ruleId -> severity table; a finding takes its value from there and an unknown rule id throws. That table is asserted against the documented catalog in both directions — and, because three agreeing declarations are satisfied by one coordinated edit, every rule is also pinned behaviourally by driving real documents through the real CLI and asserting the process exit code — and, for the rules that also mark the run incomplete, where exit 2 comes from the flag rather than from the severity, by asserting the error and warning counters the report carries.

Exit codes

Code Meaning
0 both documents were read and no error-severity rule fired
1 both documents were read and at least one error-severity rule fired
2 invalid configuration, or evidence that could not be obtained

Exit 2 has two shapes, and a consumer piping stdout must handle both:

Situation stdout status
Invalid configuration, unknown option, bad usage empty no report — the run never had a subject
A document that could not be read, decoded or parsed a report incomplete
A story record that was refused, so the export index is short of one a report incomplete

An incomplete run is never a pass, and an unread document is never reported as an absent one — and neither is an unread record of one.

Guarantees

Each of these is pinned by a test that fails when the behaviour is removed — not by a test that reads a declaration.

  • Unknown is never a pass. A document that could not be read, decoded or parsed, a limit reached mid-run, and a time budget that expired all produce incomplete and exit 2. A budget that expires before a state was examined leaves it neither covered nor missing.
  • A record dropped out of an index is not a record the document lacks — on both sides of the comparison. Every gap here is a claim of absence read off an index, so an index a record was dropped out of cannot support one. A refused story record makes the run export-partly-unread, incomplete and exit 2, and nothing is reported as covered or missing: the unreadable field may be the component, the state or the variant, so no record can be attributed and no absence established. A refused component entry is reported and fails the run, and story-component-unknown — the claim that the contract does not govern something — is withheld for a component the contract named and then refused, and withheld entirely once an entry was refused before its id could be read.
  • No vacuous pass. A contract that governs no components, a component that requires no states, and an export with no stories each fail rather than reporting green on no evidence.
  • Existence is not verification. A running story does not satisfy a state the contract listed under visualEvidence, and a snapshot recorded against a skipped story does not either.
  • Ordering is by UTF-16 code unit. Never localeCompare or Intl.Collator, whose ICU data differs between Node builds. Pinned with inputs whose order genuinely differs between the two (Z.json before a.json; Z-story, a-b, a-story, a_b).
  • The clock is injected. --now, or readClock as a parameter. The instant actually used is recorded as summary.evaluatedAt, and summary.clockInjected says whether it was pinned.
  • Every untrusted string is sanitised — component ids, story ids, state names, pointer segments, field names and excerpts alike. C0, DEL, C1, U+2028/U+2029 and the bidi controls are all removed, and a name that would render as nothing is refused rather than accepted and rendered blank.
  • A value that cannot be stringified does not cost the report. {"toString": {}} in any field, including one read before any schema check, still produces a report on stdout.
  • A parse failure never reproduces the document. V8 quotes the offending input back; the helper recognises the quoting shape before looking for an offset, and ends with a backstop that refuses any message still carrying a double quote.
  • Path confinement is resolved, not lexical. A symbolic link inside the root that points outside it is refused; a root reached through a link still works.
  • Determinism. The report is a pure function of the two documents and one evaluation instant. Two runs over the same documents at the same instant produce byte-identical stdout, and the instant is an input: pin it with --now and two machines agree on every byte. Without --now the host clock is read exactly once, at the start of the run, and the value it returned is written into summary.evaluatedAt — so two default runs a second apart differ in that field, and in the age it implies, and in nothing else. That is the whole of the clock's reach, and it is stated here rather than in the CLI help alone, because a flat "byte-identical" was false for the default configuration.

Limits

Limit Default Reaching it
--max-document-bytes 1048576 incomplete
--max-components 500 incomplete
--max-stories 20000 incomplete
--max-names-per-component 50 incomplete
--max-findings 1000 incomplete, report partial
--max-runtime-ms 10000 incomplete
--max-age-hours 72 fail — a judgement about a document that was read

Every limit is enforced and tested. An unknown limit or policy key throws rather than being ignored, and a flag that carries a value may be given once: a repeated flag is a configuration error, not a silent last-wins.

The time budget is checked between components, so it is not a hard deadline — a run overshoots by the cost of the component in hand.

Non-goals

  • It does not render, screenshot, diff or measure anything. No browser, no story runner, no image. See What it does not do above.
  • It does not decide whether a state is implemented correctly, only whether the export records a story for it.
  • It does not read your story files. It reads an export of them. If the export is wrong or stale, the report is wrong or stale, which is why generated is required and staleness fails.
  • It does not produce the export. Writing that exporter is a job for whatever tooling already knows how to enumerate stories; the shape it must produce is documented above.
  • It does not write, move or delete files, and it has no auto-fix.
  • It does not know about states outside the six it names. A contract requiring a seventh is refused, because a state nobody agrees on cannot be matched against an export somebody else wrote.

Verification

npm run check        # lint + test + both examples + npm pack --dry-run

License

MIT. See LICENSE.

About

Track required component states across design, stories and implementation.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages