JsonUI
Flow diagramDrawn from the specs, checked against the flow testsSince jsonui-cli 1.8.66 the screen-transition diagram is drawn from one source — what each screen spec declares under `transitions[].destination` — and the flow tests are checked against it rather than drawn from. A forward transition a flow test performs that no spec declares is an error: `jsonui-doc generate html` still writes every page, prints one `ERROR [doc-diagram]:` line per transition, and exits 1. This page covers the rule and why it replaced the old one, the closed vocabulary a destination can belong to, how prose becomes a screen id, the two places a face declares what a spec cannot, what the check accepts, what is listed without being an error, how to read the page, and what the mechanism does not do.9 min read
1. One source, and a checkBefore 1.8.66 the diagram was drawn from the flow tests, so a face with no flow tests got a page saying `No flow tests found` however many specs it held. The canon had declared two sources — the flow tests' screen values and the specs' `transitions[].destination` — but the generator read only the first, and nothing failed, because a declaration with no reader is a sentence rather than a rule. Measured on the way to this release: the number of places in the document generator that read spec transitions was zero.Since 1.8.66 there is one source. The diagram is drawn from the specs — every screen spec's `transitions[].destination`, plus `test.appOwnedScreens[].transitions` for a screen that has no spec — and the flow tests are the check. They are still walked (resolve, canonicalize, collapse consecutive duplicates, build edges), but the result is compared with what the specs declare rather than drawn. A face with no flow tests gets a diagram anyway; a face whose flow tests wander outside its specs gets errors that name the wandering.The check is an error and not a warning because of the direction of the mistake. A flow test that reaches a screen no spec declares is either a test of behaviour the product does not have, or a spec that is silent about a transition the product does have. Both are wrong specs in a spec-first project, and a diagram that quietly drew the test's version would have been the tool taking the test's side.
2. What a destination can beA spec's `transitions[].destination` is free prose — the validator has only ever required that the key exist — so one column holds several kinds of value. Rather than treat everything that is not a screen id as a defect, the classifier assigns each destination one of a closed set of kinds, and each kind draws differently.• `screen` — resolves to a screen id in this face's id space. Draws a node and an edge.• `route` — a URL path owned by a web router, such as `/admin/login`. Draws nothing until the route is mapped to a screen; until then it is listed with the unknowns and treated as absent.• `external` — leaves the app entirely: an `https://`, `tel:` or `mailto:` value, `External Browser`, `Phone app`, a maps app, a mailer. Draws a terminal node, never a screen.• `none` — declares that no screen transition happens: an in-page tab switch, a state change, staying on the same screen. Draws nothing. This is a positive declaration, not a missing value.• `back` — returns through the navigation stack: `back`, `dismiss`, `pop`, `previous screen`. Draws a dotted return edge to every screen that has a forward edge into the declaring screen, derived from the specs.• `unknown` — none of the above matched. Draws nothing, and is counted and listed per face with the raw value and the reason.The vocabulary exists so that a destination which is not a screen id stops being reported as a defect. Declaring only `screen` would have turned 119 of the 271 destinations measured across four faces into spec errors when they were other kinds of transition — a reporting gap becoming a rejection of correct input, which is the worse direction. The block below classifies as `screen`, `external`, `none`, `back` and `route`, in that order, checked against the shipped 1.8.66 classifier rather than written from the description.
ItemListScreen.spec.json — five kinds in one column: screen, external, none, back, route
"transitions": [
{ "condition": "tap a row", "destination": "ItemDetail" },
{ "condition": "tap 'Terms of use'", "destination": "External Browser" },
{ "condition": "switch the filter tab", "destination": "遷移なし(タブ切替)" },
{ "condition": "tap 'Close'", "destination": "dismiss" },
{ "condition": "tap 'Help'", "destination": "/help" }
]
3. How prose becomes a screen idResolution is a pipeline applied in order, and every step lives in one place rather than at a call site — a rule that lives at one call site is absent from the other two. First, compare case-insensitively with whitespace, `_` and `-` removed. Then strip a trailing parenthetical, full-width or half-width, counting depth rather than matching a pattern: a flat pattern consumes up to the inner close of `Name (a=b / onNavigate(Screen.X))`, leaves the outer one, and produces a candidate that resolves to nothing — measured on five nested destinations, three of which were unresolvable for that reason alone. Then split on ` or `, `/` and `、` and try each part. Then apply the face's declared aliases.A destination whose split parts resolve to more than one screen — `ItemDetail or Cart(depends on source)` — declares a transition to each: the classifier returns the first match, and the diagram asks for the parts and classifies every one. A parent spec carries no transitions of its own; its sub specs do, and the diagram merges them under the parent's screen id. Reading the parent alone produced false errors for dozens of flow tests on the face where this was measured.For the check, a flow test runs once per context. A step gated by `when.platform` or `when.responsive` — any `when` key with a string or list-of-strings value — runs only in its own, so two sibling steps gated ios and android are never consecutive. The sequence is built once per combination of the `when` values the flow uses and the edges are the union. Measured before this was fixed: three invented transitions on one face.Two raw ids that normalize alike are one screen to the classifier, which only ever sees the id it was handed: the key drops whitespace, `_` and `-` and lower-cases, so a layout's `forgot_password` and a spec file's `forgotpassword` collapse onto one node. The winner is decided by provenance rather than by iteration order — the layout's id first, then an app-owned declaration, then a spec's file name — and the losers are redirected onto it, so the same input no longer draws a different number of nodes from one run to the next. The pair is named rather than folded silently: `WARNING [doc-diagram]: ids 'forgot_password' (layout) and 'forgotpassword' (spec) normalize to the same key; drawn as 'forgot_password'`. The site run prefixes the owner and adds the advice to rename; the standalone `generate mermaid` prints the shorter form. Neither fails the run.
4. Two things a spec cannot sayTwo declaration points live in `jui.config.json`, because the spec is the wrong place for both. `spec.transitionAliases` declares an affix the face's prose habitually attaches to a screen name — a `prefix` or a `suffix` — so that `WebLogin`, or `Web Login`, resolves to `login`, and `CartView` to `cart`. Each entry is `{ position, affix }`, and a `position` outside those two is refused: the run fails naming the entry. It is never skipped, because an alias that silently does not fire looks exactly like a face that declared nothing.`test.appOwnedScreens[].transitions` declares transitions for a screen that has no spec — an app-owned screen, with no layout and no spec to write them in. Without it, every flow test that leaves such a screen would be an error with nowhere to put the fix. An entry may still be a bare id; the object form is `{ "id", "group", "transitions" }`, and each transition is the same `{ condition, destination }` a spec carries, classified by the same classifier.Aliases are per face, and the reason is worth stating precisely because the counts could not decide it. Applying an affix to all four measured faces unconditionally changed not one count — the other faces resolved nothing either way, because they had no id that matched after the strip, so the corpus cannot tell a per-face declaration from a global rule. What can be measured is how close a global rule is to going wrong: an id that begins with the affix already exists in the corpus, and stripping it yields a name that is nobody's screen today. The distance is one id. Per face because of that, and not because the numbers said so.
jui.config.json — the two declaration points; a bare id and the object form side by side
{
"spec": {
"transitionAliases": [
{ "position": "prefix", "affix": "Web" },
{ "position": "suffix", "affix": "画面" },
{ "position": "suffix", "affix": "View" }
]
},
"test": {
"appOwnedScreens": [
"splash",
{ "id": "account_chooser", "group": "entry",
"transitions": [
{ "condition": "an account is picked", "destination": "Home" },
{ "condition": "tap 'Cancel'", "destination": "back" }
] }
]
}
}
5. What the check acceptsEvery forward transition a flow test performs — consecutive distinct screens after resolve, canonicalize and collapse — must be an edge the specs declare. Two shapes qualify: a forward edge, or the return edge derived from a `back` declaration. The second is what makes sheets work: a flow that leaves a sheet by tapping save and lands on its opener performs that return as a forward step, and the sheet's spec declared `dismiss`, not the opener's name. The return edge derived from `dismiss` — to every screen with a forward edge into the sheet — is the edge that step is checked against.Back steps, action `back`, are exempt. A return through the stack is the inverse of a transition the check already saw, and a spec's `back` declaration has no target to compare with.Each error names the owner, the flow test's name and file, the from screen, the to screen, and one of three reasons: the from screen has no spec; its spec declares no transition to that screen; or the app-owned screen declares none in `test.appOwnedScreens[].transitions`. When the from screen also has destinations the classifier could not resolve, the line lists them, because that is usually the fix. A count without the list is not a report. `generate html` writes every page first — the diagram page carries the same list — then prints one `ERROR [doc-diagram]:` line per transition and exits 1. `--allow-partial` does not cover it: that flag accepts a missing page, and this is a wrong spec. `generate mermaid` exits 1 the same way.
generate html — the line shape, with illustrative values: two of the three reasons, the first naming the likely fix
ERROR [doc-diagram]: MyApp: flow test "checkout" (flows/checkout.test.json) transitions item_detail -> cart: item_detail's spec declares no transition to cart; 1 of its destination(s) could not be resolved and count as absent: 'Basket'
ERROR [doc-diagram]: MyApp: flow test "first_run" (flows/first_run.test.json) transitions splash -> home: the app-owned screen splash declares no transition to home (jui.config.json test.appOwnedScreens[].transitions)
6. Listed, not failedA destination the classifier cannot resolve — kind `unknown`, or a `route` with no mapping — is treated as absent. It is not drawn, it is not an error by itself, and it is listed per face with the raw value and the reason the classifier gave. It becomes an error only when a flow test performs the transition the unresolved destination was meant to declare, and then the error names the unresolved destinations on that source.A `none` inferred from wording is counted on the closing line as `none inferred from wording N` and listed on the diagram page under INFO, separately from the unknowns, so a positive declaration of no transition can be told from a destination the classifier gave up on. A flow group with no spec directory anywhere gets one `WARNING [doc-diagram]` naming how many flow test files went unchecked, and no diagram: absence of the directory is a configuration state, not a spec omission.The unresolved count is reported per face, never only as a total, together with the scanned total it came from. Measured unresolved rates on four faces were 28%, 61%, 50% and 100%. Summed, the face at 100% disappears into the average — and a face that resolves nothing looks exactly like a face with nothing to resolve. A bare zero, or a bare average, cannot tell an instrument that found nothing from one that never reached the corpus.
7. Reading the pageThe diagram page opens on an All tab that draws every edge, followed by one tab per group. A group can be declared in three places and they are read in one order: a screen test covering the node wins, the screen spec's own `metadata.group` fills in, and an app-owned screen's `jui.config.json` entry comes last. The spec field takes a string or a list, and a list puts the screen in every tab it names. The All tab exists because a group tab draws only the edges inside its group: before it, an edge between two groups appeared on no tab and in no list, and one face's closing line counted 29 resolved edges while the page drew 19. Its subgraph ids are namespaced `sg_`, so a group whose name matches a screen id no longer collides with that screen's node.Nodes with no group land in a catch-all bucket that keeps reading as undeclared. Until this release a screen with a spec and a layout but no screen test had nowhere to declare a group at all, so it stayed in that bucket permanently and hid the ones that simply had not been declared yet. The spec's own field is what gives such a screen somewhere to say it.The closing line of `generate html` ends with `warnings N`, printed even when N is 0, and the number is the tool's own tally — every warning goes through one function — rather than something a reader greps out of the log. Two clauses can follow it, and neither moves N. `(M structural: kinds)` names the warnings that fire by design on every run of that shape, so the clean reading is `N − M == 0` rather than `N == 0`. `/ gate expression matches G (K printed data, not warnings)` appears when the run printed data — a test name, say — that the gate's own expression counts too: the tool saying in advance why a reader's grep will return more than N. Above it, one line per owner reports the diagram's denominators: specs read, click targets over the nodes the graph drew, document links declared and rebased, transitions found, spec edges drawn, `none` inferred from wording, and the flow tests checked with how many transitions were absent. Two of those name their own denominator on purpose. `click targets N of M node(s) the graph drew` says what M is counted from, so a denominator that shrinks cannot flatter the ratio. `document links N declared, M rebased` prints both halves, because a face that declares no documents and a face whose paths were already right for its layout both report a zero, and one number alone cannot tell those two apart.
generate html — the per-owner diagram line and the closing line; the first two lines are shapes with illustrative values, the last is from a run
diagram MyApp: specs 11 / click targets 9 of 12 node(s) the graph drew / document links 6 declared, 2 rebased / transitions 24 / spec edges 21 (all in the All tab) / none inferred from wording 1 / flow tests 14 in tests/flows (declared test root) checked 14 transition(s), absent 0
Generated 63 HTML files this run (screens 11 / flows 14 / unitContracts not read / warnings 1)
 
