Workgraph simulator
The documentation embeds animated models of the Transformation System through a workgraph fenced block. The block holds a JavaScript object literal describing one workgraph; a page that shows several models gives each its own block, inside a content tab or a section of its own. Two scripts are on every page: docs/assets/js/workgraph-sim-engine.js, a DOM-free model of the ADRs, and docs/assets/js/workgraph-sim.js, which draws it. The styles are in docs/assets/css/workgraph-sim.css. A run is deterministic for a given seed, and the model pauses while it is out of view.
Whenever the model is not advancing the canvas loses its colour, which is what a reader catches from the corner of the eye when they expected motion and what works whatever is covering the picture. For the three causes a reader can undo — the pause, an open dialog, help mode — one word is drawn over the picture, paused every time, with a line under it saying which; the reader is looking for the same thing each time, so only the caption changes. The cause is on the widget's root as data-hold, so the styling reads which of the four it is rather than that there is one, and a reader who paused and then opened a dialog is still paused, since closing the dialog will not resume. armed, where nothing has run yet, draws no word: the transport is glowing and that is the thing to press. The word is in the viewBox's own units, so it keeps its size against the graph in a column-width block and in a window alike, and it is drawn over the picture rather than under it, these graphs running top-down so that the centre of one is a card — faint enough to be a watermark on a canvas that has lost its colour anyway.
The transport holds two kinds of control in one shape. Reset and step each do something once; the four beside them set a rate, and pause is not a button next to the speeds but rate zero within them, a radio of the same group — which is why a momentary control goes to their left rather than among them, where it would be the one child of a radiogroup that is not a radio. One press of step stops the model where it is and takes it on to the next thing that happens: the engine’s own 0.05 slices until the model records something, capped at two model seconds for the stretch where nothing is in flight at all. What counts as something is the model’s own record of it — every transition the machines draw on their edges, inputs and parcels both, and the log besides — which is the finest grain the model has, so a press lands on one thing and the picture moves by exactly that. A fixed slice reads worse in either direction: one tick usually catches the model between events, the feeder sweeping on a period of a model second and the packer on half of one, while a whole second often brings three things at once and leaves no way to tell which caused which. The log alone will not serve as the mark, though it is the obvious one: the engine writes a line for a phase change, a hook’s decision, an action’s result, an input quarantined, and for nothing else, so an ordinary run — an input taken up, run and processed, a parcel reserved, assigned and done — passes in silence, and two presses in three would find the log where they left it and spend the cap instead. The slices are the frame loop’s own, so a stepped run and a played run of one seed are the same run. What the press moved is said on a foot of the log — 4 inputs → Processed, 2 parcels → Done, the machines’ own grammar, counted and grouped under the member — which is where a reader looks for what happened and is the one thing a press would otherwise leave them to work out from the picture. A press starts a model that has not run, as a speed does, and is inert behind a dialog, where the model is held anyway and the picture the dialog covers has to be the picture it was opened on. The key is > rather than the . the shape of the control suggests: the documentation theme binds . to its own next page, on a stream over the window, so a reader stepping the model would have been carried off it. Shift is not a modifier for this purpose, ? being bound the same way, and the theme matches the unshifted key.
The layout engine is not on that list. The bundle is 1.6 MB and only a handful of these pages carry a model at all, so the renderer fetches it itself, once per page, the first time a model is scrolled to — a page with no workgraph block never asks for it, and it is served from assets/js/vendor/elk.bundled.js, beside the renderer’s own script. Nothing in the source tree holds it: elkjs is a conda dependency of the mkdocs and workgraph-sim environments, and hooks/elkjs.py copies it out of the prefix into site_dir on on_post_build, so mkdocs serve gets it too. It was a file copied into the tree by hand before that — untracked, unignored, and absent from a fresh clone, which built a widget that could not lay anything out and a check suite that would not start. The licences travel with the bundle, all eight of them: conda-forge declares elkjs EPL-2.0 AND EPL-1.0 AND Apache-2.0 AND Apache-1.1 AND MIT, because the lib/ builds carry ELK’s Java dependencies through GWT and code from its JavaScript bundler, where the npm tarball carries ELK’s own licence alone. Only the bundled build is any use here: elk-api.js wants a real Web Worker, which the checks have no way to provide, where elk.bundled.js falls back to laying out in-process. Offline, then, in the form the rest of this repository already means by it: offline once the environment is installed. While it downloads, the model's canvas shows what progress there is: a real bar where the response carries a length, an indeterminate one where it does not, and a plain message where the file cannot be had at all. A model whose layout resolves without a download shows nothing for the first 150 ms, so a fast layout does not flicker an indicator.
Type and the two faces
One scale, five steps a major second apart, declared as tokens in the block the simulator and the playground share, and every font-size in either stylesheet is one of them. --wg-t-micro is a count, an age or a status and never prose; --wg-t-meta an aside, a legend or a secondary row; --wg-t-body a row, a table, a control or prose; --wg-t-head a section heading or a tab; --wg-t-title a surface's name, once per surface. A role takes a step once and keeps it wherever it is drawn, so a header is a title in a dialog and in the actions strip alike, and a surface that has to be tighter moves --wg-t rather than giving one role a second size. The widget is an instrument panel rather than an article: at nine to fifteen pixels 1.125 is the smallest step the eye still reads as a step, and the whole scale stays inside 1.6×.
A density mode is therefore one declaration — --wg-t is 11px narrow, 12px in the page and 13px maximised — and every ratio above it holds, so no two elements change their relationship with the mode. A dialog sets the base back to 12px on itself: what it says is read at one size wherever it is opened, and a window gives a dialog more room rather than larger type.
The canvas has names of its own, --wg-g-micro, --wg-g-label and --wg-g-title. They are viewBox units, which scale with the picture where the chrome's pixels do not, so the same figure on either side of that boundary is two rendered sizes and the two must not share a vocabulary of numbers.
The faces are the theme's, IBM Plex Sans and IBM Plex Mono — one superfamily drawn together, with the slashed zero the hex file tags need. Mono is for data: a tag, an id, a state's name, a count that has to stay in its column, the log, and everything drawn on the canvas. Everything else is prose and takes the sans, so the face says what a string is rather than which block it sits in. invariants.test.js holds the scale: a fourteenth size cannot be added quietly, which is how the thirteen it replaced arrived.
Layout and edge routing
elkjs places the nodes and routes the edges, with elk.layered and edgeRouting: ORTHOGONAL. Boxy is deliberate: a right angle is unambiguous where a curve is not, and the spacing options are what keep an edge from ever passing beneath a node, which is how an edge disappears. docs.test.js asserts that invariant over every model the documentation ships.
Ports carry the meaning, and they are what the merging rules rest on:
- A fan-out from one port is a trunk that branches.
mergeEdgeslets edges that leave the same port share their first run; a filled dot marks each point where the trunk splits, so a split reads as deliberate rather than as a crossing. Files travelling to several consumers are drawn once while they are on the trunk and once per branch after the junction. - A transformation's outputs never share a trunk.
mainandartifactare separate ports, as are thewaitof anafterdependency and thejoinof a joining packer, so edges that carry different things can never be merged into one. - A fan-in is never merged. Every upstream lands on its own input port, because merging them would destroy the provenance the picture exists to show: you could no longer tell which upstream fed what.
- A port's side is fixed, its place on that side is not. Inputs are north and outputs south, so the top-down reading never changes, but ELK may permute the ports within a side, which is the freedom its crossing minimisation needs. Pin the order instead and a card's
waitport sits left of itsmainport whatever the two connect to, which is a crossing built in. No model the documentation ships has one edge crossing another, anddocs.test.jsholds that too: a horizontal run of one edge against a vertical run of another, skipping the pairs that leave the same port, whose overlap is the trunk. - A skip edge detours. An edge that spans more than one layer is routed around the layers it passes, in a channel
spacing.edgeEdgekeeps clear of other edges, rather than cutting across the middle. It is not styled differently: dashed already means a wait or a join, and the detour says enough on its own.
Every path is drawn twice, in sequence: a casing in the background colour, then the stroke on top. That is what stops two edges dissolving into each other where they cross — the later edge passes visibly over the earlier one. Box labels are painted the same way, so a trunk running past one passes behind the text.
Laying a model out is asynchronous, so a widget's canvas carries its loading state until elkjs answers, and WorkgraphSim.layout(sim) returns a promise. The result depends on the spec alone, so a model is laid out once, the first time it is scrolled to, and never again — nothing a reader does inside a card lays the graph out a second time.
```workgraph
{
name: "simple",
transformations: {
simulation: { feeder: { seeds: 300 }, packer: { size: 1 } },
reco: { feeder: { from: "simulation" }, packer: { size: 3 } },
},
outputs: { datasets: { from: "reco" } },
}
```
Top-level keys
name(default:workgraph)- Names the model in the checks.
seed(default: a random one per load)- Random seed. A block that pins one runs the same workgraph on every load; a block that leaves it out is given a random seed as the page mounts the model, so an input that fails three times and lands in
Problematic, or a quarantine that holds a member's drain and keeps the workgraphActive, happen on some loads and not others. reset advances the seed by one from wherever the load started, so running the model again gives a different run rather than a replay. The engine's own default is1, which is what the checks intests/workgraph-simbuild their models on, and they pin the seeds they run a model under, so a run one of them asserts about is reproducible. start(default:manual)- The model waits in
Newuntil the reader picks a speed, presses pause to start it held so that the sweeps can be run by hand, or steps it, and the playback group glows until then;autostarts it on load. A model staysCompleteduntil the reader runs it again. slots(default:4)- Parcels the backend runs at once, one box of the card's slot grid each: two columns up to four, three beyond, always inside the same square, so a card's size never depends on its capacity. Nine is the most a card can draw and a larger number is clamped to it. The packer packs every ready input immediately; parcels beyond the capacity queue as
Unassigned, the pool left of the grid draws them as small boxes holding their inputs, a chip over the grid's last corner counts them exactly, and clicking either shows the parcel counts by status. workgraphscoutingenables a scouting phase:{count}for one sample,{stages: [n, ...]}for a ladder of samples, or{fraction}of the query. TheScoutingToApprovinghook runs the ladder: while the current stage runs it does nothing, once every active member has drained it raises the feeders to the next stage, and after the last it accepts. The current stage is the cap on what every feeder may take, a query feeder by its cursor and a seed feeder by its request, and it stands until the workgraph is approved rather than until the scout is accepted:Approvingis where a person decides whether the full production runs, so nothing is read past the sample while an approving action waits, and it isActivethat releases the rest.failAbove(default0.1) andminSeen(default10) are the one failure threshold that the hook and the success-rate check share, judged by input fate: the inputs set aside asProblematicover the inputs that have settled,ProcessedorProblematic, across every member, so a retried input that later succeeds is no failure and an input in flight counts neither way. Once more than that fraction of at least that many settled inputs has endedProblematicthe hook accepts at once, knowing the check will fail, so a scout in trouble shows asApprovingBlockedand one still running asScouting; the minimum sample gates only that early accept, and the check judges whatever has settled. The two read the threshold over different windows: the hook's early accept judges what settled in the current stage, so a scout sent further after a block is judged on what the new stage produces rather than accepted again at once, while the check judges the whole scout, where aProblematicinput counts until an operator resets it or writes it off. A stage drains quarantine aside, since aProblematicinput waits for an operator and would otherwise hold the ladder for ever. scout further adds a stage to the run's ladder; the spec's is untouched and a reset starts it again.approvingis the list of approving actions run in order once the scout has drained: names,{name, check: "success rate"}for the check that judges the threshold,{name, manual: true}for a sign-off, which fails until someone gives it, so the workgraph showsApprovingBlockedwith force passed, the sign-off, and reset; or{name, fail: "once"}for a check that fails the first time and blocks the same way. With scouting the default list is the success-rate check, a resource estimate and a manual approval. A sign-off is the reader's to give: no block can sign itself off, and a model that carries one waits inApprovingBlockeduntil someone does.archiveAfter(default4) is the delay in seconds betweenCompletedandArchiving.target: {output: id, files: N}is the workgraph'sActivehook: it disables every external feeder once that output box holds N files, so a feeder'sseedsis only an upper bound.settings- Sweep periods and failure multiplier:
feederPeriod(default1),packerPeriod(0.5),hookPeriod(1) andactionPeriod(1) in model seconds;failScale(1) multiplies every transformation's failure rate. A period of0starts every row of that kind on every rail as manual, and the model then starts in expert mode so that those rows are in view; the default period is the cadence once a row is set back to auto. The hook period coversHandleFailedInputas well as the workgraph's and the members'Activehooks: a failed input waits inFaileduntil the next hook sweep of its member, or while that member's hooks are by hand until they are run. The action period is the sweep of the action task, which settles each entity's running action and starts its next; with an entity's actions by hand an action runs only from its rail or the walkthrough. The chaos chip setsfailOverrideto a flat 50% failure rate. sources- Input queries, keyed by id.
transformations- Transformations, keyed by id. Order matters only for layout ties.
outputs- Boxes below the workgraph, keyed by id.
Sources
label(default:input query)- Label above the box, where the edges leaving its foot cannot run through it.
files(default:200)- Number of files the query returns. A query's box is an output box seen from the other end: the count on one line, and the files themselves behind a click, listed in the order the query returns them, each opening its lineage. A list of the first three of two hundred says less than the number does.
types(default:["circle","triangle","diamond"])- Shapes to draw from.
colours(default:3)- Number of source identities (colours) to draw from.
ancestors- Id of another source whose files are the ancestors of this one's, for
packer.lookup.
Transformations
label(default: the id)- Card title.
doc- One line about what the transformation runs, in the card's tooltip. The playground's compiler puts the first line of a step's CWL
dochere. kind(default:compute)compute,replicationorremoval. Data kinds record no outputs.feederfrom: a source id, a transformation id, or a list of them.seeds: Nyields seeds instead.after: [ids]delays each input until every listed transformation that receives its file has processed it.typetakes only files of that shape.portismainorartifact, selecting which of the producer's outputs to consume. A feeder yields everything up to its limit as soon as it is enabled.batch: Nsets the limit to the scouting sample first and lets the workgraph'sActivehook raise it by N whenever fewer thaninflightinputs are in flight (default N/2), counting everything issued that has not finished in the compute members downstream, so a stopped member holds the hook back. A seed feeder counts as running while it is enabled and its request is not exhausted, even when its limit is reached, so a workgraph with a target ends when the hook disables the feeder.packer(default:{size: 1})sizeinputs per parcel;bygroups bytype,colourororiginbefore packing.join: idadds to each parcel the partner file another transformation produced from the same source file, delaying the input until it exists and marking itProblematicif it never will.lookup: idadds each input's ancestor from that source.run(default:[1.6, 3.2],[0.7, 1.4]for data)- Job duration range in seconds.
fail(default:0.08,0.02for data)- Probability a parcel fails.
partial(default:0)- Probability a parcel finishes part of each input, which splits the input.
sections(default:10)- Sections a file is split over. An input covers a range of them; the whole file carries no mask, and a child of a split carries
lo-hi. retries(default:2)- Failures before an input becomes
Problematic. subdivide(default:0)- Probability that
HandleFailedInputreplaces a failed input with two smaller ones instead of retrying it, which is theFailed→Splitedge of DX-ADR-005. A child covers half its parent's sections and carries the mask that names them. submitFail(default:0)- Probability that a
Reservedparcel is not accepted by the backend and goes back toUnassignedfor the dispatcher to try again. No backend accepted it, so it is not a retry and the inputs stayAssigned. pauseAbove- Failure rate above which the transformation's own
Activehook pauses it, judged on what has happened since it was last started, so an operator who resumes it gets a fresh window. emit(default:parcel)parcelproduces one output per parcel,inputone per input.artifact(default:false)- Each finished parcel also emits a small file on the
artifactport. finalize(default:[])- Finalizing actions run once the workgraph drains, a member's list once the lists of the members it depends on have finished, so members with no dependency between them run theirs at the same time and a blocked member holds back everything downstream of it (DX-ADR-005): names,
{name, merge: outputId}to merge that output box's files into one, or{name, fail: "once"}to showFinalizingBlocked. While a list runs, the card's pool shows the running action in place of what waits, the failed one while it is blocked, and the list's outcome once it has run; the card's panel holds every list with its results. archive,clean(default: the DX-ADR-006 example lists)- Archiving actions run after
Completed, cleaning actions after cancel. An action may carry aneffect:cleanempties the transformation's inputs and parcels,removeOutputsempties the output boxes it feeds,cancelasks the backends to stop its in-flight parcels, which sends them throughCompletingtoCancelledand recordsPendingon the action until they are all terminal. output(default: inherit)- Shape of the produced files.
holdapprovalstarts the transformation when the workgraph is approved;operatorshows a start button, in the toolbar beside the playback, glowing while it waits. A member held for an operator does not drain, so the workgraph staysActiveuntil that button is pressed.
Outputs
from- Transformation whose outputs the box collects.
label(default: the id)- Label under the box.
show(default:files)filesshows the count, one line, muted while it is zero since the sink exists before it holds anything; a click lists the files, newest first, each with the transformation that produced it where they differ and its age, and a click on a file opens its lineage.histogrambins their size.port(default:main)artifactcollects the producer's artifact files instead.
A card's bottom edge is its feeder's bar, the inputs by state in the counters' colours, stacked across the whole width of the card: against the limit for a seed or query feeder, over what was fed so far for an edge feeder, whose producer is still producing; the bookmark is in its tooltip. What is left of the edge is drawn in the border's own colour, so a card that has consumed nothing shows a plain 3px bottom and colour appears in it only as work happens, which is a zero suppressed by the design rather than by a case in the code. The other three edges stay the hairline the card has always had, and the whole edge is clipped to the card's box, whose corners are small enough that the first and last segments lose almost nothing to the rounding. A control glows while it waits for the reader: the playback until the model starts, the start button of a member held back for an operator, and a card's pool while it holds Problematic inputs or a failed action, or while the member is what keeps an Active workgraph open for a person. The pool says which of those three it is, in its own title, where it is read before anything is opened — the reader’s first question is what wants them rather than where to go, and one sentence covering three situations answered neither. The pulse and the sentence are one condition in the renderer, so a fourth reason cannot light the pool without also giving it something to say. An edge into a card carries a +N chip while the producer has made files the card's feeder has not swept, which a manual feeder sweep leaves in view. It sits on that consumer's own branch rather than on a shared trunk, since what is pending is per-consumer, and is offset to the side of the path so a file travelling the same branch cannot sit under it.
The pool on the left holds what waits before the slots, on a surface of its own, in cells: Problematic inputs first, since they wait for a person, then the parcels the packer has made that wait for a slot, each a small box in its state's border holding its inputs' shapes, then the loose Unassigned inputs, faded while delayed and small while the child of a split. Its cells are measured from the region rather than counted out here, as many columns and rows as its own width and height hold, and what they cannot hold is counted in its bottom-right corner, which is kept clear of the shapes for as many cells as the number needs and given back to them when nothing is hidden. While a list of actions is eligible the running action takes the region in place of the cells: a list becomes eligible only once the member has drained, so nothing is waiting there to be covered. The pool is as tall as the slot grid whatever it holds, which is what keeps the cards of a layer the same height. The slot grid on the right encodes one dimension per channel: the border's colour is the parcel's state, in the counts' colours; a dashed border is a parcel waiting on the backend's answer, Reserved before it is accepted and Completing before its outcome is known; the fill is the job's progress, full at Completing; a halo behind the box is a parcel that ended a moment ago, fading; the shapes inside are its inputs, and an outlined shape a partner the packer added. Nothing else changes a box: a recovery parcel is Done, and its lineage says where it was born. The parcels table in the card's dialog leads each row with a swatch of that state's box and ends with the channels, so the table is the legend for whatever the card shows, and the pool and the grid each disclose their own encoding in help mode.
Expert mode and the rail
The toolbar's expert chip shows and hides the automation rails and opens the matrix of what runs by hand; it sets no mode itself. With it off, the default, no rail is drawn on any card and no pill on the workgraph's header, the cards reclaim that width, and every sweep of every entity runs by itself. With it on, a rail appears on each card's right edge holding one row per sweep, in the order of the taxonomy: the feeder, the packer, the hooks and the action lists; the workgraph's header carries the same two rows it has, its hooks and its approving list, as pills at its right end. Each row is auto, sweeping on its own period, or manual, sweeping only when the reader runs it, and a click anywhere on the row switches it: that is the one way to set a mode per entity, and the engine keeps each row's mode across a reset. While a row is manual and its sweep has work, the mode word gives way to a run button and the row takes a faint tint; the button runs one sweep and leaves the mode alone, and a row with nothing to do shows no button at all.
A member runs two hooks, HandleFailedInput and its own Active hook, and they are two rows the engine sets apart and one row on the rail. The row reads mixed, muted, where auto or manual would go and the two differ; the â–¶ is still there while either of them is by hand with work, and a click on the row sets the whole of it uniform, automatic where any of it was by hand. Five rows on a rail would cost each card the height the compaction of the cards reclaimed, so the matrix is where the two hooks are set apart, and it is the only place HandleFailedInput can be taken by hand on its own. The workgraph holds no inputs, so its hooks pill covers its status hooks alone and is never mixed.
A row's left cell names the sweep it controls: while the row is automatic it shows the sweep's glyph only, ▷▷ for the feeder, ⬡ for the packer, ⚡ for the hooks and ☑ for the actions, with the name in its tooltip; once by hand it shows the word instead. The rows the reader has taken control of name themselves, and the rest stay quiet. The rail's width is fixed, so switching a row never lays the graph out again; entering or leaving expert mode is what does, and the layout for each mode is computed once.
Leaving expert mode sets every row of every entity back to auto, silently. That is deliberate: a hidden manual row would stall the model with no visible cause, since the run button that would explain it is hidden too. Expert off is a guarantee that everything runs, not a view filter.
What counts as work is what one sweep would do: a feeder with files on its edge or seeds below its limit, a packer with a ready input, a member's hooks with an input in Failed or an Active hook that would pause it, the workgraph's hooks with a sample that has drained or a feeder to raise or disable, and a list with an action next. A failed action is not sweep work, since a sweep leaves a blocked runner alone; it waits on force passed and reset in the strip and the dialogs. A list held back by an upstream member's is not runnable either, and the row's tooltip names the member. An action run by hand is settled as a sweep would settle it, so a cancellation with parcels still in flight records Pending rather than passing.
What runs by hand
Toggling one rail row at a time stops scaling the moment a workgraph has more than one member, so expert mode opens a dialog that sets the modes as a matrix: a column per sweep, a row per entity, one cell each. There are only ever five sweeps, so the columns cannot grow, and a workgraph of a dozen members adds rows the dialog scrolls rather than columns it has no room to show; the header row stays put while they scroll under it. The chip is the one way in and the dialog exists only while expert mode is on, so turning the chip off closes it and sets every row back to auto, whatever was set here. Its header band names it and counts what is by hand, 6 of 27 manual, suppressed while nothing is. That count is where a reader sees at a glance that something was left on, which is what matters when a node is not progressing.
The columns are feeders, packers, HandleFailedInput, status hooks and actions. HandleFailedInput comes before the status hooks deliberately: both are hooks, so their order between themselves is free, and this gathers the cells the workgraph does not have into one block rather than interleaving blanks with content. The rows are the workgraph and then each transformation in graph order. The workgraph is a row rather than a strip beside the grid, which is what lets the column logic reach it with no case of its own: status hooks clicked includes it, and feeders clicked correctly does not. A gutter of about ten pixels separates it from the members rather than a heavier rule, since it owns them rather than standing beside them, and the gutter says so without competing with the hairlines. The cells the workgraph has no sweep for, its feeder, its packer and its HandleFailedInput, draw a muted em dash rather than nothing: an empty cell reads as not loaded, and the dash states that the workgraph has no feeder, which is worth knowing. Every other cell is auto or manual and never a third thing, auto in the accent at a little over half opacity with no background of its own and manual in the secondary ink on the second surface, since by hand is the state the reader is managing and is what carries the weight.
The ternary lives on the headers. Each column header, each row label and the corner, which is labelled everything, is its own aggregate, carrying a small dot before its text whose fill tracks how much of that scope is by hand: hollow where none of it is, the left half filled where some of it is, solid where all of it is. One hue and the fill as the scale, so mixed never needs a word and no third colour is introduced. A click on any aggregate makes its scope uniform: a scope that is entirely automatic goes by hand, and anything else, all by hand or mixed, goes automatic. That is one predictable click rather than a cycle through three states, where a reader has to click once to find out where they are; mixed is reported and never set. A click on a cell switches that one cell, which is the only way to reach one member's HandleFailedInput by itself.
A bare label does not look clickable, so the hover does both jobs at once. The column or row tints and everything outside the scope drops back to about half opacity, so the scope is visible before the click; and the label text is replaced by what the click would do, → all manual or → all auto, in the accent, with the dot hidden while the text is swapped. Both forms of a label sit in one grid cell, so the wider of them reserves the width and nothing reflows on the swap. Tab focus applies the same swap, which is what covers the keyboard, and the tint follows :focus-visible as the swap does, so a label clicked with the pointer does not keep its scope lit once the pointer has left it. A touch screen has no hover, and this is a documentation page, so the preview of a scope is a desktop affordance; the labels are ordinary buttons, and a tap sets the scope without ever showing what it would do. The footer reads the dot, auto, mixed and manual against the three fills, and done closes the dialog and leaves expert mode as it was. Nothing in the footer sets a mode: everything in the corner is where the whole matrix is taken at once, and a second control for the same move is the duplication this surface keeps accumulating.
Each card opens one dialog: a header band naming the entity and its state once, a strip of four tabs, and one pane under it with the body to itself. status is the live state on the left and the extension points on the right, the two columns; the other three are the machines, the transformation's, its inputs' and its parcels', with the count of inputs and of parcels on their tabs. A card opens on status and its backlog chip on the parcels' machine. The two sections of status that count what a machine holds are the way into that machine — a click anywhere in either is that tab — since the count is the question and the machine is the answer.
A pane with the body to itself is the point of the strip: a machine and the columns sharing the height left neither of them room, and a machine is only as legible as the width it is drawn at. The body holds one height across the four, deep enough for a machine and for the list a box opens under it, so a dialog never resizes under the tab the reader is pressing. Every machine, the workgraph's included, is drawn on one canvas at one scale, the same box and the same type size; the canvas is as wide as the workgraph's spine needs to hold eight full names in a row, and the machine is drawn to the dialog's width and centred in what the body leaves. A dialog is read at one size wherever it is opened: maximised the panel widens, which is what takes a machine past its own scale, but no type changes with it. Each dialog names both of its classes in the stylesheet, since the rule they share caps the width of all of them and is declared after the rules that widen one: on the class alone, a width loses to that cap at equal specificity and the dialog silently keeps the shared one. A legend line under each machine says what applies to it. The transformation's machine is that of DX-ADR-005, laid out on a spine as the workgraph's is: the states a successful run passes through left to right on one row, the states off that path, Paused and the blocked counterparts, in a band above, each directly over the state it is a variant of, and Cancelling and Cleaned in a band below, under the last two spine states, with CancellingBlocked under Cancelling. Each band's caption sits over the band's leftmost box, so it follows the band. The states that can cancel drop a stub each into one faint dashed trunk that enters Cancelling from the left, each stub carrying its own count, so which state a cancellation came from stays readable. Its states so far are lit and marked, every edge counts the transitions this member has taken, and pause and resume are buttons on the edge they take. A blocked state covers two situations and the colour tells them apart wherever the state shows, on the machine, the card, the strip and the dialogs' headers: red while an action has failed, a problem, and amber while a sign-off nobody has given holds the list, a wait. The input machine holds each state's occupancy, each edge's transitions so far, births included, and the operator's three edges dashed with a button naming the verb and the count it would move; the inputs tab glows while Problematic inputs wait there, that being the machine whose edges move them; the transformation tab glows in the same way, and with the same class, while the member is paused with work left, whose resume is an edge of that machine and pulses on it as the pool does, in the stroke and not the box-shadow the tabs wear, a machine’s button being an SVG group. A glow marks the next step and never the whole path, so a tab glows only for what its own machine answers: a member waiting on an operator over its quarantine is held too, and its tab is the inputs tab, the transformation machine having no control to offer it. A blocked action glows no tab at all, deliberately — the actions panel docks itself over the picture with force passed and reset on the action that failed, which is louder than a glow and is already where that work lives, and while HandleFailedInput is by hand the Failed box carries a button that runs it over what waits. The packer's own Unassigned → Assigned edge carries a fourth button, flush, and keeps its ordinary stroke: an operator does not put an input in Assigned, the packer does, and a flush asks it to run now. The verb is not run packer — an automatic packer sweeps twice a model second, so running one is nothing the model was not about to do anyway, where a flush packs the groups it is holding short of full and no sweep ever will. The count is those held inputs rather than what the pool holds, and the button is offered only while there are any, since a full group is the packer's own business. The parcel machine holds the dispatcher's states and the four terminal ones, and offers no operator edge: DX-ADR-005 gives one, forcing a parcel stuck in Completing to Failed, and nothing in the model wedges a parcel there to use it. A zero is suppressed on every count, a birth edge's included. One rule policy below it: a 0.5px line separates sections and nothing else. That is one selector, section + section, which reaches both columns because the sections that count what a machine holds carry the click through to that machine themselves rather than being wrapped in a div — wrapped, they were never adjacent siblings, the rule never matched down the left, and the only line there was one inside a table. The terminal states are a group inside a table rather than a section, so a space divides them now. Below the hairline: the inputs by status and the parcels by status on the left, the feeder's arguments, the packer's rules, the hooks and what each last decided, and every action list with its results on the right; a hook's name and what it decided are two columns, so a decision that wraps stays in its own. The drain guard — what is non-terminal and so keeps the member open, Problematic counted among it, since the workgraph stays Active until an operator has reset or written off every quarantined input (DX-ADR-005) — is the feeder block's first row, where the heading already carries the feeder's own state at its right end: a member is drained when its feeder is off and nothing is non-terminal, and both halves of that read together there. In amber, which is a wait everywhere else in the widget and is what this is. Nothing in the dialog disables the feeder: its rail row by hand is the one way to stop it. The flush is on the input machine's edge, above, and the packer's ▶ on the rail stays the way to run an ordinary sweep by hand. A click on the flush opens the explanation rather than flushing — write off · 34 says exactly what it will do, where a flush makes some number of undersized parcels the label cannot predict — and it is the same explanation a manual packer sweep that made nothing opens, so there is one text and one confirm with two entrances. The packer's own section says where it stands, 12 ready, 8 short of a group, which is the half of a stalled pool its rules do not answer. In the checks the engine verifies after every step that each input state's inbound minus outbound transitions equal its occupancy, and invariants.test.js reconciles the parcel machine the same way, over a run that ends with its rows cleaned: a list with effect: "clean" empties the boxes and records each removal as an outbound transition, so the edges keep the history.
A box that holds something lists it. On the inputs and the parcels tab a click on a box shows what is in it under the machine, and the same box again shows everything; a box drawn zero opens nothing, having nothing to list. The strip is packed rather than stacked — a chip each, wrapping, as many as the line holds and the rest counted at the end — which is the pool’s own rule, so it asks the reader to learn nothing new. A stacked row is the full width of the pane and the pane has room for about one of them once the machine has taken its share, so a member with forty-three inputs read as a heading, a sliver of a row and a count; a wrapped strip has a height its line count predicts, and it settles into the slack the machine leaves instead of scrolling inside it. Three lines, and what a line holds is arithmetic rather than a guess: a chip is a shape, a four-character tag and its padding, all of which the stylesheet declares, and the checks do that sum against those numbers. The strip and its chips each carry a third class to outrank the theme’s list rules, without which every chip is a block the width of the pane and no packing happens at all.
That is what makes the whole set affordable, so a tab opens on all of them. There is no set to name and none to explain, and nothing is held back by a rule the reader cannot see; the ordering does what a filter would — the quarantine leads, then the failures, then the machine’s own order — so what needs a reader is in the first line and the count at the end swallows the settled tail. The heading states the selection rather than justifying it, all or the name of the box that was clicked. The machine stays the only way to choose: a dropdown would be the one <select> in the widget and a second control beside a machine that is already selecting. A chip is a file’s shape and its tag, or a parcel’s state swatch and its tag, tinted with that state’s own colour out of the map the counts tables read, so a strip holding several states reads at a glance. Everything else — what the state means, the sections a split covers, the failures against the retries the hook allows, how far a parcel has got — is in the chip’s title and in the lineage a click opens, which is where all of it already lived. An input’s chip opens its file’s lineage; a parcel’s opens nothing, a parcel being a surface nowhere in the widget. No chip carries an operator’s button: the machine acts over a whole state and the lineage dialog on one input, and the same verbs on a chip would be a third scale between them — the strip is for seeing.
The state strip and the state line
Under the toolbar, two rows of bare content are the workgraph's header, and the band they make is the one way into the workgraph's dialog: a click anywhere on it opens it, and on hover the band tints and says so at the right end of its last row. The dialog has the shape every entity's dialog has: a header band naming the workgraph and its state once, the workgraph's state machine of DX-ADR-005 full width, where each edge counts the transitions taken, a hairline, and two columns, the members by state on the left and the workgraph's hooks and its approving list with its results on the right.
The first row is a strip of the workgraph's states, drawn once, wrapping where the column is narrow, the lit one now and the marked ones passed; ApprovingBlocked is red while a check has failed and amber while a sign-off waits. It is the path this run can have taken rather than the enumeration of every state, because grey on it means not yet: a workgraph with no scout leaves out Scouting and Approving, which it will never reach, and a cancelled one keeps the happy path up to where it was left and then the cancelling tail. What it drops is not lost — the machine in the dialog is DX-ADR-005's and holds every state, with no transitions counted on the edges a run never took. It carries no control. What an operator can do to the workgraph is on its state machine in the dialog, and on every machine the rule is one: an edge an operator can force renders as a button on that edge, labelled with a verb, only while the edge is legal, never disabled, and its tooltip says what becomes of the work in flight. A button is the size of the app's other controls, which an edge ten units long cannot hold, so where the picture puts one clear of the line a leader runs from it down to the edge, in the edge's own group, fading and lighting with it. From Active the workgraph's machine offers drain and halt on the edge to Finalizing and cancel on the edge to Cancelling; from New and the other running states cancel alone; from ApprovingBlocked scout further on the edge back to Scouting, and while a member is FinalizingBlocked back to Active on the edge back to Active, the two backward edges of DX-ADR-005; from Completed on, nothing. Acting is immediate.
Termination is the workgraph's alone. drain stops the outermost feeders and lets everything already in flight finish; halt stops every feeder, packer and hook, cancels the parcels in flight, and closes each transformation as its slots empty. Both end in Completed through Finalizing, as DX-ADR-005 has it: there is no forced transition, the operator changes what the members do and the core takes the transition once every member has drained, so the button is the only place the difference between the two lives, and the line under the strip says draining or halting meanwhile. cancel is the destructive one, on which the members run their cleaning lists instead of finishing and the outputs are removed. A transformation cannot be drained, halted or cancelled on its own: its machine still draws Cancelling and Cleaned, which it passes through when the workgraph cascades down, but those edges carry no button. Pause and resume stay per transformation, on its machine.
The second row is a muted line, its status glyph at the left, saying what the workgraph is doing or waiting on: the scout's stage, with the inputs processed and set aside so far, the approving check that runs, a sign-off, a failed check, and in Active whether the workgraph is requesting or, with every external feeder off, feeders done, draining, then the Active hook's plan. Those are conditions inside a state rather than states of their own, and the line is where they show. A member only a person can release takes the phase word's place: held by sprucing · 24 quarantined, held by buffer removal · paused, and the names alone where several hold it at once. A quarantine nobody has decided, or a member paused with work left, holds the workgraph in Active until a person moves it, and feeders done, draining is what a healthy drain says. A halt keeps its own line, having written the quarantine off and cancelled what was in flight, so the hold it leaves clears without an operator; a drain stops the outermost feeders and leaves both holds where they were. The line is not there while the workgraph has nothing to say, in New above all, nor while a list of actions runs, since the actions panel shows that; the header is then one row tall. The hint that the band opens the dialog keeps its width at the line's right end whether or not it shows, so a long line truncates before it. In expert mode the workgraph's pills sit at the right end of the line, or of the strip while the line is away. The line carries no button: what an operator can do to a running list lives in the actions panel, and what an operator can do to the workgraph's state lives on the machine. The action that stops the in-flight parcels on a cancel records Pending until the backends confirm, which is what Pending is for.
The actions panel
Whenever an action list starts, the workgraph's approving list or a member's finalizing, archiving or cleaning list, a panel docked under the control band, above the workgraph's header, full width, appears and shows it, so it covers nothing it describes: each action with its result as it lands, the running one marked, the lists in one row, and beneath a list only what its ticks do not say, which action is running or next, what it waits for, what failed. A finished list says nothing more, since a column of ticks already says it, and no line is repeated from one list to the next. The engine's runningLists() is what it draws, the workgraph's list first and then the members' upstream first, so a member held back by an upstream list says which one it waits for. That order is the row's, kept lists included, which is why the renderer works it out from the same two sources rather than from the order the lists happened to start in. The row drains to the left: a list leaves when it is done with and the ones behind it slide into its place, so what is left on the row is the work still to do and the space at its right end is how much has been done, read without counting anything. A column is as wide as its own list needs, between a floor and a cap, and never grows into the space its neighbours leave, which would move it for a reason the reader cannot see. The workgraph's own list is the exception, and takes the row: it runs only while the workgraph is Approving or ApprovingBlocked, and a member in either of those is Active or Paused, neither of which runs a list, so it is always the only column there is — nothing to keep a width against and no departure to read into a shift, where at a column's width its sentence wrapped and its operator buttons dropped onto a line of their own with most of the band empty beside them. The exception is the entity's, not the count's: a column that widened whenever the row had room would move every survivor of a draining queue each time one left. The floor is there because a column narrower than it is unreadable and the cap because one long line should not take the row; a list carrying two operator buttons and the sentence explaining them — the approving list blocked on a sign-off is the worst case the model has — needs more than the floor, and where the buttons still do not fit beside the sentence they drop under it rather than crushing it. How many columns the row holds is measured from the widths it drew rather than divided out of the floor, since dividing would promise room a wide column does not leave. A row with more lists than it has room for says +N at its right end rather than wrapping onto a second row or putting half of itself behind a scroll — a panel that grew with the run would shove the picture it is docked above. Nothing is lost behind that count: a member's own dialog holds its lists with their results, and its card already says 2 finalizing actions passed. The panel is the summary and the dialog is the record. The row is patched column by column rather than redrawn each frame, since a column replaced every frame could never be seen to move; a column that has moved is let go from where it stood, and under prefers-reduced-motion it simply lands. The panel holds nothing: the model runs on below it, and while a list runs the action sweep goes no faster than T.watch, one action every second and a half, so the results can be read as they land. A column's heading holds the entity and the state it is in, and the kind of list only where the panel holds more than one kind — with one, the header above the row says it once, actions · 3 finalizing lists, and a column repeating it is a word taken from the name beside it. The entity is the member's label, or workgraph for the workgraph's own list, which is what the log's subject column and the mode matrix call it; the model's name is the document's title rather than an entity's, and the lockup already carries it. Nothing in the heading is shaved to fit: a word cut to a stub takes the width it saves and says less than nothing, so where something has to give it goes whole, and what gives instead is the name, which wraps. A finished list stays in view, dimmed, for a beat of its own and then leaves, and the panel goes once the row is empty; a beat each rather than one for the panel, because the row is the work left and a list waiting for the last of its neighbours to end would be saying it still had something to do. While it is kept, its heading names the state its own list ended in rather than wherever the entity has gone since. A list the run abandoned is not a finished one and leaves at once: a cancel out of Finalizing is the one change of phase with no idle gap for the panel to empty in, every member going straight from its finalizing list to its cleaning one, and without that distinction the abandoned list would sit beside the list that replaced it, pairing the member's new state with the old list and showing an action as running that nothing is running. A blocked list is a running one, so nothing drops it for the action that blocked it. It has no close: its header has the grammar every dialog's header has, the name, the kind and the control at the right end, and that control collapses it in place, leaving the header row where it is while the lists run at their own pace; a click on it again, or a list of a new kind starting, the next phase of the run, opens it. What an operator can do to an action lives in it, and only where the state allows it: force passed and reset on a failed action, where a sign-off nobody has given is recorded as failed so that forcing it passed is the sign-off, and is drawn as a wait, amber, rather than as a problem; with a list by hand the run of the next action and, for a sign-off, force passed before it has failed. The two backward edges of DX-ADR-005 are the workgraph's and live on its machine as buttons on the edge they take, offered only once something is blocked, since an operator acts on a blocked state and nothing else: scout further while ApprovingBlocked, the Approving → Scouting edge, on which the sample grows and the scout carries on keeping everything it has produced, and back to Active while a member is FinalizingBlocked, the Finalizing → Active edge, on which every member returns to Active and its finalizing results are reset, for a finalisation an operator cannot recover in place; the panel's footer repeats them, since the blocked list is where the reader is looking. While the lists run by themselves the panel offers nothing, so it never reads as a prompt.
The walkthrough
With HandleFailedInput by hand, something is regularly waiting for the reader: an input in Failed that HandleFailedInput has not decided. The engine's pending() lists what waits in order, the failed inputs of each member whose HandleFailedInput is by hand and then one action per entity whose actions are, and the walkthrough takes the inputs: a dialog opens on the first the moment anything new waits, unless another dialog is open. An action to run is the actions panel's, not a step here. The model holds while it is open, as it does for every dialog, so the reader takes one step with nothing moving underneath. Its header counts the queue, 1 of 4 waiting, and its footer offers decide later, which puts the step off and moves to the next, and decide later & pause, which closes the dialog and pauses the model. A step put off stays pending and is counted; the chip beside expert says how many wait, and opening the dialog from it shows the put-off steps again.
An input's step is one line of fact, the member, the parcel that failed and the input's failures against the retries the hook allows, then the input's record, then the three states the hook can send it to, Unassigned, Split and Problematic, each naming what it means. The hook's own decision, drawn when the parcel fails so that a run is the same whether the hook sweeps or is run by hand, is preselected and marked default; apply on that row is the hook's run, and on any other row the operator deciding in its place, which the log says. An input already down to one section cannot be split, and its Split row says so. No paragraph explains any of this until what is this? is opened. The dialog is as wide as its content and no wider, so the strip's geometry stays legible. A run by hand, from the actions panel or a rail, settles the action as a sweep would.
Lineage
One picture serves the failed-input step and the lineage dialog a click on any file opens: provenance on the left, the focus in the centre with a ring, what it became on the right, files as shapes and parcels as boxes with failed attempts greyed. A box is a fixed 112 units and its member's name is fitted to it rather than the other way round: the strip lays its generations out on a fixed pitch, so a box grown to its content would push its generation into the next one. The name is cut with an ellipsis where it has to be, by the same fitTitle a card's heading uses, given the lineage's own advance per character — 9.5px IBM Plex Sans regular, where the heading is 11px semibold, so measuring by the heading's would cut a name several characters before it needed to. A cut name needs no title of its own: the group already carries one naming the parcel, its member, its status and its inputs. The line beneath, the tag and the status, is left whole — the statuses are a closed set and the longest of them reaches 86 of the 112, which the checks hold against the enum so that a state named longer would be caught rather than drawn over the edge. A box says in its shape whether its member computed something or moved data — a stadium for data management, the rounded rectangle for compute — because shape is the one channel on that box nothing else is using: the stroke is the parcel’s status, the dashes a recovery parcel, and the fill and the dimmed label a failure, so a greyed removal would read as a removal that failed. Shape and stroke stay independent, and the checks hold them so: a finished removal is a green stadium and a failed one a dim dashed stadium, neither mistakable for a compute parcel in the same state. Two kinds and not three — the model names replication and removal apart, but one data-management transformation may do several operations at once, and the picture should not rule on which it mostly was. The class is the member’s own kind, the one a card already wears, so the two surfaces share a vocabulary rather than growing a second, and the card’s red for a removal is the other statement, that this member destroys data; the two sit on top of each other without either having to mean the other. The engine's lineageAround walks provenance up only and consumers down only, by generation, a parcel and its files each, so the two sides never mix. In the failed-input step the picture is drawn one generation of provenance deep and the right side is the input's own record: every parcel it has been in, its parent's before its own where it is the child of a split (ParentInputID, DX-ADR-004), stacked oldest at the top and numbered, each with its model time, the newest at full contrast with a solid red border and the earlier ones dimmed. Past four the rest fold into +N earlier, which unfolds in place. That record is what the failure count in the fact line rests on, and full lineage opens the dialog on the same file, three generations deep, where a click on a file moves the focus. The lineage dialog has the shape every entity's dialog has: a header band naming the file, its kind and where it came from, the round close, the picture, a band saying where the file stands as an input, and its explanation folded behind what is this? as the failed-input step folds its own.
The header names what the thing is rather than what it is made of: a file some transformation holds as an input is an input, one no transformation has taken is a file, which is what a dataset nothing consumes stays, and the artifact port is its own. The band under the picture is the rest of that answer, and the one thing the picture cannot say: a row per input made from the file, since a file becomes one row of a transformation's input table per input (DX-ADR-004) and a file cut into smaller inputs is several, each naming the sections it covers. A row holds the member, the input's state in the counts' colours, and the member's name opens that member's input machine, where the state and its edges are — the picture says what became of the file, and only the machine says where it stands. The lineage dialog gives way to it rather than sitting behind it, so the machine is where the reader ends up and not a dialog deep.
The operator's edges are on those rows too, and this is the one place they act on a single input: the machines offer reset and write off over every input in a state, because that is the scale an operator works at when a run has gone wrong, and a reader looking at one file wants that file moved and not its neighbours with it. OPERATOR_EDGES in the engine is which states an operator may move an input out of and where to, and the actions over a whole state, the action over one input and every button that offers one all read it, so none of them can come to offer an edge another does not. The rule the machines keep holds here: a verb, only while the edge is legal, never disabled. A row whose input is in any other state carries no button.
Help mode
The ? chip is a toggle, and it does two things. It puts a mark at a corner of every region of the picture that is on screen, and it opens a small panel of what a change to the picture cannot invalidate: what the simulator is and that it reaches no real DIRAC, the keys, the eight nouns of the domain a clause each, and the two ADRs the whole thing rests on. A mark opens that region's own disclosure, anchored to it, and the panel gives way to it: the panel sits under the chip and a disclosure over the region it explains, and a widget the width of a documentation column has room for one of them at a time. The marks stay while a disclosure is open, so one can be closed and the next opened without pressing ? again, and the mode ends on ?, on Esc, or on the playback: the model running again is the reader done with the marks. No click ends it, wherever it lands. Neither the panel nor a disclosure is a dialog, so a disclosure never has to be dismissed before the next is opened and nothing the model does clears the mode. The model holds while the mode is on, as it does while a dialog is open: a mark sits over the region it explains, and a region that has moved is one the mark is no longer over. The reader's own pause is untouched, so what they paused stays paused when the mode ends.
Help marks one surface at a time. The picture is one, and a dialog is another while it is open, since it covers the picture and has the reader with it. Each surface carries its own ?, the toolbar's and the dialog's, so the one in front of the reader is the one that works, and its own layer of marks. Opening a dialog with the mode on moves the marks into it and closing it brings them back; the ? panel belongs to the picture, so a dialog's ? marks and says nothing else. The steps, files and lineage dialogs disclose nothing of their own, each already carrying a paragraph that says what it is, so while one of them is open nothing is marked at all.
Nine regions of the picture carry a disclosure: the state strip, the state line, a card's pool, its slot grid, its rail, its bottom edge, the +N on an edge, a datasets box and the event log. Nine more are in the transformation and workgraph dialog: the machine tabs, the machine, the inputs, the parcels, the members, the feeder, the packer, the hooks and the action lists, of which each shape of the dialog draws the ones it has. The dialog's regions name themselves with data-region, which is what its anchors aim at, and its marks straddle the left corner of a region, since the right one carries a status or a count. Each disclosure is one to three sentences, a legend where its region carries a visual encoding that nothing beside it already names, and links to the state machine it belongs to and the ADR that decides it; a region that is itself a machine links to no machine. A disclosure explains its own region and nothing else, which is what stops the set of them turning back into one page of everything.
Two rules keep them from rotting, and both are checked.
The first is where they live. A disclosure is registered with disclose(key, …) beside the function that draws its region, never in a file of its own, so a change to a region and the change to its explanation land in the same diff. help.test.js asserts the distance between the two in the source and fails when a disclosure drifts away from its drawing.
The second is that no legend is written by hand. Each row draws its swatch by calling the same function that draws the real element, over the same state → style map the drawing reads: slotSwatch for a parcel's box, poolEntrySvg for a cell of the pool, barSegmentSvg for a segment of the bottom edge, statePill for a pill of the strip, machineBoxSvg for a box of a machine, logRowHtml for a line of the log. A swatch therefore cannot come to disagree with what it describes, and a state added to an enum appears in its legend by itself. The checks assert both halves over the parcel, input, transformation and workgraph enums: every state has a style entry and a label, and every style entry belongs to a state something can still reach. A state added without a legend entry fails the build.
The marks are drawn on a layer of their own, position: absolute over the widget and empty while help is off. Nothing is reserved for them and no renderer emits one, so turning the mode on and off moves no pixel of anything underneath.
The panel holds nothing that says where something sits, what colour it is or what shape. That is the line: a sentence that would need editing because a node moved belongs in that node's disclosure, and help.test.js scans the panel for positional words and for the vocabulary of encodings. What an operator can do is not written down here either — it is on the buttons of the machines, whose tooltips say what becomes of the work in flight, and maintaining that in two places is exactly how the page this replaced went stale.
Maximised
maximise, beside the ?, gives the model the window. It is not the browser's own fullscreen: the widget takes position: fixed over the page, its header included, so the browser's chrome stays where the reader expects it and M or Esc brings the page back.
The keys the widget answers to are the bare ones and nothing else. A click on a card leaves the focus inside the widget for the rest of the page's life, so a key held with Cmd, Ctrl or Alt is left unread and unprevented and stays the browser's: Cmd+C copies, Cmd+R reloads, Cmd+3 goes to the third tab. Shift is not one of those, since R, C, E and M are bound in upper case deliberately, and a field being typed in keeps every key of its own.
The band grows a lockup at its left end, maximised only: the mark, Workgraph simulator, and the model's own name, then the divider the band already uses between groups. Its grammar is that the mark says the product and the words say the surface, so neither half spells DiracX out again; the playground's toolbar wears the same lockup with Playground for the words, and the shape of it is declared once, beside the tokens the two stylesheets share, for the reason their own comment gives. The window covers the site header, the page's heading and the model's name with them, and nothing else in the widget says which model is running until a dialog is opened. In the page it would be saying it twice, and the band there is out of room already — the chips drop the words expert and chaos at narrow widths rather than grow a badge. The mark stays off the canvas: its two hues mean nothing on a surface where green, red, amber, blue and the accent each mean something, and help mode teaches exactly those.
Fixed is the window only while nothing above the widget is a containing block, and a transform, a filter or a contain anywhere in the page boxes it into that ancestor instead — the playground dims a stale panel with a filter, which is exactly that. So for as long as it is maximised the widget moves to the body, where nothing stands above it but the document, and a spacer of its height holds its place in the flow, so nothing behind reflows and the reader lands where they were. The document's own scrolling is held meanwhile, since a scrollbar is drawn over a fixed layer however deep it sits and scrolling under the window would put the reader somewhere else. In the column the log wraps a line rather than cutting it, and takes the shape described under the event log: the band it came from was wide and short, and the column is narrow and long.
The dialogs widen with it. The panel is 980px in the page, which draws the machine's 1060-unit canvas at nine tenths of its own scale; maximised it is min(1440px, 90vw), which takes the machine to about a third again. The width is what makes a machine legible, the machine being drawn in a viewBox and following it. The type is the same in both, the dialog setting --wg-t back on itself: what a dialog says is read at one size wherever it is opened, and the room the strip's four panes gave back is what let that size be the size it is.
The band carries the colour scheme switch for as long as it covers the page's own. It is that switch it presses, found wherever the page keeps it — the theme's header, or the playground's toolbar, which is the same markup moved there for the same reason — so the scheme stays one decision, written to the theme's own storage and read by every page after it. The button wears the switch's icon and title, and a page that carries no switch shows no button.
The picture is fitted to the box the band and the log leave it, by width or by height, whichever runs out first, and the scaling is the viewBox's own: a label, a card and an edge are all drawn in its units and grow with it. Nothing is laid out again — the layout depends on the spec alone — so maximising costs one reflow and no call to elkjs. A window is wide where these graphs are tall, so from 900px the log stands beside the picture rather than under it: the height it gives back is the picture's scale, and the width it takes was empty beside a graph that runs downwards. The chrome around the picture is drawn in pixels and does not follow it, so the band, its controls and the log go up a step with --wg-t, which is the one declaration a density mode is.
The playground
The playground is the same widget with a CWL document in front of it: docs/assets/js/workgraph-cwl.js compiles a workgraph document (DX-ADR-007) into the spec above, and docs/assets/js/playground.js is the page around the two. Both scripts are on every page of the site and do nothing at all until they find a workgraph block or the playground's mount div.
The page is not shaped like a page of the site. docs/overrides/playground.html leaves out the banner, the header and the tabs, since the editor, the diagnostics and the workgraph running beside them want the window and the theme's chrome is a third of a phone's; the way back to the documentation is the link at the right of the toolbar. The same is done again in CSS, keyed on the body class the playground sets while it is mounted, because instant loading swaps a page's content without swapping the chrome above it, so a reader arriving from another page would otherwise keep that page's.
What the chrome carried comes back in the toolbar. The page names itself at the left with the lockup the maximised widget wears, the mark and Playground, the way to the documentation is a link at the right, and the theme's palette switch sits between them: the template renders the theme's own markup for it and the page drives it, since the theme binds the palette it finds inside its header. What the switch writes is the theme's __palette entry, so the scheme a reader picks in either place is the one the other opens in. Narrow, where the toolbar sheds its controls' words, the lockup drops its own and keeps the mark: 20px is the most compact identity there is, and it is the half that still says what the page is. A workgraph waits in New once it is built, as every model the documentation embeds does.
The toolbar's line, at the right, says one thing at a time and only while it is true. A condition — 2 errors: waiting for a document that compiles — stands exactly as long as the condition and goes when the errors do. A confirmation, that a link is on the clipboard or in the address bar, goes after a beat or at the reader's next keystroke. A routine event is not written there at all: a rebuild changed the picture under the reader, and a model in New glows on its own playback control until it is started, which is the affordance, on the button rather than at the far end of the toolbar from it. What a reader who is not watching the page would lose that way is announced instead, in a live region out of sight — workgraph rebuilt, what happened rather than what to press. The line is not there while there is nothing to say, which is the state line's rule.
The workgraph has the right pane to itself. What was parsed and Run settings are a report on the document and a form for the run, not two more workgraphs, and a strip of three tabs said they were three peers and put both diagnostics above the thing they describe — readers in a test went into them first and came out confused. Each is a drawer now, shut until it is asked for, at the foot of the pane it belongs to: the parse report under the editor, beside the problems strip that already answers for that document, and the settings under the picture they govern. The two are kept apart from each other for the reason the settings note itself gives — a form and a parse report behind one label would teach the very thing that note exists to prevent. A reader lands on the workgraph every time: what the drawers remember is that they were closed.
Beside them is the dialog that holds what the page is and every example it ships. What the page is, including that it reaches no real DiracX, is the top of it; under that are the examples — the families down the left, a workgraph and then what is added to it, with LHCb's own productions last, and the documents of the chosen family down the right. Picking one opens it in the editor and closes the dialog, and the example the editor still holds is marked until the first keystroke of the reader's own. The dialog opens by itself on a first visit and never again on its own; someone arriving on a link to a document is not shown it at all, having come for the document.
Reset playground, the toolbar's other button, is what opens it again. It forgets the document, the settings, the layout and the having-been-here that this browser is holding, and leaves the page as a first visit finds it, which is with that dialog open over the first example; a document the reader wrote themselves is asked about first, since the browser holds the only copy of it.
compile(document, settings) returns {spec, problems, ignored, sandbox, notes}. It takes a parsed object rather than text, so it needs no YAML dependency and the checks can drive it directly. problems carry a path such as steps.reco.in rather than a line, which locate(text, path) turns back into one; the compiler never sees the text. What it could not carry across is prose rather than silence: ignored holds the run bodies it did not follow, the job parameters and the requirements, sandbox the output-sandbox globs, which are a job-wrapper side channel and never dataflow, and notes every pool it clamped and every feeder ceiling it lifted clear of what the packers below hold back, since a real request is for a billion events and a page can draw a few hundred.
The second argument is what is not in the document and cannot be: job durations, failure and partial-outcome rates, the files a query returns, the parcels a transformation runs at once, the pool ceiling and the seed. They come from the run-settings drawer under the picture. A dirac: field carrying any of them would break the property the page exists to demonstrate, which is that what is being edited would really submit, so compile reads none of them from the document and cwl.test.js holds it to that.
checkSchema(document, schema) is a separate pass against docs/schemas/dirac-1.0.json, the hint vocabulary, which is provisional and kept in step with the worked examples rather than settled: it answers whether the document is written in the vocabulary, where compile answers what the picture should be. containerAt, pathAt, schemaAt and suggestAt work over the text as it is being typed, which is what the editor's completion and hover read. The editor is Monaco, fetched from a CDN on this page alone, with a textarea as the fallback; the compiler, the simulator, the diagnostics and the schema are all local, so with the network blocked only the editing experience changes.
What the checks hold
A machine claims to show the model: its boxes are states and its edges are transitions. Nothing enforced that claim, so a
count the engine kept and no edge drew was a picture confidently saying an edge had never been taken. invariants.test.js
reconciles the two. It drives every operator action from every state DX-ADR-005 allows it in, collects every transition the
engine recorded, and fails on any the machine that owns it has no edge for. A blocked counterpart has no edge of its own
beyond the pair it shares with its partner, so a transition out of one is drawn on the partner's edge; which off-path states
those are is declared on the machine's own spec, as blocked: true, and one fold reads it for both machines.
The same file holds the pairs that cannot be checked by eye: that every data-act the markup offers is an act the widget
handles, that a constant the renderer places agrees with the one the stylesheet sizes it by, that a dialog's own width
outranks the cap every dialog shares, that the lineage dialog offers an operator exactly the edges OPERATOR_EDGES allows
out of the state each of its inputs is in, and that taking one moves that input and leaves the rest of its state alone, that no selector matches nothing and no class is emitted for nobody, that a
hand-placed machine crosses only the one edge it means to and runs no edge alongside another close enough to read as one
line, that every box, pill and label a machine places lands on the canvas rather than past its edge where it is clipped
away, and that the engine names no colour, escapes no HTML and never asks whether it is running in a document.
Every model the documentation ships is run to Completed, in docs.test.js, so the harness stands in for an operator
wherever one is needed: it writes off each quarantine once that is all a member waits on, presses start on each member held
back for an operator, and gives the sign-off, which the engine's approval: "auto" does for it on the specs the checks
build — the one switch of the model no block may set. Each model is run that way under four seeds. The pages pin no seed and
the widget gives each load a random one, so a model that completes only under the seed the checks happen to take is a model
a reload would leave stuck, and a failure names the seed along with the members that never drained and what they were still
waiting on. The step ceiling a run is given is one only a hang reaches, since what the check asserts is that the workgraph
terminates; a budget fitted to what a run takes today reports a shift in the data as a broken model.
The event log
Under the picture the log holds every event of the run, oldest at the top: its number, the entity it is about and what happened. It follows its newest line until the reader scrolls up, and a ↓ latest pill in the corner follows it again. Nothing is dropped — a run ends, and the noisiest model the documentation ships makes a couple of hundred lines — so a line's number is the event's number and stays that. The renderer appends the lines it has not drawn rather than drawing the log again, so an event costs one row however long the run is.
The time is not a column but a divider: the label, then a hairline rule across the rest of the width, heading the lines that happened at it. The rule is what makes it a break — the transition rows are full-bleed tinted, and an unpainted gap between them reads as a hole torn in a stripe — and it goes after the label rather than along the top of the row, where half the time it would land under a band's own edge and read as a double one. A sparse stretch gives one divider per line, which is the price of appending: holding a bucket back a frame to know its size first would buy alignment and cost the property the whole log rests on. It is not a line of the log — a line filters the log to its subject, and a time is not a subject — so it is inert.
Three levels of emphasis, so that a run can be read from the log alone. A transition of the workgraph or of a member is the skeleton of the run and carries the only background there is; it is drawn entity → Paused with the cause muted after it, the engine recording the state and the cause as fields of their own. The state is drawn as the card's badge is, not as the strip's pill: the state's own colour out of one tone map the stylesheet keeps as --wg-state, which the badge reads as a fill and the chip as a colour, so the two cannot come to say different things about one state. The chip takes the row's own rhythm — inline, square, a hairline and no fill — since the strip's geometry is a control in a row of eight where a line of a log is a record, and the row's tint already says "transition". A blocked state covers two situations, so the engine records at the transition which it was: a sign-off nobody has given is a wait, amber, where any other failed action is a problem, red. Work that finished is the body of the log, with the state it ended on in that state's colour — the other question, not where the run got to but how a piece of work ended. A moment its own result supersedes a beat later — running clean intermediates — is barely there.
In the maximised column the log changes shape. The entity, the arrow, the state and the message flow as one sentence that wraps under itself rather than sitting in columns, and the entity is told apart by how it is drawn; the band keeps its columns, being 11em tall and one line per event, where the alignment is what makes it scannable. Scanning down for one entity is what the flow gives up, and a click on a line, or on a card, answers that better anyway. The event number goes as well, the width being worth more there than the number is, and the band still carries it. A row pinned to the foot of the column carries the elapsed time — which the widget shows nowhere else — and the phase, so the timeline above it is anchored; it sits outside the list, so it never touches the append path or the ↓ latest pill, and in the band there is no room for it. The phase there is the strip's pill in the now tone, where a line's state is the card's badge: the foot is the present and every line is the past.
The foot under the log belongs to the widget and not to the model. It carries what the last press of step moved, and it is not written into the log itself: the log is the model’s record, and a line that exists because of how a reader watched would leave two readers of one seed holding different logs of it — and would break the thing the slices were chosen to keep, that a run stepped and a run played are the same run. It sits outside the list for the reason the clock does, never touching the append path, the follow-the-newest test or the pill, and it is a live region, a press being the one control here that announces nothing by moving. Its line and a half comes out of the band rather than off the widget, so pressing step moves nothing below the log, and the band has it back the moment the model runs again.
The log and the picture are the same thing seen twice: hovering a line lights the card it names, and clicking one shows only that subject's lines, with a chip in the corner to show them all again. A click anywhere on a card opens that transformation's dialog, and pointing at one lights it the way a hovered line of the log does, since that is the same statement made from the other side; the glow says it and never the stroke, which is already carrying the transformation's state. Nothing inside a card outlines itself unless it does something the card does not: a parcel waiting in the pool and the backlog chip over the grid's last corner open the parcels on their own tab, a file shape anywhere opens that file's lineage, and the rail's rows switch a sweep. The pool had an outline of its own and opened the dialog the card opens, which is an affordance promising something it did not have.
A frame runs twenty times a second and the engine is not what it costs: the largest model the documentation ships steps in 0.035 ms, and everything else a frame does is drawing. So each dynamic region of the picture is written only when the string it would write has changed, and the one region that changes every frame — the files in flight, four hundred and fifty-six of them at once on ancestry.md — is not written at all: it holds an element per file and the frame moves it, from a point taken off the path's length, which is measured once when the scene is built. Nothing in a frame reads the layout back after writing to it; the actions row's width, the one thing that needed asking, is watched instead. invariants.test.js holds all three, since none of them shows in a diff.
The engine is exposed as window.WorkgraphSimEngine with Sim and the vocabularies it shares with the renderer, and the renderer as window.WorkgraphSim with layout, mount, mountSpec and the panel functions the checks drive. mount reads a block's text; mountSpec(element, spec) takes a spec already in hand, which is how the playground mounts what it has compiled, and widget.dispose() takes a model back off the page, which is what a rebuild does first. Sim needs no DOM: call start(), then step(seconds). layout returns a promise, and needs elkjs loaded first; tests/workgraph-sim/elk-shim.js is what does that for the checks, under node and under the JavaScriptCore shell alike.