The ten states
Spec §32.4. Every component in the dashboard has ten possible states, and this records which of them each component actually has, how it expresses each one, and — for the ones it does not have — why not.
The reason this document exists rather than being left to the components: eight of the ten are obvious and get built by reflex. Read-only and permission-denied are the two that get skipped, and they are the two that matter most on a surface an agent also serves. A viewer-role human and an agent session both need to see why a control is unavailable rather than find a dead button or, worse, no button at all.
Where each state lives
| State | Where it is defined | Notes |
|---|---|---|
| default | the component | — |
| hover | app.css, per component | never the only signal for anything |
| focus | :focus-visible in the base layer, once | one ring for the whole surface, drawn with two shadows so it follows a rounded corner |
| active | .btn:active, .tab:active | a half-pixel press and a brightness step |
| selected | [aria-selected], [aria-pressed], .on | the attribute is the state; the class follows it |
| disabled | :disabled | not right now |
| loading | aria-busy + .is-loading, or Skeleton | see below |
| error | RefusalPanel | on this surface an error is usually a refusal |
| read-only | aria-readonly, .is-readonly, :read-only | the control is real; this session cannot use it |
| permission-denied | .is-denied + Denied | not by you — see below |
The three that are not just CSS
permission-denied
A denied control is rendered, inert, and carries the reason. It is never removed.
Hiding is the tempting answer and the wrong one. Somebody who may not approve needs to know that approving exists here and that their role is what is in the way. A missing button makes the product look broken rather than restricted, and leaves them nothing to act on.
The sentence comes from capabilities() in core, which composes it with the
same code requireMay refuses with — so the dashboard and the CLI cannot drift
into explaining one refusal two different ways:
dana is an observer and may not approve or verify workobservers read and raise. commission need <id> -m "..." puts a task in front of the queue with your name on it — that is the lever you do have.
The hint is the important half. A permission refusal is the one kind a person cannot fix themselves, so it has to say who can, or what they can do instead.
useCan returns null while the answer is still loading, which is not the
same as denied. Treating unknown as denied would flash every control into its
denied state on first paint and teach somebody they have no permissions.
That “still loading” used to be able to last forever: useMe memoised the
PROMISE, so one failed /me at startup pinned every control on every screen
into its unknown state for the life of the tab, with no retry. Only a success
is cached.
An agent session says so. §34.5’s fifth state, and the surface could not
express it at all — core has always returned kind and the client type simply
did not declare it. On the surface whose whole premise is that agents operate
it, an agent session looked identical to a human one, right up to the moment it
pressed Approve. The rail now reads agent · contributor, and carries core’s
sentence above it — including the half that matters, which is the name of a
human who can do what this session cannot.
Cannot view a project is one state and not two. “There is no such project” and “there is one but not for you” are never distinguished, because a surface that told them apart would be an oracle for what other tenants have. One heading — Not here, or not yours to see — core’s sentence, and core’s hint, which is how to find what you can see.
error
§34.3 names seven conditions, and the first thing to get right is that they are
not one state. data.ts decides which, once, from the shape of what came back —
never from the words in it — and everything downstream branches on that kind:
| kind | what it is | how it reads |
|---|---|---|
refusal | the API declined something that is not yet true | Refused, with what is missing and the command |
denied | this identity may not | Not yours to do — the one kind nobody can fix alone |
offline | nothing answered | The server stopped answering |
server | the server answered, and answering is what failed | The server could not answer — SQLite unavailable arrives here |
corrupt | something answered, and it was not this API | That was not Commission answering |
Three rules, each of which was broken:
The refusal is the API’s own sentence. The surface built its message out of
body.error — which is the CODE — so every refusal in the dashboard began with
the literal word “refusal” and the sentence core had composed was dropped. An
evidence refusal naming the placeholder somebody typed arrived as “refusal —
attach the real reference”. The whole thing is now carried the way the terminal
prints one: the sentence, then each missing: line, then the hint.
A failed read does not blank a page that has data on it. A poll six seconds
after somebody opened the queue found the socket shut, and the whole rendered
page became a panel reading “Bad Gateway”. Everything on it was still true;
only our ability to confirm it had stopped. The failure now goes to one banner
in the shell — Disconnected, which says when the page was last current —
and useQuery’s error is reserved for a read with nothing to show instead.
No error state loses work somebody had typed. Three places did: the queue
row reset its note field on the line after the act, before knowing whether it
went through; the confirmation dialog held its reason in its own state and was
unmounted on the click; and a refused forced closure therefore lost the
justification, which is the one act the audit trail exists for. An act handler
now returns whether it went through, and nothing is cleared or closed on a
false.
read-only
Same treatment, different cause: the identity may act in general, but this session or this object cannot be written. The control stays visible and inert.
There are two causes, and both were invisible until something was pressed.
The server was started read-only. COMMISSION_TOKEN is the documented way
to expose the dashboard without exposing its writes: every mutation without the
bearer header is refused with a 401, and a browser has no way to send one. So
the whole surface is read-only — and it rendered a live Approve button anyway,
refused after the click, and said only “mutations require Authorization:
Bearer”. That is exactly what §32.4 names as the failure. /me now carries a
session block composed by the server (which is the only thing that knows), and
useCan answers with it for every action but read — so a screen that already
asks whether this identity may judge gets the second reason for free. The
controls that ask nobody’s permission — marking news as seen, settling a sync
disagreement — take useReadOnly directly, because on a read-only server they
were the only live buttons left on a page where everything else had correctly
gone inert, and a page with one working button is worse than one with none.
Another system owns the field. Under jira-planning, Jira owns the title,
description, priority, labels and assignee: Commission does not write them, and
an edit here would be overwritten at the next reconciliation. Nobody has been
refused anything, which is why it wears the read-only treatment rather than the
denied one — FieldOwnership renders each with aria-readonly and states the
division rather than only the restriction.
loading
Four forms, and none of them is a spinner. §34.2 names six places a load happens — initial app load, project switch, route transition, sync, graph expansion, event pagination — and they divide into two questions: is there anything on screen yet, and is it the whole page or one region?
A page loads as a skeleton shaped like what is coming — a statement, its
figures, and the first rows beneath (Loading). §32.4’s rule is that loading
never collapses the layout it will fill: when the data lands it fills in place,
and nothing somebody was already reaching for moves.
A section arriving on its own reserves a section (SkeletonBlock): a
heading, then the real card with the real rows in it, empty. Four grey lines
where a titled block of six rows is coming is still a collapse — only a smaller
one — so the reserved space is drawn out of the same classes the arriving
content is.
The shell holds its own shape. The rail was the one place this was broken:
with no projects yet, the System heading and the account row sat halfway up and
were shoved down when the estate landed — under the pointer of anybody who had
gone for Audit. It now reserves as many project rows as it had on the last
visit, remembered in commission-rail-rows, which makes the reservation exact
rather than a guess on every visit after the first.
A region being refreshed keeps what it already had (Busy). A filter
change, a poll, one more hop of a dependency walk, another page of the log —
all of these happen over content somebody is reading, and the previous answer
is a good enough answer to keep looking at while the next one arrives. The
region dims, goes inert and carries aria-busy; the control that CAUSED the
refresh stays outside it, because the one thing that must keep working while a
filter is applied is the filter. Nothing moves: a pulse here would be the second
looping thing on screen.
A busy control goes inert and keeps its label. It does not swap its text for ”…”, because the word on a button is what tells somebody which button it was.
There is no spinner anywhere, and that is a deliberate consequence of §11.6: exactly one thing in the product loops, the lease somebody is holding right now. That mark means this is happening while you look, and it only means it while it is the only loop on screen. A spinner would be a second one and would cost the first its meaning.
Per component
kit.tsx — §32.2
| Component | Has | Does not have, and why |
|---|---|---|
| Button | all ten | — |
| IconButton | all ten | inherits Button’s rules; label is required so its accessible name cannot be forgotten |
| Input / Select | default, hover, focus, disabled, read-only, error | no denied state of its own — the button beside it carries it, and two explanations of one refusal is one too many |
| SearchField | default, focus, loading | clear button appears only when there is something to clear |
| FilterBar | default, selected | states what it is narrowing to, so a filtered list never looks unfiltered |
| Tabs | default, hover, focus, active, selected | — |
| Dialog | default, loading | focus is trapped inside it while open; escape closes it |
| Drawer | default | mobile only |
| Tooltip | hover, focus | answers focus as well as hover, so it is not invisible on a keyboard |
| Toast | default | a refusal never dismisses itself; a confirmation times out |
| StatusChip / Badge | default | a fact, not a control |
| Avatar | default | shape carries human vs agent, not colour alone |
| ProgressBar | default | renders nothing at all when total is zero — no measurable progress is not zero progress |
| EmptyState | two kinds | sealed for an empty queue, which is the product working; quiet for a failed search, which is not |
| Skeleton | — | it is the loading state |
| SkeletonBlock | — | the same, for a whole section: a heading and the real card, empty |
| Busy | loading | a region being refreshed, with what it already had still on it |
domain.tsx — §32.3
| Component | Has | Notes |
|---|---|---|
| JudgmentItem | default, hover, loading, denied, leaving | leaving collapses rather than vanishing, so the act is visible |
| GateRequirementRow | met, unmet | an unmet one carries the command that makes it true; a met one carries what satisfied it |
| RefusalPanel | — | it is the error state; heading names which of §34.3’s seven |
| Disconnected | — | the server stopped answering, said once for the whole page |
| LeaseTimer | held, expiring, expired | expiring is announced with aria-live, because a lease running out while somebody reads the page is a change they should be told about |
| SyncBadge | in sync, delayed, disagreeing, no credentials, credentials expired | an expired token and a network failure need different things from a person and are never collapsed into “disconnected” |
| SyncStatusPanel | the same five | carries core’s fix as well as its says — §34.3 wants what happened and what to do, and replacing a credential is not the same job as running one command |
| ConflictPanel | default, denied | every option states its effect before it is taken; recommended is a hint and never a default |
| FieldOwnership | read-only | which facts another system owns, and therefore which this surface does not write |
| DependencyNode | default, focus, blocking, truncated | truncated is rendered rather than hidden — a graph that silently stops is read as complete |
What is asserted
tests/component-states.test.ts holds the ones that would rot silently:
- a denied control is present, inert, and carries its reason
- the reason is core’s sentence, not the view’s
- unknown is not rendered as denied
- the loading state reserves height rather than collapsing
- a section’s loading state reserves a section rather than a paragraph
- the rail holds its projects region open while the estate is arriving
- a refresh keeps what is on screen rather than replacing it
- a refusal reaches the surface as the API’s own sentence, not as its code
- an invalid evidence reference names the value and the task
- a dead socket, a failing server and a corrupt reply are three states
- a refused act keeps the reason somebody typed, and its confirmation open
- a read-only server says so before the click, and denies rather than removes
- an agent-served view says plainly that it is one, and who can act instead
- a field another system owns is read-only, not denied
- nothing loops except the held lease
This page is docs/design/component-states.md in the Commission
repository, rendered in place — the site keeps no copy of it. The repository is private, so there is no edit link to follow.