Skip to content
commission

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

StateWhere it is definedNotes
defaultthe component
hoverapp.css, per componentnever the only signal for anything
focus:focus-visible in the base layer, onceone ring for the whole surface, drawn with two shadows so it follows a rounded corner
active.btn:active, .tab:activea half-pixel press and a brightness step
selected[aria-selected], [aria-pressed], .onthe attribute is the state; the class follows it
disabled:disablednot right now
loadingaria-busy + .is-loading, or Skeletonsee below
errorRefusalPanelon this surface an error is usually a refusal
read-onlyaria-readonly, .is-readonly, :read-onlythe control is real; this session cannot use it
permission-denied.is-denied + Deniednot 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 work observers 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:

kindwhat it ishow it reads
refusalthe API declined something that is not yet trueRefused, with what is missing and the command
deniedthis identity may notNot yours to do — the one kind nobody can fix alone
offlinenothing answeredThe server stopped answering
serverthe server answered, and answering is what failedThe server could not answer — SQLite unavailable arrives here
corruptsomething answered, and it was not this APIThat 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

ComponentHasDoes not have, and why
Buttonall ten
IconButtonall teninherits Button’s rules; label is required so its accessible name cannot be forgotten
Input / Selectdefault, hover, focus, disabled, read-only, errorno denied state of its own — the button beside it carries it, and two explanations of one refusal is one too many
SearchFielddefault, focus, loadingclear button appears only when there is something to clear
FilterBardefault, selectedstates what it is narrowing to, so a filtered list never looks unfiltered
Tabsdefault, hover, focus, active, selected
Dialogdefault, loadingfocus is trapped inside it while open; escape closes it
Drawerdefaultmobile only
Tooltiphover, focusanswers focus as well as hover, so it is not invisible on a keyboard
Toastdefaulta refusal never dismisses itself; a confirmation times out
StatusChip / Badgedefaulta fact, not a control
Avatardefaultshape carries human vs agent, not colour alone
ProgressBardefaultrenders nothing at all when total is zero — no measurable progress is not zero progress
EmptyStatetwo kindssealed for an empty queue, which is the product working; quiet for a failed search, which is not
Skeletonit is the loading state
SkeletonBlockthe same, for a whole section: a heading and the real card, empty
Busyloadinga region being refreshed, with what it already had still on it

domain.tsx — §32.3

ComponentHasNotes
JudgmentItemdefault, hover, loading, denied, leavingleaving collapses rather than vanishing, so the act is visible
GateRequirementRowmet, unmetan unmet one carries the command that makes it true; a met one carries what satisfied it
RefusalPanelit is the error state; heading names which of §34.3’s seven
Disconnectedthe server stopped answering, said once for the whole page
LeaseTimerheld, expiring, expiredexpiring is announced with aria-live, because a lease running out while somebody reads the page is a change they should be told about
SyncBadgein sync, delayed, disagreeing, no credentials, credentials expiredan expired token and a network failure need different things from a person and are never collapsed into “disconnected”
SyncStatusPanelthe same fivecarries 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
ConflictPaneldefault, deniedevery option states its effect before it is taken; recommended is a hint and never a default
FieldOwnershipread-onlywhich facts another system owns, and therefore which this surface does not write
DependencyNodedefault, focus, blocking, truncatedtruncated 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.