# and when the run printed data that the gate's expression also counts (a real line, jsonui-cli 1.8.67):
Generated 4 HTML files this run (screens 3 / flows 0 / unitContracts not read / warnings 3 / gate expression matches 5 (2 printed data, not warnings))
8. What this does not give youThe check compares screen identities, not what happened between them: a flow test that reaches the declared screen by the wrong route, or performs the right transition for the wrong reason, passes. Aliases cover the two positions observed so far, prefix and suffix; infix and partial matches are not covered, and saying which is which matters — a generalization that names its coverage makes the third example a counterexample that asks for a redesign, while one that says all makes the same example an exception pushed in sideways.The kind vocabulary is a fixed word list, and how uneven it is across languages moved with the release. In 1.8.71, the release this site pins, `none` is recognised from Japanese wording — `遷移なし`, `画面内`, `タブ切替`, `そのまま`, a leading `なし` — and from English too: `no transition`, `stays on`, `same page`, `in-page`, `tab switch`, `switches tabs` among them, plus a bare `none` when it is the whole value. The English half ignores case, so `none`, `None` and `NONE` all read as `none`. Before 1.8.71 that half did not exist: a destination reading `none` or `stays on this screen` classified as `unknown` and was listed as absent, which is still what an older pinned toolchain does. Every English alternative is multi-word or anchored to the whole value, and that is a rule rather than a coincidence — an unanchored bare word would match inside any prose and would silently swallow an `unknown` the report otherwise names. Measured with the vocabulary's own near misses: `Target screen or tab` carries `tab` and stays `unknown`, `Login none required` carries `none` and stays `unknown`, and `state change` and a bare `stay` match nothing. `external` did not move and matches `https://`, `tel:`, `mailto:`, `External Browser`, `Phone app`, `App Store`, the maps apps and their Japanese names, case-sensitively: `external browser` in lower case does not match. One consequence deserves its own sentence: the markers do not agree about case — `none` and `back` ignore it, `external` does not — so any single claim about markers and case is false for one of them. Measured against the pinned 1.8.71 tree on 2026-09-11, negative cases included. That day `main` stood one commit ahead of the tag and the difference was a test file, so a bootstrap install and this pin agreed — but such an install follows `main` rather than a tag, which makes that agreement one day's measurement and not a property.The diagram needs the classifier from `jui_cli`. In an environment where that package cannot be imported — a bare `pip` install of `document_tools` alone — `generate html` draws no diagram and prints one `WARNING [doc-diagram]`, and `generate mermaid` exits 1. A face that installed through the bootstrap script has the package beside it and is unaffected.Everything on this page is stated from jsonui-cli 1.8.66, and this site does not run `generate html` or `generate mermaid` itself: the exit-1 behaviour is stated from the canon and the upstream acceptance, the classifier results from the shipped tree. A diagram from before this release was drawn from the flow tests, said `No flow tests found` on a face full of specs, and exited 0 on every wandering test — it is not a statement about the specs.
Keep going
Writing screen testsScreen tests and flow tests — the files the check walks, and the `when` gates that decide which steps are consecutive./guides/testing
Screen identityWhere a screen id comes from, and how a transition is asserted at runtime./concepts/screen-identity
Navigation between screensHow a spec's transitions are meant to be written in the first place./guides/navigation