Skip to content

UI conventions

The web UI is a single-page bundle served by the API server itself, from packages/dirigent-server/frontend. This page is authoritative for how it is built; where the code disagrees with this document, one of the two is a bug.

Nothing here is about what the screens show. That is decided per screen, from a design board, and each one arrives as its own change. What follows is the frame every screen is built inside.

Every UI change is walked against this page in the live browser before its PR merges, which is what the ui-review skill does. Build the branch first -- make ui-static, because a checkout's dg dev serves the packaged bundle where there is one -- then open every screen the diff touches in the dark mode and again in the light one, at 390x844 as well as at a desk width: the finger rule, a listing's card form and the panel sheet exist only below the breakpoint. Screenshots go under .playwright-mcp/review/, which is never committed, and the PR body says the pass ran and what it changed. A change to a token is read in all three palettes. A convention that proves wrong is changed on this page first and in the code second.

The stack

Piece What Why it and not the obvious alternative
Build vite 8, TypeScript 7 with project references tsc -b gates the build, because vite strips types without checking them
UI React 19, react-router 7 with BrowserRouter The server answers a navigation nothing claimed with the shell, so paths stay clean and a deep link can be pasted
Styling Tailwind v4, CSS-first There is no tailwind.config.js and there must not be one -- the theme lives in src/index.css under @theme
Components shadcn on Base UI (@base-ui/react), style base-nova The generated files in src/components/ui/ are pristine and never hand-edited
Graphs @xyflow/react for the canvas, elkjs for the layout React Flow draws what it is given and decides no geometry; elk's layered algorithm is what places a DAG. React Flow rides in the graph screens' chunk and elk is a chunk of its own, in a worker -- see below
Editor monaco-editor with monaco-yaml A pipeline document is YAML checked against a schema, and squiggling an unknown key where an apply would refuse it needs a language server rather than a textarea. Its own lazy chunk, behind CodePane, which is what every screen that writes source mounts -- a document, and a config field whose schema says it carries a program
YAML yaml A document crosses the wire as JSON and is written by people as text, and lib/pipeline-document is the one place it is parsed and rendered. Nothing in the entry chunk reaches that module, so the parser rides in the chunks of the screens that read a document
Markdown marked, for its lexer only A description is authored markdown and has to render as prose. Nothing here produces an HTML string, so what is drawn is a token tree React escapes -- see lib/markdown
Palette cmdk
Toasts sonner
Mode next-themes, class strategy
Fonts IBM Plex Sans and JetBrains Mono, self-hosted through @fontsource No CDN: a UI served from a private network has to work on one
Lint oxlint, plus scripts/check_ui_classes.py and scripts/check_ui_system.py A JS linter cannot see inside a className, so the type scale and the design system get checks of their own
Format oxfmt, pinned exactly, configured in .oxfmtrc.json Prettier's output from the linter's own project, and it sorts Tailwind classes against src/index.css rather than guessing at the theme
Tests vitest in environment: node, playwright for the browser
Packages bun, always. Never npm, yarn, or pnpm

Base UI is the primitive layer, and app code never imports it. Pages and components import from @/components/ui/* and nothing else reaches for @base-ui/react directly. That boundary is what lets the component layer be swapped without a change anywhere above it, and other apps in this family are on a different primitive underneath the same imports.

A screen whose dependencies no other screen has is a lazy route. src/App.tsx reaches it through React.lazy and a Suspense whose fallback is the same PageState loading card every read shows. The entry chunk is what every reader pays for on every screen, so it holds the login screen, the two listings this app is mostly read through -- pipelines and runs -- and the 404, and everything else is fetched when it is opened: run detail, a pipeline's page and the pipeline editor, because React Flow is the canvas nothing else draws on; the dashboard at /, which composes a screenful of reads nothing else composes; connections, triggers, schemas, blocks and examples, each carrying the forms, the dialogs and the panels nothing else uses; and the three admin screens, which most readers of this app are never offered.

A chunk inside a lazy route is the same rule again. Monaco is larger than everything else this app depends on, and every pane in this app that writes source or windows it mounts components/pipeline/CodePane: the editor's source pane, a step config field whose schema published a contentMediaType, a schema document, an alert rule's expression, an example's text, and the read-only window a produced value is opened in. CodePane is the one React.lazy reaching CodeEditor, so opening a pipeline does not fetch an editor and opening its source tab or a jq step does. monaco-editor as a whole registers every language it ships, so what is imported by path is the api entry, the suggest controller a schema-checked buffer cannot do without, and the languages a field can carry -- YAML, shell, twig, SQL and JSON. jq, FHIRPath and CQL have no monaco grammar, so lib/program-grammars writes each as a Monarch tokeniser and CodeEditor registers them. Its three web workers are ?worker imports, which vite emits as chunks of their own.

An engine is fetched when the screen that needs it mounts, and not with the screen's code. elk is the second of them: components/graph/elk is the one dynamic import of components/graph/elk-engine, so the graph screens' chunk carries React Flow and the canvas, and the 1.4 MB of layout algorithm arrives only once a canvas asks for geometry. It arrives in a web worker -- elkjs/lib/elk-api wrapping elkjs/lib/elk-worker.min.js -- so placing a fifty-step graph is not work the thread that draws is blocked on. Nothing waits on it to draw either: placedByRank places the ranks by hand, wrapped onto rows at the same WRAP_WIDTH elk is handed, and that is what a canvas shows until elk answers and what it keeps showing if elk never does.

A chunk two screens want is fetched before either is asked for. warmEditor and warmLayout are those same imports, fired by the shell once there is a session and the browser is idle -- lib/idle is requestIdleCallback where there is one and a timer where there is not -- so the first source tab, jq step or output window of a session opens against a chunk that has already landed, and the first canvas is placed by a worker that is already running. Nothing waits on either, each happens once, and the login screen is outside the shell and asks for none of it.

build.chunkSizeWarningLimit clears monaco and nothing else. Monaco's chunk is the one this bundle has that is over vite's default 500 kB; elk's worker is larger still and is not a chunk at all. Every other chunk, the entry included, stays far under the limit, so the warning still means something when one of them grows.

oxfmt owns whitespace, quotes, semicolons and the order of Tailwind classes, at four spaces, single quotes outside JSX, no semicolons and a print width of 110. Nothing about that is worth an opinion in review: run make ui-fmt. The generated files in src/components/ui/ and components.json are ignored, as they are in oxlint. make ui-lint runs the check first, so an unformatted file fails the gate before anything else is read.

make ui builds the bundle, make ui-dev serves it with hot reload against a running dg dev, make ui-lint and make ui-test are the gate, make ui-e2e drives a real server in a browser and make ui-shots photographs every screen for the gallery. make docs-shots is the other camera: nine screens, the dirigent palette in dark mode at 1440x900, shot against the showcase shelf and committed under docs/images/screens/ because Screens renders them. make ui-static puts the built bundle where the server wheel packages it, and it comes before every e2e run and every live review, because that packaged copy is what a checkout's dg dev serves. The gate is wired into make check behind a check for bun, so a machine without bun gets a loud skip rather than a failure.

Five type sizes, and the named sizes outside them

The scale is 12px, 13px, 14px, 15px and 26px. Nothing arrives beside it without a name. The faces stay IBM Plex Sans and JetBrains Mono; what moved is that a page now has a heading somebody can find from across the room, and that the working size of a dense screen is a step under its prose.

Class Size What wears it
text-xs 12px Codes in mono, chips, counts, timestamps, a status word -- anything read at a glance rather than as prose
text-sm 13px The working size: a control's word, a rail entry, a row's description, a fact, a table cell
text-base 14px Prose: a description's body, a failure's message, a dialog's sentence, the command palette's search field
text-title 15px A section heading and a listing row's title, at weight 600
text-page 26px A page heading, at weight 500, and nothing else on any screen
text-stat 28px mono A stat's number, and nothing else on any screen
text-wordmark 48px The login brand lockup's wordmark, and nothing else on any screen
text-lockup 20px The rail's wordmark beside the mark, at weight 600, and nothing else on any screen
text-display 36px The sign-in heading, and nothing else on any screen
--text-graph-title 12px A step's title on a graph, in mono at weight 500, and nothing else on any screen
--text-graph-small 11px Every other word drawn on a graph: the block or what a source listens to, a badge, a count, a duration, a fan-out's strip, words standing over an empty canvas

Mono is for what is typed somewhere else: a code, a step's key, a block, a kind, a duration, a log line, a JSON value. A title, a heading and a sentence are in the sans face, and a code inside a sentence is mono at the sentence's own size through text-[0.88em].

The wordmark is a brand mark rather than text: the login screen draws the product's name at display size beside its tile, and no other screen may set a heading there.

A graph has two sizes of its own, because it is drawn at the scale of a diagram rather than of a page. They are set on .step-node and .graph-words in index.css rather than on each word, so a word added to a box inherits its size and no word on a canvas can come out at the page's.

scripts/check_ui_classes.py fails the gate on anything outside them, whether the sixth size arrives as text-[17px] or as text-lg. The check has no allowlist -- it knows text-title, text-page, text-stat, text-wordmark, text-lockup, text-display and the two graph sizes by name, and reads index.css itself so another size cannot be minted there either. A named exception can be counted; an allowlist is a check people argue with. It skips src/components/ui/, because those files are generated and regenerating them is how they are fixed. A size one of them carries is said back onto the scale in index.css, against its slot: the small button's 0.8rem is text-sm.

A relative size composes with the scale rather than escaping it, so text-[0.85em] on code inside a sentence is deliberate and passes.

Tokens

Everything colour is a CSS custom property in src/index.css, exposed to Tailwind through @theme inline. Nothing anywhere else may write a colour.

Two theme axes, kept apart. The mode is light or dark: next-themes owns it, writes it as a dark class on <html>, and follows the operating system until somebody says otherwise. The palette is which set of colours the app spends inside that mode: src/lib/theme.ts owns it, writes it as data-theme on the same element, and a small script, public/palette.js, applies it before the first paint so there is no flash. Neither axis knows about the other.

A palette is two blocks of CSS and one row, and nothing else in the app learns it exists. The blocks are html[data-theme='<name>'] and html.dark[data-theme='<name>'] in index.css, beside the base ones; the row is in PALETTES in src/lib/theme.ts, carrying the name and the label. Each block answers to [data-palette='<name>'] as well, base palette included, so an element inside the app can be painted in a palette the document is not wearing -- which is what a swatch card is, and why a swatch cannot drift from the tokens it stands for. A test asserts the pre-paint script's list and that array agree. Because a palette's selector outranks .dark, a token the base sets in both :root and .dark has to be set in both halves of a palette that touches it at all -- a token set in the light half alone leaks into the dark mode. A token a palette does not touch falls through to the base, which is how a palette that only moves its surfaces keeps the status and kind hues.

Both axes are chosen on the settings dialog's Theme pane. Appearance is Light, Dark and System, one segmented control rather than a menu, because a choice from a fixed set of three is one control and not a list to open. Palette is three swatch cards, because what a palette is is what it looks like, and a card showing the ground, the surface, the line, the ink and the accent says in one glance what a sentence under a segmented control took a paragraph to approximate -- so no palette carries a line about itself any more. Neither row holds a copy of what it sets -- Appearance writes next-themes, Palette writes choosePalette, and both read back through the store. The mode is also the account menu's Appearance submenu, the same three writing the same store, because it is the one look somebody changes in passing; the palette's Appearance rows are the third door to it. The top strip carries no mode flip: the account menu stands on every screen, and a control said twice on one screen has to earn the second time, which a two-way flip that cannot reach System does not.

Three palettes ship, ordered quiet to loud: dirigent, paper, contrast. The default is the Flow first palette: neutral blue-grey surfaces, softly raised, in a dark and a light mode; paper is the same app on warm paper with warm ink; contrast is white on black and black on white with every line and every ink pushed to meet 4.5:1. The amber is the brand in every one of them, and a palette moves its lightness only where its own ground needs it. The surface ladder keeps its order and roughly its steps in every palette, so a component designed against the rungs still reads -- except where a palette's whole point is the ground it commits to, and contrast collapses page and card onto one white and one black, drawing every card by its edge instead of its shadow.

Every section of a page is a headed panel. components/ds/Panel is the one surface a page draws: a title bar on --card with the section's label at the left and its count or a fact about it at the right in mono, a hairline in --panel-line under the bar, and the body on --panel-ground, a rung off the bar, edged all round in the same hairline and lifted by no shadow. A panel holding a graph has a faint dot grid on its body in --panel-dot, because a graph is drawn on a ground and not on a card. A failure is the one panel that says so: a rule in the failed ink along its top and the warning mark beside its title, which is the only colour a panel ever carries. Nothing draws a left border in a status colour, and nothing on a screen styles a surface of its own: a treatment is changed in Panel and in the .dg-panel rules in index.css, and the generated card follows the same rules.

Every panel's title bar is one fixed height, so bars side by side line up. The bar is --spacing-panel-head tall -- 40px, and 46px below the breakpoint, where a control in it is a finger tall -- with no vertical padding: its label, its count and whatever it carries are centred in that height, so two panels standing beside each other put their labels on one baseline. A control in a bar is compact, xs or sm, and fits the bar rather than growing it. A failure's rule along the top is drawn inside the panel's edge, over the bar, and adds no height. The run terminal's toolbar is the one bar that may wrap onto a second line, and only below the breakpoint, because it stands alone and carries filters rather than a label.

A listing is a headed panel too. ListTable draws the .dg-panel edge and ground: its row of column headings is the panel's bar, on --card over the hairline, and its rows stand on the panel ground with a --border hairline between them and no stripe. Given a title it carries the panel's own title bar above the headings, with the count where the line along the foot would be, so a screen with two listings -- Triggers' sources and schedules, the catalog's families -- is two panels rather than two headings over two cards.

Inside a panel or a dialog there are three boxes, and each is drawn once. A Well is a group that belongs together -- a form opened under its row, a minted secret, a sample -- on the panel ground, edged in --panel-line. An Inset is reference material -- a block of JSON, a code block in a description, a log -- on the page's own ground, edged the same way, so a preview reads as cut into the surface it is on. A code editor is a field, so CodeFrame edges it the way every field is edged -- the --input hairline at the control's corner -- around the editor's own ground, and WindowedPane puts every pane that opens in a window inside one. A box that says how something is going is a Notice, and a refusal off the wire is Refusal. No screen types a border, a corner and a ground onto a div of its own: the boxes are .dg-well, .dg-inset and .dg-frame in index.css, beside .dg-panel.

The rest of the ladder. --sidebar is the rail, a rung under --background, the page. --secondary is raised: a button's fill, a chip, the active rail entry; --popover is a dialog and a menu, raised by --shadow-pop over a --scrim, which is the one shadow left. --node is a box on a graph. A control has a ground of its own, --field, which is the page's, edged in --input, so an empty box reads as a box in both modes. --muted is the wash a row takes under the pointer. --border is the hairline between rows inside a panel body; --border-strong is the shell's rule. Ink runs --foreground, --muted-foreground for a hint and a code, --faint for a timestamp beside a name. Every surface stays near-achromatic, so status colour is the only colour a screen carries.

The amber is the brand, the reader's avatar and the focus ring, and nothing else. --brand fills the mark and the avatar at the foot of the rail, and --ring is every focus ring. An action is drawn in ink: the primary button is --primary, which is the foreground as a fill with the page's colour on it, so the one loud thing on a strip is the verb somebody came to press rather than a colour. A secondary button is --secondary, a quiet one is ink on nothing, and a verb that takes something away is the critical ink on --critical-wash. Selection is neutral: the active rail entry is a --secondary fill and weight 600, a chosen box on a graph is a ring in the foreground, and a chosen row is the --muted wash held.

--primary-ink is the amber as text, and it is worn by the door's eyebrow alone. A link is ink, underlined on hover, and an aside link beside a section heading is the muted ink: a link names a place, and a place is not news. scripts/check_ui_classes.py refuses a bare text-primary anywhere in the app's own source, because the primary is a fill.

Status is named for the state machine. --status-succeeded, --status-failed, --status-running, --status-waiting, --status-queued, --status-pending, --status-completed-with-errors, --status-cancelled, --status-skipped. The names are RunStatus and AttemptStatus from dirigent_client.enums, so a status string off the wire indexes a token with no translation table. Each has an -ink twin: the token fills a chip at 14% alpha and the ink is what is legible on it -- the .status-chip class in index.css is that rule, written once. A delivery is a machine of its own, so --status-sending and --status-sent are NotificationStatus's live states written as the run states they mean -- in flight is running, arrived is succeeded -- and a palette that moves those moves these. skipped is drawn in neutral with a dashed edge, because a skipped step did not happen and has not earned a colour.

Four semantic aliases sit over the top for anything that is not a run: --good is succeeded, --critical is failed, --warning is completed-with-errors, --info is running. Each has an -ink twin as well, for text on its own 14% fill.

A kind is not a status, and has its own family of hues. What a thing is -- a block kind, a connection kind, the clock a schedule fires on -- takes --kind-<family> and the .kind-chip fill rule, which is .status-chip's written once more against tokens that cannot be confused with a state. lib/kinds maps a kind to a family and hashes the ones this bundle was built before, so the mapping is total and stable; no component writes a tint inline. Every family is far from hue 80, because that hue is the identity colour and means action.

Both modes, every time. This app was designed dark first and the light one is its inversion, not a second design: the same hues, the lightness ladder turned over. A token changed on one side is checked on every surface that consumes it on the other -- an amber that reads as the brand on near-black can read as mustard on near-white, and a raised fill that is a clear step on the dark page is white on near-white in the light one, which is why a secondary button there also carries a hairline ring. The light mode's shadows are a fraction of the dark mode's, because a shadow on a pale ground reads as dirt long before it reads as depth. A palette is checked the same way, on both of the modes it has.

The app has one decorative element, and it is on the login screen. The brand pane carries a graph of steps above the lockup, filled in --terminal-node and edged in --terminal-node-edge -- one and two surface rungs above the pane's own ground, per palette -- joined by --terminal-graph-edge and set over arcs in --terminal-graph-faint. One run through it is lit in --terminal-accent, with a glow under the lit links. One step wears --status-failed, because a graph in which nothing ever goes wrong is not the graph this app draws, and the head, the only solid node, wears --status-completed-with-errors under its halo: the state a run is in when it reaches its end past a failed step, in the same token the run screen gives it. Nowhere else in the app is a shape drawn that does not stand for something the reader can act on or read a value from.

The brand pane is bounded. It is 52% of the window, never under 560px and never past 1056px, the width at which the graph reaches its largest scale, so a display wider than that spends what it gains on the form rather than on the mark. The two columns begin at lg, not md: a window under 1024px cannot hold the pane's floor beside the form and its padding, and a pane drawn narrower than its floor squeezes the graph into a strip. Between lg and xl the pane holds that floor and the form column takes what is left; below lg the two stack and the pane is a strip. The form pane bounds its form the same way and centres it across the column, so what a wider window buys that column is spent evenly either side of the one question it asks. Down the column it is centred only where the two columns are: stacked, the form stands at the top of its section, under the brand strip and the section's own padding, and whatever height is over falls below the button. A form centred in what is left under the strip opens a band of ground above the greeting, and the greeting is the first thing read on the screen that greets.

The door's refusal takes no room. A wrong password and a rate limit arrive as problem documents, and the sentence is drawn in a critical-edged bar the height of a field, hung below the button and positioned out of the flow: the form is centred in its column above lg, so a notice that took space would move every field the moment somebody got a password wrong. It is the one refusal in the app not drawn by Refusal, for that reason alone.

And the seam between the two is dragged. It is a separator like every other dragged edge here -- eight pixels of hit area over the line, drawn as nothing until a pointer, a focus ring or a drag asks where it is, answering the arrow keys, Home and End as well as the pointer -- and it moves between the pane's own two bounds, never past what the form column needs of the window. A width somebody chose is that browser's from then on, kept as pixels rather than as a fraction for the reason every panel here is: what they dragged it to was a decision about what is in it. A double-click on the seam, or Delete on it, gives the pane its clamp back; a window that narrows holds the choice inside the new bounds rather than forgetting it. Nothing else on any screen may grow a handle this quiet -- a control nobody can find is a control nobody needs, and this one is the door's alone.

That graph is fitted to its box and centred in it. The box is the pane's width from below the top down to a fixed gap above the lockup, and it is measured; the drawing has a shape of its own -- the table's extent plus its padding -- and it is scaled uniformly to fit, never past 1.5, so a wide pane does not magnify it into a poster. A box taller than the fitted drawing gives the rows up to 1.3 times their spacing and never the columns anything, because what a taller pane is worth is a little more air between the ranks rather than a different graph; whatever height is still over is ground, split evenly above and below. A box smaller than the shape needs is the one case the drawing is spread across it and shrunk instead, which is what keeps a step's box off the one beside it. The lockup and the facts are anchored to the foot of the pane, so neither moves between a short pane and a tall one.

The radius ladder is crisp, and it is the floor as well as the ceiling: 4px for a surface -- a card, a listing, a panel, a dialog, a menu, a graph's ground -- and for a box on a graph and the brand tile; 3px for a control; 2px for a chip and for a box on a miniature graph. They are stated in @theme rather than derived, as --radius-surface, --radius-node, --radius-control, --radius-chip and --radius-mini, and every component takes its corner from them: no class names a radius of its own. A ladder of 1 and 2 read as square and one of 8 and 6 as soft, so this one goes neither below nor above. Nothing but a status dot and an avatar is fully round: not a chip, a switch, a port on the canvas or a scrollbar. Tailwind's rungs are mapped onto the ladder -- rounded-sm is the chip's 2px, rounded-md and rounded-lg the control's 3px, which is what the generated buttons and fields ship, rounded-xl through rounded-4xl the surface's 4px -- so a shadcn primitive lands on it without being edited, and a generated rounded-full is said back onto a rung in index.css against its slot.

apiFetch is the only fetch

src/lib/api.ts is the one place in this app that calls fetch. Every read goes through it, and a page component that called fetch itself is a bug.

It exists because three things have to be right on every request and a page will silently get one of them wrong: the versioned prefix, which this bundle cannot know at build time; the cookie credential; and the fact that a refusal arrives as a problem document rather than as the resource.

The three shapes every resource module is written out of live here too -- Page, Problem and JsonMap -- because a wire primitive declared twice is two of them, and a resource module that had to import one from another resource module would say the two resources are related when they are not.

  • The prefix is read, not written down. Settings.api_prefix is configurable, so the server answers GET /config.json with the prefix this instance mounted at, and the bundle asks once. A caller writes the path the OpenAPI document writes -- /pipelines, /auth/me -- and never the prefix.
  • A refusal is an ApiError carrying the problem document: status, title, detail, code, params, problems, instance. code is the dotted code of the catalogued message the detail was rendered from and problems is a list of issues, each with its own code, message, params and location. There is no type member; this server emits none, so nothing may key off one. apiJson throws it; apiFetch hands back the Response for a caller that cares about a particular status.
  • A path outside the versioned API is read through rootFetch. The probes under /health answer at the root whatever prefix the API mounted at, and readiness answers 503 carrying the readiness document rather than a refusal -- so that one hands back the Response and lets the caller decide, and it lives here because this file is the only one that calls fetch. lib/server-status is its only caller and the only reader of the probe: the corner dot paints from that store and the settings dialog reads the same one, because two readers of one probe are two answers that can disagree on the same screen.
  • A 401 is the shell's, not a page's. The session expired, and the answer is the login page rather than a refusal card on whatever happened to be open. The shell registers the redirect through onUnauthorized.

src/lib/sse.ts is the streaming half, and it is fetch plus a reader rather than EventSource: the browser's own client cannot set the Last-Event-ID header these streams resume with, cannot be aborted, and reconnects on its own schedule rather than the caller's. A caller passes a path and gets frames -- event name, data, and the id the stream last stated -- and decides for itself what attempt, log, run, end and expired mean. A stream ending is not a failure: end says the run settled, and expired says the server closed the tail at its own wall-clock limit, which lib/run-stream reopens from the cursor without showing the frame to the screen.

One stream per open thing, never two. runs/{id}/$events is already multiplexed, and the server caps a principal at eight open streams: a second connection for a log pane would spend that budget delivering the same story twice, with no ordering between the copies. lib/run-stream is the loop that holds one open -- sequential by construction, so there is no path through it with two in flight -- and its test double counts concurrent opens, which is what makes the rule fail a test rather than a review. What a frame means is a pure reducer beside it, so a replay being idempotent and a stale frame not moving a settled state backwards are decisions a Node test makes.

Stores, not a query library

A page holds its own reads in useState, and a fact that genuinely spans screens -- who is signed in, whether the rail is collapsed, which palette is painted, how often a watched screen reads itself again -- is a module store from src/lib/store.ts, read in a component through useStore. store.ts imports no React, so a store is exercised in plain Node. That is the whole of the state layer: no react-query, no swr, no zustand.

The rule that makes it work: a store publishes only when its value actually changed by Object.is, and holds the reference it was given, so useSyncExternalStore sees a stable snapshot and does not loop.

A setting every screen spends is read by lib/format, not by every screen. Which clock an instant is rendered against is lib/times, and the formatters ask it -- so one setting moves every timestamp in the app instead of each screen deciding for itself. Nothing under src/pages reads that store, so the subscription is in App.tsx, on the route table: a change there builds every route element afresh and the screen in front of somebody repaints. Subscribing in the shell would not do it, because react-router hands the shell the same element object each time and React skips a subtree whose element has not changed. lib/preferences is the same shape for a behaviour rather than a rendering -- whether a log pane opens following the tail -- and the pane reads it itself.

A thing is titled by name and addressed by code

Every addressable thing the API answers with -- a pipeline, a connection, a schedule, a source, an alert rule -- carries the same four fields, and the UI renders them the same way everywhere: id, which nothing draws; code, the kebab-case key that appears in the URL and in every document that references the thing; name, an optional human title with no identity at all; and description, the long-form body.

The title is the name when there is one and the code when there is not, and the code is always on screen. lib/identity is where that is decided, once: titleOf answers the title, headingOf answers it with the code to draw in mono beneath it -- or with null, when the title already is the code and wears the mono face itself. Every listing, panel header, breadcrumb, graph node and palette row asks there rather than spelling name ?? code out again, so no two screens can disagree about a name that is whitespace, the code is never drawn twice, and the string somebody would paste into a URL or type into a document is in the same place on every screen. A screen that showed only the name would be a screen nobody can act from.

A code in mono never wraps mid-word. report-daily-digest broken onto two lines at daily- is a different string to somebody copying it, so a code is one line and nothing else: where it does not fit it truncates with an ellipsis and carries the whole of itself on title. components/ds/Code is that rule written once, and every code on a screen -- beside a title, in a fact, on a node, in a row -- is drawn through it rather than through a bare font-mono span.

A listing headed by titles is ordered by them. The API answers a listing in code order, and a screen that drew the names but kept the codes' order would read as no order at all -- so the pipelines listing sorts by titleOf, without case, with the code breaking a tie. A run row is headed the same way: RunOut.pipeline is a code and nothing else, so the runs screen reads the pipeline names once and joins, and a code the read did not reach is still headed by its code.

description renders as markdown, sanitised by construction rather than by filtering. lib/markdown uses marked for its lexer alone and answers a tree of blocks and inline runs; components/Markdown turns that tree into React elements. No HTML string is ever produced, so there is nothing for a sanitiser to clean and nothing dangerouslySetInnerHTML could be handed -- a <script> in a description arrives as a token and leaves as text React escapes. Paragraphs, headings, emphasis, inline code, code blocks, lists, quotes, tables and links are the subset a description needs; anything else is drawn as the source it was written as, and a link is followed only for http, https and mailto. A table is set the way its column declared and scrolls inside its own box, because a description is drawn in a panel narrower than some tables are. A listing row is the exception, because a cell is a glance: oneLine closes up the whitespace and the row truncates the words rather than rendering a heading cut in half.

The chosen row's code is in the address. Schemas and Connections put the code of the row somebody opened, in a panel or in a dialog, into the URL and read it back from there, the way Pipelines does, so a schema or a credential is a link that can be sent rather than a selection somebody else has to find.

A step is the one exception to the shape, and it is the same rule underneath. Its map key is the reference -- what depends_on and ${steps....} read -- so the key plays the part code plays elsewhere, and the step's optional name is the title drawn above it.

One way to say a time

How long ago on screen, the exact instant on hover. What somebody asks of a timestamp is how recent it is, so formatRelative is the visible text everywhere -- in a listing cell, in a panel fact, beside a version -- and the wall clock is the element's title. components/Instant is that pair written once, and a screen that spelled a wall clock out where the listing beside it said "2m ago" would be two answers to one question on two halves of the same screen.

An exact instant is written largest field first: 2026-09-02 11:36:05, and UTC after it when lib/times is set to that clock. formatInstant is the only place it is spelled, and it is not the reader's locale: an instant is read here to be compared -- against a log line, against another screen, against what a machine wrote -- and 9/2/2026, 11:36:05 AM sorts by nothing and means two different days to two readers. A relative reading that has run past its recency window falls back to the date half of the same spelling rather than to a locale date.

Time speaks in both directions. formatRelative measures either side of now -- 3m ago behind it, in 3m ahead of it -- through the same units and the same thirty-day horizon, past which both fall to the date half of the exact spelling. When a schedule fires next is the same question as when a run started, so a next fire time is an Instant like every other moment in the app rather than a wall clock spelled out where the column beside it says "2m ago". The window either side of now is one instant: a clock a few seconds out of step with the server's reads as "just now" rather than as a firing already overdue.

A schedule says what is true of it where the next firing would go. Pausing keeps the computed next_fire_at on the row -- resuming recomputes it from now, so it has to carry on from somewhere -- and a paused schedule that drew that instant would promise a firing the scheduler will not make. It says "paused" instead, muted, in the listing cell and in the panel fact both; the chip beside its name is what a chip is for, and this is the answer to the question the column asked. A schedule with no next instant at all says nothing is scheduled rather than drawing a dash, and that reading comes first: a one-time schedule pauses itself once its moment has gone by, so the flag alone would have a spent clock read as a stopped one.

A window is the exception, and it is read exactly. Every other instant on a run answers how recent it is, so it wears the relative form; the interval a run covers is what a step filtered its query on, and what somebody checks it against is a date on a source rather than the distance from now. formatWindow is that pair -- both ends through formatMoment on the clock the app is set to, the zone stated once after the two rather than on each -- with the instants as the wire wrote them on the element's title.

A box that takes a wall clock names the clock it is read against. A rendered instant carries its own suffix, so nothing beside it has to say which zone it is in; a datetime-local control carries none at all, and what somebody types there is read against the app's own setting before it is sent. So a section of them states that zone once, in mono beside its label, and each box says nothing about itself. lib/times answers what it is called and how far from UTC it is at a given instant, which is what turns a typed wall clock into ISO 8601 with a zone on it. What the browser draws inside such a box is its own locale's spelling, and it is the one place in this app an instant is not written largest field first: the control is the platform's, and a box drawn by hand to spell it the house way would be a date picker this app then owns.

A one-time schedule is a moment, and it is read like one. Its at is drawn through Instant -- "in 2h" before, "3d ago" after -- rather than as a raw clock string, and once it has fired the cell says so in front of the instant. What decides that word is last_fired_at and never the calendar: a moment that has passed with no firing behind it is one the scheduler has yet to reach, and saying "fired" of it would be a second untruth in place of the first.

A listing is a cursor walk

Every screen that draws a listing draws it with one component. components/list/ListTable is the panel, the heading row, the hairline between rows, the row that reaches for the next page and the count, along the foot or in the title bar where the listing has a title; a screen decides its columns and what each cell says, and nothing else. Where a row opens something -- a trigger's history beside the table, a connection's dialog over it -- the screen passes onSelect, and the row answers Enter and Space as well as a click, because a row only a pointer can open is a row some people cannot. The column header is sticky: rows scroll beneath it inside the listing's own scroll container, and the header keeps the card's ground so no row ever shows through it.

A row on most listings fills the panel, and that is a disclosure, so pressing it again takes it back. What a press opened a press closes: the row loses its tint, the screen stops filling the panel, and an unfilled panel is not drawn -- so the thing opened from the listing can be shut from the listing, rather than only from the panel's own control. A row whose selection is the URL navigates back to the bare listing instead, which is the same gesture said in the address bar. ListTable decides this for every listing, from selected and panelStanding, and a screen says only what taking its own selection back means: onClose. Pressing the open row while the panel is down opens it again rather than clearing a tint nothing is showing, because a control that does nothing is the fault this rule exists to fix. A row that navigates is not a selection and does not toggle: the runs listing goes to a screen, and a press there has already left. Connections and Users are the two listings whose row opens a dialog instead, and the pattern is still being tried: see below. No other listing has moved, and none should until that trial has been read.

Every listing is a keyset walk, answering rows and an opaque cursor and no total, so there are no page numbers and what the foot can honestly say is how many rows have been read and whether there are more. lib/paging is what an answer does to the rows already held, as pure functions over the state; hooks/use-paged is the part that needs a browser, and it holds the question beside the rows so an answer to filters somebody has since changed cannot land on the screen.

A trigger row is two fixed lines. The lead cell is headingOf's pair -- the title, and the code beneath it in mono only where the title is not already the code -- and everything else the row has to say about itself goes on that second line as a chip: the document that owns it, or managed, and paused or disabled where that is true. There is no kind chip, because the heading over the table is the kind, and no id, because a row's key is not something a person reads. A row whose height depended on how many chips it wore would make a listing of twenty rows a listing of twenty heights.

A connection row is two lines and three cells. The lead cell is headingOf's pair drawn across -- the title, and the code in mono where the title is not already the code -- with the description's first line under it in the muted ink, truncated, which is what a credential is for said in the words of whoever set it up. Then the kind, its mark and its code in mono, and the health. What a credential is pointed at is read on the connection's own dialog: a settings line per row is a column of url= and timeout= to scan past before the health cell is reached. A healthy connection is a bare dot, and failed or unknown gets the word, which is the top bar's rule: words only when something is wrong. The dot is 8px; the words are healthOf's -- failed, never checked, not verified, and healthy as the bare dot's tooltip -- in the channel strip's three colours: green where the last check passed, red where it failed, a hollow ring where nothing was proved. One vocabulary, decided in lib/connections and drawn by HealthSaid on every screen that draws a credential's health: this listing, a pipeline's requirements, the reference row under a field. The check's own sentence is not on the row: it is the tooltip on the health cell, on hover and on keyboard focus alike, and it is in the connection's dialog in full, where there is room for the whole of it. Checked is how long ago, and nothing at all where nothing has ever asked -- so a row nothing has checked has no Checked line on its card either. Asking again is the dialog's Check now, not a button on every row.

Connections opens in a dialog, shown first and edited second, and this is a trial, of which Users is the second use. Pressing a row opens one dialog over the listing, at /connections/<code>, and there is no panel on this screen. It reads first: the title, the kind and the code; the description; then the health with the check's own sentence, every setting that holds a value under its own key, every secret as dots or not set, and when it was made and changed. Its footer is Delete, and Check now and Edit at the other end, with the corner's close as the way out. Edit is the same dialog in its edit state, at the same width, so nothing moves under the hand: the schema-driven form in two columns, a required text field, a map, a JSON value and a program taking both, a footer of Cancel and Save, and no corner close. Cancel, Escape and a press outside all go back to the reading rather than closing, and a press outside does nothing at all while a form is open, because a typed credential is not something to lose to a stray click. A save checks the connection, because the health on screen was about the settings that were just replaced. Delete raises ConfirmDialog over the dialog. What uses the credential shuts it: GET /connections/{code} answers used_by -- the pipelines whose current version names the code with the steps and sources that do, the alert rules delivering through it, the storage schemes configured from it -- and where any of that is true the reading draws it as a Used by fact, each one linked where it has a screen. The same read is made again as the question opens, and the question says Used by <what>. with Delete shut and saying why. The server checks again at the delete and holds what it finds, so a refusal for what that read did not see stays in the question as one line. GET /connections carries nothing about what uses a credential, because a listing would have to walk every document for every row.

An account opens the same way, and is the trial's second use. A press on the row, anywhere but its chevron, opens one dialog over the listing that reads first: the title, the role, the email where there is one, when it last signed in and when it was made. Its footer is Deactivate (or Activate), and Reset password and Edit at the other end. Edit is the same dialog at the same width: name and email side by side, the role as one compact select with what each role may do on its option, a footer of Cancel and Save, and no help line that does not add a fact. There is no panel on this screen. There is no single read of an account, so the open one is not in the address. A role is a word and not a code: plain in the Role column and in the reading, and never a chip beside the mono username it could be mistaken for.

hooks/use-read is the one-page sibling of use-paged, for a read that is a document rather than a listing -- what this instance has installed, how the last day of runs came out. Same rule: the read function's identity is the question, and an answer to a question nobody is asking any more is discarded rather than left on screen.

A screen that watches beats, and the rest are read when they are opened. A run's own screen holds one event stream and needs nothing beside it. Two screens go stale standing still -- the runs listing and the dashboard -- so each reads itself again on the one cadence lib/refresh holds for the whole app: off, or five seconds to five minutes, thirty by default, chosen from the split refresh control and answering the same everywhere it is offered. hooks/use-heartbeat is the beat. It fires only while the tab is visible, because a screen nobody can see asking anyway is heat, and it fires at once when the tab comes back, so what a reader returns to is the present. A beat is quiet: what is on screen stays until the answer lands, a beat that fails changes nothing and the next one asks again, and a listing's beat folds page one in at the head matched by id -- so a row already on screen is refreshed in place rather than moved, and the count of what arrived is the pill the runs screen offers.

A filter is the server's or it is not offered. GET /runs narrows by pipeline, by status, by how far back to look and by the tags the run's pipeline wears, and those four are what the runs screen has. A control for something the server cannot filter would narrow whichever rows happen to have been loaded while appearing to answer the question it asks. Where a screen does narrow what it has loaded -- finding a pipeline among the pages read so far -- the foot already states how many rows those are. A corpus is the exception, and it is one because nothing in it is unread: the block catalog and the examples screen are answered whole by what this build installed, so every control on them narrows all of it and their empty state can say a search found nothing rather than that there is nothing yet.

An examples row says what a copy of it would have to change. A document carrying a top-level connections: or schemas: section is what runs alone under dg run --local, and an apply refuses it, so the row states carries connections; a copy names them -- carriesNote in lib/examples -- and a template's copy names them under requires instead. A document carrying neither says nothing, because a column saying "nothing" of a hundred rows is a column of noise.

A tag filter is a set, and its members stand beside the control. tag repeats on the wire and repeating it narrows, so the control is a menu of checkboxes rather than a choice, and each chosen tag stands next to it as a chip that removes itself. Where a row already draws its tags, the chip on the row adds it to that set: the filter is reached from the thing that shows what to reach for. Where a row's tags would be the same words repeated down a column -- every run of one pipeline -- the rows carry none and only the filter does, and a pipeline's row carries none because its shape and its sentence are what it says about itself. On the pipelines screen the set lives in the address (?tag=) so a narrowed listing is a link somebody sends, and it is replaced rather than pushed, because a filter being assembled is one destination and not five. The runs screen carries the same control beside its own three, seeded from the address once and held in state after: a control that wrote every keystroke back would put a history entry behind each one.

A tag on a row is the filter's own door. components/TagChip is a chip until it is given onSelect, and then it is a button saying "Filter by " that adds that tag to the set the listing is already narrowed by rather than replacing it, because a tag drawn on a row somebody cannot press is a fact with a gesture missing from it.

A column's heading is a label, whatever the cells under it are. A column of codes draws its values in mono and its heading in the sans face, because the heading is the same word in the same voice on every listing.

A column holds a value, a title or prose, and that is what decides its width. A value is drawn from a fixed vocabulary -- a chip, an instant, a duration, a count, a mark -- so it is shrink-to-content and declares the floor it needs to say itself on one line. Text is whatever somebody typed, and it comes as one of two things: the title the row is known by, or the prose beside it -- a description, a path, a code it refers to, the sentence a run failed with. A screen says which through the column's kind, and lib/column-width decides the width from it, so two listings of the same shape lay out the same way and no screen sizes its own columns. A column of text declares no width of its own: max-w-0 is what lets it shrink and its content truncate, the floor under every one of them is 144px, and inside the cell the content carries min-w-0 and truncate and says the whole of it in title, or the cell shrinks and the words are simply cut off.

A title reads whole before a description takes the rest. A title is short and it is the identity -- a reader who cannot read the whole of it cannot tell one row from another -- and a description is long and written to be cut. So the title column asks for exactly what its longest title needs and every column of prose beside it cuts up what is left. Where a title has no prose beside it there is nobody to hand the rest to, and it takes the room the way any other column of text does.

The text columns ask in shares that add up. A column of text that asks for the whole table, as w-full does, asks for more than there is, and a browser answers that by scaling every such ask down together -- which is how a title beside a description came to be given half of what it asked for, and a failure sentence 144px of a 1240px table. So the listing measures itself once -- what its longest title needs, and what the value columns take -- and hands each column of text a percentage of its own width: the title its need, the prose an equal cut of the remainder each. What a title needs is read off its cells on the one layout the column stands at its floor in, where the whole of the title runs past the cell and the width it is missing can be read off it; a cell with room to spare says nothing, so a column given what it asked for measures the same again, and one whose rows grew under it -- the runs listing, when the pipeline names it was waiting for land -- measures the difference and asks again.

A need is measured in fractions of a pixel and asked for in whole ones. scrollWidth and clientWidth answer in whole pixels, so a cell short of its content by a hundredth of one reads there as a cell with room to spare -- while the browser, which lays out in fractions of a pixel, has already drawn the ellipsis, and the ellipsis eats the word in front of the character that did not fit: The reading, as Postman Echo answers it lost it to a shortfall of a twentieth of a pixel. So every edge is read off a client rect and every run of text off a range over it, the need rounds up to a whole pixel, and the share stating it rounds up while the share of the remainder beside it rounds down -- the asks still have to add up.

A share is a width, never a floor. A floor is what a listing measures itself against before it gives up on drawing a table at all, so a title column that declared the width it wants would turn the narrow listings into cards. What it asks for yields instead: where the floors under the columns beside it take what the table had left, the browser gives the title what remains of it, and at 1024 every listing is still a table.

Text that declares no bound is a table as wide as the longest thing anybody ever wrote in it. An unbounded failure sentence is what made the runs table 910px wide in a 712px column, and the identity beside it 164px. A column's floor is also how a listing knows it has run out of room, so a table whose columns declare widths says them as floors rather than locking them with fixed, which honours a width exactly and a floor not at all. The Tags column takes at most a quarter of the table.

A card's rows follow the same rule as a listing's columns. The dashboard's health card lines a mark, a code, what the thing last said and when up on one line in a card a third of the screen wide, which is a table's problem in something that is not a table. A healthy connection says nothing there, its dot's tooltip carrying the word and the check's sentence: the mark is a value and holds its width, the code is the identity and keeps a floor of six characters, what it said is prose and truncates in what is left with the whole of it on its title, and the instant is a value out of a fixed vocabulary and holds its own. Nothing on such a row is shrink-0 unless it is short by construction, or the row's minimum is wider than the card and the card scrolls inside itself -- the same defect as a listing that scrolls sideways. A card whose row cannot be read at a third of the width takes the whole width instead: the chart's column and the health column share a row at xl and stack under it.

The dashboard says what needs somebody first, then how things are going. Its first section is the runs that need a look -- every pipeline whose newest run failed or completed with errors, one row each, led by the pipeline's miniature with the failed step filled, then the title and code, the step and the sentence it failed with, the status word, how long ago, how many runs that is where it is more than one, and the catalogued code. Where nothing needs a look the section is not drawn, because a heading over nothing is a wall of one zero. Then two columns: the runs of the last day by the hour, with the failed and the troubled parts of each bar in their hues and the rest in a neutral ink, because a green bar is the loudest thing on a page where nothing is wrong; and under it how long each pipeline took, the median of its runs in that day. Beside them the health of what the runs depend on -- each connection that is not healthy by name with its word, a count of how many are, and every worker. Then the runs in flight, the most recent runs, one line each, with All runs beside the heading, and the next firings -- each of the three only where there is any. There are no stat tiles: a number that is zero on a good day is a number nobody reads on a bad one.

The tags fold to the room they have rather than wrapping into it. Chips are one line: a row whose height depends on how many words it wears makes a listing of twenty rows a listing of twenty heights, so what does not fit that quarter folds into one trailing +N chip -- which says how many it is holding, spells them on hover, and opens them as the same filter every other chip on the row is a door to. A second line is allowed only where a table is 1280px or wider, and never more than eight chips are drawn whatever the room. The fold is computed, not guessed: a chip is mono at 12px, so how wide one is is arithmetic over its characters, and lib/tag-fold fits the words -- the +N counted as the chip it is -- into the room one ResizeObserver on the listing's own card measured. One observer for the listing rather than one per row, and a share of the table rather than the cell's own width, which is the width the chips already took. Where the listing is drawing cards a row's tags have a row of their own, so nothing folds there.

A choice closes on its choice; a set stays open. components/list/Choice is one value out of a menu of them, and picking one shuts the menu, because the question has been answered. A set is the other case: several tags mean AND, so TagFilter's rows are checkboxes and the menu stays where it is until somebody is done with it. Neither trigger wears a count -- what has been chosen stands beside the control as chips that take themselves off.

A count says what it counts. rowsRead writes "1 pipeline", "50 runs, more to load": the noun is the row's, singular where there is one of it, and a listing with a cursor left says so. A bare number along a foot reads as a total, and a keyset walk has no total to state. The noun reaches the foot in both numbers, as the {one, many} pair its count entry holds, and ListTable's noun prop takes that pair rather than a word: a foot handed one plural would have to reach the singular by rule, and the rule that makes a batch out of batches makes an entrie out of entries.

A pipeline is listed by its shape

A pipeline's row leads with its miniature graph, because what a pipeline does is the shape of its steps before it is any sentence about them. components/graph/MiniGraph draws it from PipelineOut.shape -- the current version's steps reduced to a key, a block or the kind a source listens to, what each depends on and whether it fans out -- and from nothing else, so a listing of fifty pipelines is one request. Then the title, the code in mono beside it, and the description's first sentence under them, which is what a description's first sentence is written for. What fires it and how it last ran stand after that only where they exist: a schedule count and the kind of each source as chips, and the last run as its status word and how long ago. A pipeline that has never run says nothing there, never "never run", because a column of the same words is a column that says nothing. The row is the link to the pipeline's page, so it draws no arrow, and the listing draws no panel.

A miniature is drawn the same way everywhere. A box is 14 by 10 at the miniature rung, 2px; a step that fans out is a box with a second one stacked behind it; a source is a dashed box on no fill; a step the last run failed in is filled in the failed hue, which is the only colour a miniature ever carries. Ranks are the longest path to a step, read left to right, and a rank's steps stack down. An edge is drawn only where it changes the shape -- an edge another path already implies is left out, so a step that waits for everything before it does not draw a fan of lines -- while the full graph on the pipeline's page keeps every one. The scale is shared and capped: every miniature is drawn at the same 22px per rank, so a longer strip is a longer pipeline, until the lead cell's width is reached, and a pipeline deeper than that is drawn smaller rather than cut.

A person's own pipelines and the shipped examples are two lists. PipelineOut.example says whether a pipeline's code is one the installed example corpus ships. The person's own come first, in their own panel, at a row's full height, ordered by title. The examples come after, in a panel of their own with denser rows: a short set, the ones that have run first and most recently, and a link counting all of them to open the rest in place. A screen whose examples outnumbered the person's own by twenty to one would otherwise be a screen about the examples. Where every pipeline on an instance is an example, the person's list is not drawn, and where none is, the band is not.

The search box and the tag filter narrow both lists, the tags on the server and the search over what was read, and the counts beside each heading are what is left.

The pipeline's page leads with its graph

A pipeline's page is its graph first. The title, the code in mono under it, and Edit, Validate and Run… at the right of the heading; the description; then the graph on the canvas ground; then what the pipeline did, side by side -- Recent runs, its newest runs one line each (the state, what started it, how long it took and when, each line the link to its run) with All runs beside the heading opening the runs screen narrowed to this pipeline, and No runs. as the whole body where there are none; and, only where the document declares a report: and a run has settled, Report, the latest settled run's document rendered as the run's Report tab renders it, at a pane's height with the window for the whole of it, the run it came from beside the heading, and the settled runs before it as links to their own Report tab at its foot. A document declaring no report, or a report and no settled run, draws no Report panel. GET /runs?settled=true is how the page finds that run, because no single status asks for it. The two rows take the same flex bases, so the seam between Recent runs and Report lines up with the one under it. Then three panels side by side -- Blocks, which block each step runs with the steps as chips that choose them on the graph, drawn only where a step runs one; Triggers, the schedules and sources that fire it; and Connections, what it reaches and how each one is. Edit opens the editor at /pipelines/<code>/edit, which is the screen the rest of this page's editing rules are about; Validate dry-runs the stored version against the instance as it is now and says the answer in one line.

The graph is fitted, never scrolled. At 1440 wide a pipeline's graph is drawn whole on the page with no scrollbar of its own: the canvas is as tall as the drawing it holds rather than a fixed stage, the drawing is scaled down where it is wider than the page and never up past its own size, and a graph too deep for the width is wrapped onto rows the way every canvas wraps one. It pans, zooms, lays out and opens in a window as every view of a graph does. Choosing a box says under the graph which block it runs, what it waits for and what it feeds, and puts it in the address as /pipelines/<code>/<step>, replaced rather than added to.

An endpoint says where it is called, and a run says who it answered

An endpoint source is drawn as the address a caller is given. Wherever a source a request reaches is described -- its row in the pipeline page's Triggers panel, the editor's step panel for it, the triggers screen's panel -- it says its methods and the full URL in mono, wrapped rather than truncated: this instance's origin, the endpoint prefix and the path, which is the string a caller pastes, with a copy button beside it. Under that, how a caller authenticates, as configured: a bearer token by its prefix, an HMAC signature by the header it arrives in, or public. components/triggers/Endpoint is that block, written once, and lib/sources is where the URL and the line are decided.

New token is how a source's credential is replaced, and it asks first. The verb is New token, or New secret on a signed source, on whatever surface describes the source; it raises ConfirmDialog, Replace the token of <code>?, whose one line is that the old one stops working, because that is what the verb takes beyond the thing named. The answer is the minted-token dialog every source credential is shown in: SecretReveal, the URL, how a caller presents it, and for a bearer token a curl line that makes one call with it -- the first method, and the source's sample body where it has one -- because the first thing anybody does with a new token is try it.

A run says how its source was entered and whether its reply went anywhere. A run of a pipeline with a source was entered one of three ways a reader cares about: by a call, by hand with an input, or by hand from the source's sample. The received attempt carries it as via, and the run's status line says it after who started it: by admin, from the sample, by admin, with an input given, by a call to ask. A reply renders whether or not anybody is waiting, and its output carries delivered: the reply's box says sent or not sent after the word reply, in place of its duration, and its step panel says sent to the caller or recorded, not sent. A hand run is answered nowhere, and the screen never lets it look as though it was.

Run… says what a hand run of a pipeline with a source does, in one line over the footnote: that it starts from the source's sample, or with an empty input where there is none, and that nobody receives the reply where the pipeline has one.

A graph is a shape, and each screen reduces its own to it

lib/dag-layout knows nothing about runs or documents. A run's graph is its pinned definition with a state on each node; the editor's is the stored document with an edit mark on each. The geometry of the two is one problem, so what elk is handed is an id, a width and a height per node and a pair per edge, and each screen reduces its own shape to that. components/graph is where React Flow and elk are actually imported -- the canvas with this app's props on it, the hook that asks elk for positions, and the door that fetches the engine -- and both graph components are lazy, so the two of them share one async chunk rather than shipping two copies.

Motion lives on edges, and it means data travelling. A run's graph is the one place in this app that moves, and only while the run is live: an edge out of a step that has produced its output into one still reading it is dashed in the ink and travels along its path, and the moment a step produces its output every edge out of it into a step that could still read it plays one short handover before settling. An edge stills as soon as either end is done -- a source that failed or skipped never had an output to send, and a target that has settled is not reading one. A node gains nothing from any of it -- status colours stay the only colour voice there, and selection stays neutral -- so what a reader watches is where the data is, not which box is loud. A terminal run is still everywhere, and so is one opened after it settled. Somebody who has asked their machine for less motion gets the same reading standing still: a travelling edge is lit in the same ink and a handover is not drawn at all, decided in lib/run-detail from the setting rather than left to a stylesheet to hide -- and the stylesheet stops the animations as well, so a frame rendered before the preference lands is still still.

Layout is recomputed on the shape, not on the state. signatureOf is what the elk call is keyed on: which nodes there are, how wide and how tall each is, and what joins them. A step going from running to succeeded moves nothing, and neither does typing into a config field.

A form is a JSON Schema, read once

lib/schema-form turns a JSON Schema into field descriptors: which control a field takes, what its bounds are, whether it is required, what its enum offers. A block's config schema and a pipeline's parameter schema are both fed through it, so the step form and the run dialog are one decision rendered twice, and every shape the shipped catalog publishes has a test.

What no control fits is edited as JSON. A list and a schema with no type at all are a textarea rather than a wrong control -- guessing at a list of integers with a text box is how a document ends up carrying "[200]". The fallback has a test of its own, so a shape this bundle was built before degrades rather than misleads.

A map of scalars is a key/value table. An object schema with no properties of its own whose additionalProperties names one scalar type, or a union of them, is the pairs kind: two columns, one row per entry in the document's own order, a blank row at the foot to type the next pair into, and a remove control on each row that holds a pair. That is headers and query on http.request, env on shell.run and on each docker verb, inputs and outputs on docker.run, and headers on webhook.post and on the webhook connection kind. What the table writes is the plain object, so the document, a run's parameters and a connection's config keep the shape they had and nothing downstream learns a form was involved. A cell is a switch where the map holds booleans alone and a box otherwise, and what is typed is read as the narrowest shape the map takes -- a whole number where it takes integers, a number where it takes numbers, true or false where it takes booleans, and text last -- so 2 in query is the number 2 while 2 in headers is the text. A key written twice wears the same marking a refused box does and is not written until it is fixed. Every other map stays JSON, and the reason is always that a cell cannot hold what the values are: pipeline.run's params takes any JSON value, a map of lists takes a list, a map whose values may be null takes something an empty cell is not. A reference in place of the whole map is not a table. The document language lets ${...} stand wherever a value goes, so a map written as one is the text it was written as, with a reference, not a table beside the label; clearing the box brings the table back.

A field that carries a program is edited as one. A string whose schema published a contentMediaType -- application/jq for the three jq verbs, text/x-shellscript for the shell string shell.run and docker.run take, application/sql for a query, text/x-jinja for a report's template, application/x-compose+yaml for an inline compose file, application/fhirpath for the FHIR pack's FHIRPath verbs, text/cql for its CQL library -- gets the same Monaco a document is written in, through the same CodePane, so a jq program is read on the lines it was written on. dirigent_common.programs is where the seven are spelled, and the schema is what decides this and nothing else: every other string stays one line, however long a value it holds.

Each of the seven is highlighted in its own grammar and in the house inks, the same in every one: a keyword and a number wear the warning ink, a string the good ink, a function -- and jq's .path -- the info ink, a comment and the punctuation the muted ink, and a name bound from outside the program -- jq's $name, FHIRPath's %name and $this -- --binding-ink, because it is the one thing a reader of an expression has to look for elsewhere. It is the kind family's hue with more chroma than a kind ink, oklch 0.7/0.18 dark and 0.48/0.17 light, because a thin mono glyph at the kind ink's chroma reads as the foreground beside it. Every palette holds it at 6:1 or better on the editor's ground and 2:1 or better against the text, so none overrides it. Any other name is foreground, and so is one written in backticks or in CQL's double quotes, define "Numerator" included: the quotes are what tell it from a string, which is green.

A document is written in one editor wherever it is written. The editor's source pane and a step config field whose schema says it carries a program are the same Monaco against the same GET /schema/document -- a key no block takes is squiggled where it was typed rather than reported a round trip later. Each pane names its own buffer, because monaco holds one model per uri and two panes sharing a name would share a document.

A pane too small for what it holds opens in a window, and the button is at the top. components/WindowedPane is where that gesture is written once -- the button, its place, and the window, which is the viewport less a gutter -- and every pane that offers one comes through it: a config field carrying a program, a value edited as JSON, a report document, an alert body, an example's source, the block of produced JSON on a run's step, a schema's page, and the graph on a run's page and a pipeline's. A pane in a panel with a head hands the button to the head, beside the panel's other tools (WindowButton, with button="none" on the pane). A pane with no head keeps it inside its own top-right corner, in a row of no height held to the top of whatever scrolls it, so the button is on screen wherever the reader has scrolled to and covers no more than the end of the first line. A panel clips its corners with overflow: clip rather than hidden for this reason: a hidden overflow is a scroller of its own, and a button held to it would never move.

The side panel's toggle is at the screen's right edge. While the panel is shut it is the last control in the top bar; while it is open it is the last control in the panel's own strip, over the panel it shuts. The rail's own collapse is beside the logo.

A field that names a thing shows the thing. A string property carrying x-dirigent-ref holds the code of a connection or a schema, and under the box the form draws what that code resolves to, in the step panel's own group gesture: a shut row with a one-line summary, and the definition under it against the group rule when it is opened. A schema the document carries in its top-level schemas: resolves before one the instance holds, which is the order the engine resolves them in, and says carried by this document; one the instance holds says instance and opens with a link to /schemas/<code>, a connection with its kind, its health as every screen draws it, and a link to /connections/<code>. A code nothing holds is not stored or not configured, muted, and not a button -- there is nothing under it to open. Nothing is called missing while the listing behind the answer is still being read, and a value written ${...} names no code at all, so neither draws a row. The code itself is never repeated: the box above the row already holds it.

A JSON Schema is edited against its own meta-schema. The media type application/schema+json is what asks for it: a buffer holding a schema document is checked against JSON Schema 2020-12, the draft the server validates with, so its keywords complete as they are typed and a value the draft does not take is marked where it was written. Two buffers hold one -- the dialog a schema is written in, and the schema's own page at /schemas/<code>, where a stored one is corrected beside a value it is tried against -- so a shape is fixed in the editor it was authored in rather than read in one place and rewritten in another. What that page may change is what SchemaUpdate carries: the name, the description and the body. The code is the heading and never a box, because the route addresses the schema by it and every validate.schema step and requires.schemas entry names it.

The label is the key. What is being edited is a document, and the word an author writes in it is max_response rather than "Max Response". description is the help under the label, and whether a field is required is said beside the label rather than inside it, so the control's accessible name stays the key. The required word is body ink, never amber: amber is the brand's and the focus ring's, and a field label is neither. FormField draws it.

Every field is a design-system control, and an empty one looks like an empty one. A field renders through ui/input, ui/textarea, ui/select or ui/switch and never a bare element, and the treatment is checked in both palettes: the dark one fills a rung of the ladder and edges it with a hairline the ground shows, and the light one has to say the same thing with a fill and a stronger line, or a value floats in whitespace and an empty box is not there at all. A row is the key as its label, the annotation beside it, the control, and one muted line under. An unset secret is an empty password box with a placeholder saying so -- never the words "not set" as prose where a control belongs.

A section the document requires is open, and one it does not is a link. A run dialog asks for a window because the document it runs reads one -- a step writing ${run.window.start} stops the run it is given none in -- so where the document references it the section stands open, every field in it is required, and the verb is shut with the sentence saying what is missing. Where the document references none, the same fields sit behind one plain link and nothing is asked. A dialog that hid a field the document cannot run without would have the server refuse what the form could have asked for; one that opened every optional section would make every dialog the longest form it could ever be.

A form opens on what the document needs. A block such as docker.run publishes twenty config keys and a step takes two of them, so the step form draws the keys the schema requires and the optional ones the step already sets -- required in body ink with its marker beside it, optional in muted ink -- and the optional keys nothing has answered sit behind one plain link reading N more fields, which opens them in place. The split is partition in lib/schema-form and it is read from the values the form opened with, so a key being filled in does not jump over the fold under the hands typing it; it is above the fold the next time that step is chosen, because choosing another step is another form.

A value written as a reference is not checked against the field it stands in. The document language lets ${...} stand wherever a value goes, and what one stands for has no text, no bounds and no type until the run resolves it, so a field carrying one is checked for nothing else -- not its pattern, not its enum, not even that it is a number. That is the rule the server applies: defer_references swaps every such value for a sentinel that every JSON Schema keyword passes over, so a form that refused one would be refusing a document that applies. What counts as one is hasReference in lib/references and it is the engine's own grammar: a run of dollars before braces with something in them, the dollars collapsing in pairs, so $${...} is a literal and ${} is text. It holds where a document is being edited and nowhere else -- a step's config, and not its clocks or its retry budget, which are the engine's own keys -- because a run's parameters and a connection's config are values, and the server checks those literally. Whether the reference names something the run will have is the whole document's question, and Validate is what asks it. The source pane holds to the same rule from the other end: GET /schema/document publishes each block's config with the reference beside every field's own type, so the two halves of the editor agree about one document. A control that cannot hold a reference gives way to a box: a key/value table, a choice and a switch each draw a value the schema describes, and drawn by their own control a reference would read as empty, unset or off and the first touch would write that over the document. So the text is drawn, what it is is said beside the label -- a reference, not a table, not a choice, not a switch -- and clearing the box brings the control back.

The refusal here is the client's half. A field is checked against its own schema so a form can say what is wrong before it asks. Whether a document applies -- its graph, its references, the blocks it names -- is the server's, and the editor's Validate and Apply are the same dry run asking it.

The pipeline editor edits a copy

A pipeline is a chain of immutable versions and the only verb that changes one is $apply, so the editor holds a local document in lib/pipeline-document: the graph, the step form and the source pane are three readings of it, and the count of unapplied edits is the difference between it and the version the instance holds. It is a module store because those three are not inside one another.

A pipeline that does not exist yet is edited in the same screen. /pipelines/$new is the editor with no pipeline behind it: the local document is a skeleton nothing has applied, so everything in it is editable, the code among it. Validate is the same dry run against the draft; Run is shut, because $run runs the version the instance holds and there is not one. The first apply creates the pipeline and the screen goes to the address it will answer at from then on -- which is why a static pipelines/$new route sits beside the dynamic pipelines/:code, and why New is the primary half of the listing's split button while the two doors for a document written somewhere else, From a template… and From a file…, are in the menu beside it.

A document opens on the source that holds it. The editor's panel opens on the step tab, because a pipeline is read a step at a time, and choosing a box on the canvas opens that tab rather than whichever one was last in front of somebody. The exception is a document that arrived as text: From a file… on the listing hands the editor what it read and From a template… hands it the copy it made, and the editor opens on the source pane, which is where that document actually is. A blank /pipelines/$new opens on the step tab like every other screen -- the canvas says how to add the first step, and the source, whose schema would mark an empty steps map before anything had been done, is a tab away.

The step panel reads the config first, then the engine, then the name. What a step does is the block's own config, so the panel opens on that form, folded the way every generated form is. The engine's half sits under it as five groups in one order -- Waits for, Fan-out, Timing, Retry, Rule -- because for_each, the timings and a retry policy are the setting a step runs in rather than the question it answers, and a flat list of twelve controls buried the two keys the step was about. A shut group is one row: its title, and a mono line of what would run, with the values the document sets in body ink and the defaults it leaves alone in muted -- timeout 30s · no deadline · on_timeout fail, off for a fan-out over nothing, all_success for a step that says nothing about its rule, the prerequisite keys as chips. Clicking the row opens that group's own fields under itself, against one rule in the identity colour marking what is being edited; the line goes while the row stands open, because the fields under it say the same thing, and a group holding a refusal stands open with no button to shut it, because a refusal folded away is one nobody can answer. poll belongs to a sensor, so the timing group neither says nor draws one for an operator unless the step carries it anyway. Which key belongs to which group, and how each line reads, is lib/step-groups over the schema in lib/step-keys, so a member added to the definition is filed rather than appended to a list. The display name is last and it is one small field: nothing references it, and asking for it first was asking the least of the step first.

Apply is quiet until it would write. The button takes the identity colour where the document differs from the version the instance holds, and where there is no version at all; where applying would write nothing it stands in outline beside Validate, which is what it would do.

A plan says what an apply takes away before it is pressed. The dry run answers with the source rows and the managed schedules a document has stopped declaring, and those are the one consequence in the dialog that cannot be read back from the document: a source goes with its inputs and its token. They stand under the changes in the warning ink, with the words a reader would ask for, and only where there are any. What an apply did to its sources and schedules is the note that follows it. A token an apply created is shown once, the way a rotated one is, because the answer to that apply is the only time it can be read: MintedToken stands over the editor before anything moves on.

A refusal the dialog can answer is answered in the dialog. A top-level connections: or schemas: section is what lets a document run alone under dg run --local, and an apply refuses one that carries either -- a refusal that stays, because a reference by code is the single contract and a stored document that carried a credential would carry it into every version and every export. What stood beside that refusal was a Cancel and an Apply that could never work, and the remedy the sentence named was a command line, so the way forward is under it now. lib/carried is the decision and components/pipeline/CarriedOffer lays it out: one line saying what an instance stores and what applying does, then every carried entry with its kind, its code and what becomes of it. The server's own refusal is not drawn beside it. A message is rendered by the CLI, by this screen and by every other client, so it states the fact and names no tool; printing it over a list and the controls that answer it would put one fact on screen three times with the least actionable copy loudest. lib/starters.uncarry is what lifts the sections into requires: -- the same text rewrite dg pipeline new makes, so the teaching comments around everything that stays survive it -- and the button says Apply, because a button says what somebody wants and not the steps behind it.

Four cases, and the row says which. A carried entry meets an instance that has nothing under that code (create), one holding the same thing (already here), or one holding something else under the same name (differs); the fourth is a document that only names a code under requires:, which carries nothing to create from and is what the plan's own issues and the Requires chips say. Applying never replaces a stored schema. A stored schema is what every other pipeline naming that code checks against, from its next run, so an apply that wrote over one would change what those pipelines check without anybody having decided that -- and the line over the list, which counts what applying stores, would stop being true. differs shuts the confirm and the sentence beside the rows says which code collided.

So differs is the row's own decision, and it opens in the row like every other. What stood beside it was a sentence naming two remedies, both of them the document author's: change what the document carries, or carry it under a code of its own. The third -- update the stored schema to what the document carries -- is the right one often enough that leaving it out made the dialog a dead end, and it was the one thing the browser could not do at all. So the row expands over what the instance holds, in the pane a schema is read in everywhere else and at a pane's height, with the window for the whole of it; says that every pipeline naming that code checks against the new shape from its next run, which is what a run pinning its shapes at creation actually means -- and, under the names, that a run in flight keeps the shape it started with, which is the half a reader mid-run needs; and offers the write. It is a write of its own and not a step of the apply, because the two answer to different people -- one pipeline's author is applying, and every other pipeline naming that code lives with the result. The identity travels with the body: storing a schema reads its own title and description where the write names neither and PATCH /schemas/{code} reads nothing, so a replacement sends all three or the instance keeps the labels of a shape it no longer holds. The row then reads already here and the confirm is drawn, the collision it was shut for having gone -- and that is the whole report, in the place the reader is already looking.

A carried schema is stored and a carried connection is not. A schema is a shape: it holds no credential, and storing it under the code the document already names puts the same resource where an instance keeps one -- under that code and no other, because it is what every validate.schema step in the document references and nothing here invents or renames it. A connection's config can hold a credential in a field the kind declares secret and in one it does not, since a sql or a git connection's url carries a password or a token inline, so nothing can read a carried connection and say no credential is in it; nor can it be compared with a stored one, because a read redacts every secret field. It is named under requires: and the way forward is the form that mints one -- never a sentence telling somebody where to go while a button goes there.

And that form opens in the row that said so, because leaving this screen costs the document. A pipeline nothing has applied is held in lib/pipeline-document and nowhere else, and the editor forgets it as it unmounts, so a link out of this dialog was a control that destroyed the work it was offered to rescue -- which is the same fault as a confirm that cannot confirm, read from the other end. The row expands under itself instead, the gesture below for a row whose verb needs a form, and nothing navigates. The code and the kind are the document's own, the row already draws both, and no box asks for either again: a create under a different code would leave the row exactly as it was, and the code is never on a screen twice. What the form asks for is the credential -- the kind's required settings, its optional ones folded behind the one link every generated form folds them behind, and a write-only box per secret field -- because nothing is read out of the carried connection, which is the whole reason a connection is not created from a document. A kind this instance has not installed publishes no config schema, so that row offers no form at all and says missing alone. The instance is asked again the moment the credential exists, which turns the row already here and is what was in the way of the confirm being drawn; nothing says it a second time as a line or a toast. A remedy that has to send somebody to another screen belongs on a document the instance already holds a version of, and there is no such remedy in this dialog.

What a copy of a template still needs is said where the copy lands. A copy rewrites the two lines that make it somebody's own and leaves what the document carries in it, because the apply is what refuses a carried section and the dialog it raises is where the shapes in it can still be stored; a copy that lifted them first would name schemas nothing holds, and an apply refuses that too. The editor draws what is still missing as chips under Requires -- lib/requirements checks connections and schemas alike against listings the screen has already read, and claims nothing of a listing that has not landed.

The source pane may hold text that is not a document, and while it does, the local document is the last one that parsed and nothing else may write to it. A form quietly replacing text somebody is halfway through fixing is worse than a form that says it cannot.

The source pane holds the text it was handed, then renders the document. A document that arrived as text -- a picked file, a template's copy, something typed into the pane -- is shown in that text, comments and blank lines and all, until a structural edit moves past it; from the first edit on the canvas or in the step form, the pane renders the local document, because no renderer can put a comment back. GET /pipelines/{code}/$export stays the canonical text: it orders steps topologically and keys the way a digest is taken over, while the pane renders the document in the order it is held in, so what is edited and what is read back are the same thing. An apply canonicalises it.

The editor's schema comes from the instance. GET /schema/document answers with dirigent/v1 composed with this instance's own block config schemas, one if/then case per installed block, so monaco-yaml marks a config key the named block does not take at the character it was typed at. An instance with a plugin another lacks answers with a different schema, which is the point: the editor refuses exactly what that instance's apply would.

The palette is a registry

src/lib/palette.ts holds actions as data: id, title, group, keywords, hint, run.

The shell registers what is always there -- every screen the account is offered, the view rows, sign out. A screen registers what only it can do, from an effect, and returns the unregister so a row cannot outlive the screen that would carry it out. Re-registering an id replaces the row.

The settings dialog is the same idea for a different surface: src/lib/settings.ts holds every row as a record -- what it is called, which category it is filed under, what else it can be found by -- so its search box is one pure function across every category rather than a traversal of markup, and the shortcut rows are derived from lib/shortcuts rather than written down twice. What a row puts on its right edge is the dialog's, keyed by the row's id.

General holds the behaviour preferences, each one a store the screens read rather than a copy this dialog keeps: which clock every timestamp is read against, whether a log pane opens following its tail, and whether an editor marks the row the caret is on. A preference defaults to what this app did before it existed, so nothing moves under a reader who has never opened this dialog, and an editor already on screen takes the change without being rebuilt.

Its nav reads Preferences, You, This instance: General, Theme and Shortcuts are preferences, Account is the reader's, and Server is the instance's. There is no About, because a version stated on two panes of one dialog is the same fact twice; the documentation and the API reference are two links at the foot of the Server pane, where the instance they answer for is. No pane carries a line under its heading and almost no row carries one under its label. A row's description is optional and survives only by adding a fact the control cannot show, such as which timestamps a clock setting moves and which keep their own -- so a line restating the label is not written.

filterActions is a pure function and cmdk's own scoring is turned off, because cmdk ranks by fuzzy match over rendered text and would put a screen's own name below whichever row shares more letters with the query. Every term must match, so typing more words narrows; a title that starts with the query sorts above one that merely contains it.

Every chord this app binds is a letter. On a Norwegian layout [, ], {, }, | and \ all need Alt to reach at all, so a binding over one of them is a binding nobody here can press. Cmd/Ctrl+K opens the palette, the platform's own modifier plus B hides and shows the rail, T shows and hides a run's terminal, and ? opens the list of every key -- each matched as the character it produced, never as Shift plus a physical key. src/lib/shortcuts.ts decides every press as a pure function; hooks/use-app-shortcuts.ts is the one place a real KeyboardEvent is read.

Panels are sized in pixels

The rail is dragged by its right edge, and opens at 168px. It holds between 152px, where its longest word still fits, and 320px. What it was dragged to is kept in pixels per browser, a double-click on the edge gives it back its 168px, and the edge is a separator like the panel's: it takes the arrow keys, Home and End, and says what it resizes. Hiding it whole is still the platform's modifier plus B and Show navigation; a hidden rail has no edge. Below the breakpoint the rail is a drawer and is not dragged. The rail's width and whether it is hidden, and the right panel's width and whether it stands open, are what lib/panels keeps, each read out of storage as its store is built, so the shell renders at its settled size on the first paint rather than snapping into it.

What is in the right panel is the screen's. The panel itself is drawn once, in components/RightPanel, and a screen fills it with fillPanel from an effect that returns the unregister -- the same shape as registering palette actions, and for the same reason: a panel cannot outlive the screen whose data it is drawing. A tab is an id, a label and a render, and the open tab is remembered by id, so a screen that gains a tab does not move the reader onto a different one. A screen that fills nothing gets the line saying so.

Whether the panel stands open is the reader's; a screen opens it for one visit. The toggle is the only thing that writes the stored preference. A selection, or an address that names a row, opens the panel through openPanel for that screen alone, and the next screen starts from the preference, so a link into one screen never decides how every other screen first draws.

The top strip carries the screen's two facts. lib/screen-status holds one note and one identifier, stated for as long as a screen is mounted -- where a run's event stream is, and the trace the run is on. The note is data with a tone rather than markup, so what a connecting stream says is a pure function and the strip stays one line whatever it holds.

The right panel is dragged, and what is stored is pixels, not a fraction. What somebody dragged it to was a decision about the content in it -- wide enough for a log line, narrow enough to leave the table readable -- and a fraction re-decides that every time the window is resized. A window narrower than the stored width clamps for that session and leaves the stored number alone, so the intent comes back with the window. The drag handle is a separator and answers the arrow keys, because a panel that can only be sized by pointer is a panel some people cannot size.

One hook drags every edge. The rail's right edge, the right panel's left edge and the run terminal's top edge are the same sentences with a different coordinate read off the pointer, so hooks/use-drag-size takes the axis as a parameter rather than being copied per direction. The pointer is followed on the document rather than on the handle, and the size is written to the element's own style while the drag lasts -- a React render per pointer event leaves the edge visibly behind the hand -- with the store hearing one committed number on release.

The shell is a rail and a strip

The rail, the screen and the right panel are three columns, and nothing runs across them: there is no bar along the foot and no rule under the top. The rail stands on --sidebar, a rung under the page, and that step is its edge; the top strip stands on the page's own ground. Every strip in the top row is one height -- h-shell-top, stated once in index.css -- so the rail's head, the top strip and the right panel's tab strip put the mark, the crumb and the tabs on one line. e2e/shell.spec.ts measures every strip with getBoundingClientRect, in both palettes, with the rail hidden and the panel open.

The rail is the mark, the screens and the account, top to bottom. Its head is the brand tile and the word dirigent at text-lockup, which is the way to the dashboard. Its entries are single-line words with no icon, and a word that names a screen needs no gloss under it. They are grouped by what somebody is there to do: Dashboard alone at the head; Operate (Runs, Triggers); Build (Pipelines, Connections, Schemas); Reference (Blocks, Examples); and, for an admin, Admin (Users, Workers, Alerting). A group's name over its entries is the faint uppercase label at text-xs. An entry is muted ink, the pointer lifts it to --card and ink, and the active one is the --secondary fill at weight 600. lib/nav is the array the rail draws and the palette offers, and the line an entry carries about itself, and its mark, are data the palette reads.

The account stands at the foot of the rail, and the whole row is the control. A round avatar in --brand carries the username's first letter upper case in --brand-foreground -- one pair in every palette and both modes, at 8.1:1 -- then the username, and an up-and-down chevron at the row's right edge saying the row opens a menu. This is the one place a code stands in the UI face. The username is the account's code, and wherever it is addressed -- the accounts listing, the Account pane, a token's owner -- it is mono; here it is the reader's own name said back to them, which nobody copies or types from, so it reads as a name. GET /auth/me carries no display name, so the username is the title in the row and in the menu alike.

The account menu opens upward from the row. Its head is the avatar beside the username and the role, a plain word as everywhere else, with the instance's name, environment and version on the line under them -- said there once, and nowhere else in the menu. Then Settings, Keyboard shortcuts with the key that opens them, Appearance, a separator, and Sign out, each row led by its mark from lib/icons. A key is drawn beside a row only where lib/shortcuts binds one. Appearance is a submenu of Light, Dark and System with the one in force checked. The keyboard is the menu primitive's own: focus moves into the menu on open, the arrows walk its rows, the right arrow opens Appearance and the left one shuts it, and Escape closes the menu and gives focus back to the row.

The rail hides whole. The platform's modifier plus B and the palette's row take it off screen and bring it back, and while it is hidden the top strip carries Show navigation at its left, where the drawer's button stands below the breakpoint. Whether it is hidden is the reader's, and kept.

The top strip is the screen's crumb and the instance's corner. At its left stands the crumb: where a screen is one thing, the trail back to the listing it belongs to, drawn by Breadcrumb into the strip rather than onto the page, so the page opens on its heading. A listing has no crumb. At the right, the screen's note and identifier come first, and nothing a screen states there is something its own page already says -- how many rows were read is the table's, a standing fact is which version the instance holds rather than who applied it and when, and an instant is the house's relative form. Then the palette's button, which is the search glyph with the chord on its tooltip; the panel's toggle, where the screen has filled a panel; and the instance's dot, last.

A page's head stands still and its body scrolls. Under the top strip a page is two parts: the head -- the title, the code under it, a line of facts and the screen's verbs, which PageHeader and PageHead draw into the shell's head slot -- and the body, which is the one scroller. So the track starts below the verbs, never beside them. The scroller spans the content column to the window's edge with the page's padding inside it, and its thin track, in the line tokens, sits in the right margin that padding leaves: it takes no width the content had, nothing moves sideways when it appears, and the body and a docked terminal end at one right edge, the same edge as the head.

A fact appears once in the shell. How this instance is doing belongs to the dot, and nothing true of the session rather than of the screen stands beside the crumb.

The dot is the whole of what it says while the instance is well. Its details are its tooltip: the name and environment from /system/info, the health and when it was asked, the version, and every check. Words appear only when something is wrong: Server degraded, Server unhealthy, Server unreachable, retrying, beside the dot, in that state's ink. statusWords in lib/server-status decides it. A press opens the settings dialog's Server pane, which is what a finger has where there is nothing to hover with.

Small screens

The shell has one breakpoint, md at 768px, and one rule above it: at md and up nothing about the shell on this page changes. Everything below is what the same screens become on a phone. Nothing scrolls sideways. A page whose body scrolls horizontally is a defect, whatever is in it, and so is a listing that scrolls inside its own card.

Above lg a table has no breakpoint: it follows the width it was given. The window cannot answer for a listing whose box it does not know -- the same columns fit a 1024px window and overflow the moment a panel takes half of what was left. So from lg up the card form is the listing's own answer about its own box: a ResizeObserver on the listing's card reads the room it has, the table drawn in it reports what the columns need -- its scrollWidth at table layout, which is wider than the box exactly when they do not fit -- and a listing that cannot hold its table draws its rows as cards wherever it stands. lib/card-form is that decision, useCardForm is the observer that feeds it, and the form a listing settled on is what its own cells read through useListCards -- a cell that draws itself differently on a card asks the listing it is in, never the window. The columns settle first: the layout a title column is measured on is one it stands at its floor in, and a table narrower than the one that will be drawn says nothing about whether the real one fits.

Below lg a listing is cards, whatever it measures. Under 1024 the content column beside the rail is about 500px, and a listing of two text columns measures itself as fitting a table there: two columns of twenty characters, which is a table by arithmetic and a card by reading it. The window is a floor under the measurement rather than a second opinion about it -- useSmallWindow -- and everything above that floor is the box's own question.

The form has slack in it, so it cannot flap. A listing turns into cards when the table it drew overflowed its box, and remembers what that table took; it turns back into a table only once it has that width and SLACK more. Without the gap the width at which the table returns is the width at which it overflows again, and a window dragged across that edge would strobe.

A width is published before it is judged. A cell that fits itself to the listing -- the tags fold, a bounded column -- reads a width nothing has measured yet as no bound at all and draws everything it holds, so the first layout of a listing is wider than the one after it. The measurement sets the width and settles the form on the render that follows, and a face that arrives late re-measures behind it: a form settled on either of those layouts is a listing stuck in cards on a screen with room for its table.

A utility hides; useSmallScreen chooses. md:hidden on one of two renderings leaves both in the document -- every row and every control twice, two elements with one accessible name -- so where the same content is drawn two ways, the hook is what decides and only one is built. A class is still the answer for hiding a thing that has no second form.

The rail becomes a drawer. Below md it is off screen until a menu button at the left of the top strip -- icon only, aria-label "Open navigation" -- opens it over a scrim. It closes by its own close button, by Escape, by a tap on the scrim, and on every route change, because a drawer still standing over the screen somebody navigated to is a drawer they have to dismiss twice. Focus moves into it on open and back to the button on close. The account menu lives at its foot, as it does at the foot of the rail above the breakpoint.

A listing row becomes a card where the table will not fit, which on a phone is every listing. The table is one component and so is its narrow form: ListTable draws its first column as the card's head -- the title as the row's link, the code in mono once -- and every other column as a labelled fact under it, labelled by that column's own header. A column headed by nothing is a control -- a check, a revoke -- and it has no label row: it stands under the facts at the card's content edge, where the head starts, rather than in the value column beside a blank. cardFacts in lib/card-form is that split. What a row does as a table it does here: a row that opens a panel opens it, and a row that is a link is one. The card is the whole listing's form, header row and column widths included, so nothing on a narrow table has to be told how to shrink.

A column with nothing in it for that row is left out of the card, label and all -- and the cell is what says so, by returning null. A component that renders nothing is an element all the same, so a cell handing one back reads as a fact the row has, and the card draws a label with a blank beside it: a schema with no description under a Description saying nothing. The column decides, never the component it draws -- a tag strip with no tags, a duration for a run still going, a description that is empty are the cell's answer and not the component's -- and lib/card-form leaves out every fact that answered nothing.

The breadcrumb shows its leaf, and the crumb carrying the thing's code where the leaf is not it -- the code is on screen on every screen. The whole trail is the element's title; the leaf truncates with an ellipsis rather than wrapping the strip onto a second line.

A toolbar does not wrap, here or anywhere. A screen's verbs are data rather than markup -- ToolbarActions -- so that below md the primary action can stay on the strip while everything beside it moves into one overflow menu, aria-label "More actions", without a label being written down twice.

A dialog is a sheet. Full width, full height, its footer pinned to the foot and its body the part that scrolls. It is the generated dialog's own slots that are restyled in index.css, not a second dialog.

The right panel opens from the foot. Below md it is a full-height sheet over the screen, raised from a tab bar across the bottom carrying the tabs the screen filled -- so the graph screens are the graph, and what is beside them is a tap away. A screen that fills no panel has no tab bar.

A document is read, not written, below md. The source pane is read-only, the add-step and delete-step controls are not drawn, and neither is any of the three verbs -- Apply, Run, and the Validate that would dry-run a document nothing here can change. The strip says "Read only on a small screen" where the verbs were, and carries the document's identity and nothing else: what version it is at is in the top strip already, and a fact appears once on a screen. A graph is still read, a step is still chosen, and a run is still watched.

A segmented control that does not fit wraps; it never becomes a menu. The control is for a set somebody can see all of, so the set stays on the screen below the breakpoint too: one standing at a form field's height is a grid two cells wide there -- four options are two rows of two, an odd last one takes the width -- and the single row it always was at md and up. A label too wide for its cell wraps onto a second line, because a cut word says less than a folded one. Folding the control into a picker instead would hide behind a press exactly what it exists to show.

Every control a finger lands on is at least 42px tall, whatever element it is made of. The height is one token, --spacing-finger, and below the breakpoint every control takes its minimum from it: in index.css against the generated primitives' slots, against every trigger -- a menu's and a tooltip's carry the trigger's slot in place of the button's, so naming the button alone misses every filter, the instance menu and the palette button -- and against control-link, which is what a link drawn as a control wears: a drawer entry, a chip on a header strip, a row that navigates. The canvas's zoom cluster is sized there as well, being the graph library's button rather than one of ours. A menu opened at a point rather than under a control -- the canvas's right-click menus -- anchors a zero-sized trigger at the cursor, and data-menu-anchor keeps the rule off that one, because nothing draws it and no finger lands on it. Where a component draws a control of its own it is h-finger or min-h-finger. A control drawn as a square takes the width too, because a 42px tall target 28px wide is a target on one axis only. No screen sizes a control for a phone itself.

Text inside content is not a control. A card's title link, a timestamp, the link in a heading and a tag chip a row wears are the row's words rather than something drawn to be pressed; they keep the size the type scale gives them, and the row holding them is what the finger has -- which is min-h-finger already. The rule is about what the app draws as a control, and that is what makes it checkable.

A keyboard chord is drawn only where a keyboard is likely. The palette button is the search glyph at every width, with Cmd/Ctrl+K on its tooltip rather than on its face, because a phone has no modifier key to press and a chord on the strip says nothing to a pointer; below the breakpoint it takes the square the finger rule gives an icon button. The list of shortcuts keeps its keys, being a list about keyboards.

A kind may be drawn as well as named

A glyph that identifies a kind is information, not decoration. The one decorative element in this app is the login graph and that does not bend: a mark earns its place here by telling kinds apart at a glance, which a column of identical dots and four words cannot do. So a kind may carry a mark of its own, and only a kind may. A glyph never stands for a state, and one drawn beside a word that already says the same thing is the decoration the rule above forbids.

A glyph belongs to a kind, and lib/glyphs is the one place a kind is given one. A connection kind and a notifier are the same word -- slack names the credential's kind and the sender alike -- so there is one map and one function over it, kindGlyph, pure over the code the wire answers with. Adding a kind is adding one entry there and nothing else: no screen keeps a table of its own, and no screen works a mark out from a code itself.

The mark is drawn beside the code wherever a kind says what a thing is. The channel strip's chips, a rule's target, the connections listing's row, the connection's own dialog, the New connection dialog's kind rows, the pipeline editor's reference row under a field naming a connection, and the health rows on the home screen: a reader who learned the envelope on the strip reads it on all seven. A screen that says what a thing is and draws no mark is the fault this rule exists to stop, because the same kind looking different from one screen to the next is the reader's problem rather than the screen's. The blocks catalog is the one screen that names kinds and draws none: its rows are the registries themselves, where the kind is the row rather than what the row is, and it lists schemes and notifiers in the same shape beside them.

The mark does not take the kind's word away. Where the kind is said -- the connections listing's row and the connection's dialog, as the mark and the kind in mono, with no hue, because status is the only colour those two carry -- the word stays beside the mark: it is the value somebody types into a document, and a mark cannot be typed. Where it is not said the mark stands with the code alone and the kind is not spelled twice: a channel chip and a rule's target are the mark and the code and nothing else. The dialog's kind rows and the editor's reference row spell it as well, for the reason the picker's rows do -- the kind is half of what the row is found by, and the reference row has no code of its own to stand beside, the box above it already holding one.

A pack declares the mark its own kind wears, and it is one path or it is nothing. A ConnectionKind carries mark: the d of a single SVG path on the same 24-unit grid, which reaches the app through the catalog and is held once in lib/marks. kindGlyph(kind, marks) is still the one place the question is answered -- the built-in map wins for the kinds it names, a declared path is drawn next, and the neutral glyph is what is left -- so a screen still asks one function and keeps no table. A declared path is a string off the wire, so it is drawn only when it is path data and nothing else: non-empty, ASCII, path-data characters, at most 4 kB, which is what registration held the pack to and what this bundle checks again before a browser sees it. A notifier declares none, so the channel strip and a rule's target draw the marks this bundle names.

A kind with no glyph takes the neutral glyph rather than a gap, which is the same one a palette row with no icon takes. Kinds arrive from whichever packs an instance installed, so a pack's kind that declares no mark of its own gets a mark rather than a hole beside the kinds this bundle was built knowing. Something that is not a kind at all carries neither a mark nor a neutral one: a worker row on the health panel holds the mark's width so the codes under it line up, and draws nothing in it.

Two kinds that are the same sort of thing may share that sort's mark. kafka and rabbitmq are both queues and both wear the queue; the code is always beside the mark, and it is the code that says which of the two is in front of somebody.

The glyph says what a thing is and the dot says how it is, and neither does the other's job. A glyph never takes a status colour and a dot never says which kind something is, so a card carrying both says two facts rather than one loud one.

What the kinds this repository ships wear: an envelope for email, a scroll for log, the Slack mark for slack, a webhook for webhook, a globe for http, a branch for git, a database for sql, a container for docker, a drive for s3 -- a mark says what the credential opens and an s3 one opens storage, where a cloud would say where a thing is hosted, which half this list could claim -- and an ordered list for kafka and rabbitmq. A glyph is size-4 in muted ink wherever it is drawn; what it sits in belongs to the thing drawing it -- the palette row gives it a size-7 tile on --muted, a channel chip draws it bare.

lucide draws every glyph that is not a brand, and a brand mark comes from the brand. lucide ships no brand icons at all. A brand mark is Simple Icons' where it carries one and the brand's own media kit's where it does not -- Slack is the second case, so its mark is the paths off slack.com/media-kit, kept inline in src/components/alerting/SlackMark.tsx, the shape untouched and scaled onto the same 24-unit grid every other glyph is drawn on. A brand mark is monochrome here, which is what a kit's monochrome variant is for: it takes the surface's ink in both palettes like everything else on the strip, where a mark in its own colours would be the loudest thing on a screen whose colour means a state. A mark a pack declares is held to the same two rules: one path on the 24 grid, filled in currentColor.

The channel strip

The strip is one wrapping line of chips, and a chip holds three things, and a fourth when something is wrong. The kind's glyph at 16px in muted ink, the code in mono, and an 8px dot: 36px tall, on the control's rung, on the card's ground with the app's own border. A credential that failed or has proved nothing says so after the dot, in healthOf's word; a healthy one, the log and a kind nothing is set up for say nothing. Nothing else is on it -- no time, no sentence -- because a paragraph per channel is a screenful of channels on a screen whose subject is rules.

A connection is a channel and a notifier is a kind, so a chip says the connection's code and the glyph says the kind: five email credentials are five envelope chips reading ops-mail, billing-mail, and so on. Only a kind nothing is set up for is one chip reading the notifier's own code, and so is the log channel, which has no credential to name.

The dot has three colours and no more. Green where the channel can deliver -- the log notifier, or a connection whose last check passed. Red where the last check failed. Grey for everything else: a kind nothing is set up for, a credential nobody has checked, a probe that proved nothing. A reader learns three colours once; six would be a legend.

The rest of the words are the tooltip's, and a credential's are the connections listing's: healthy, failed, not verified and never checked are healthOf's, read through it rather than said a second way here, so one credential reads the same wherever it is drawn. The strip adds two of its own, for the two things that are not a credential: ready for the log channel, which nothing checks, and not set up for an installed notifier no credential exists for. The tooltip is this app's own primitive, it opens on hover and on keyboard focus alike, and it holds, in this order: the kind and the state word (email · failed), how long ago the check was where there was one, the connection's code in mono, and the check's own sentence in full. Nothing on the strip ever says "connection" or "built in": a reader knows they have not set up Slack, not that a credential of kind slack is absent.

A chip goes where its channel is. One with a credential opens it at /connections/<code>; a grey chip with no credential opens /connections?new=<kind>, which is the connections screen with its dialog already on that kind; the log chip goes nowhere, because there is nothing behind it to open.

Every chip answers a pointer and the keyboard the same way, the log chip included. It takes the row wash, the focus ring every control here takes, and its tooltip on hover, on focus and on a click alike -- the log chip is a button rather than a link, and nothing else about it differs. A chip drawn inert beside its neighbours would be saying it is a different kind of thing.

The strip is ordered by the dots: green, then red, then grey, and inside one colour the notifier's code and then the connection's. A notifier's chips stand together only where their dots agree -- the channel that stopped is what the strip is read for. channelsOf answers in that order, so no screen decides it.

Below md the heading is the fold, and it carries the answer. A chevron, the word Channels, and at the right one dot per channel in the same colours and the same order, nothing else. It is folded on every visit: what a phone is on this screen for is the rules and the queue. Opened, it is a table of three columns -- the dot; the glyph and the code; and either Set up <notifier> for a grey chip with no credential or how long ago the check was -- and a row with a credential is a link to it, like the chip.

An option wears what its value wears

A dropdown whose options are values the app already draws somewhere -- a status, a kind -- renders each option as that drawing: the status filter's rows are the same chips the listing shows, and the trigger carries the chip it chose. Choosing becomes recognising. An option never wraps; the menu grows to its longest row instead of folding a label in half.

The menu is floored at its control and capped at the screen. It is never narrower than the control it hangs from, so a menu is at least as wide as the thing that opened it; it is never wider than its longest row needs, nor than the screen less a 16px gutter at each edge. It hangs from the control's start edge, swaps to the end edge where starting there would put it off screen, and slides along where neither edge fits -- so a menu never scrolls sideways and never hangs off the screen. Only where the longest row is wider than that cap does a row give way, and then it is the label that ellipses.

A string option is drawn bare, and every other value wears its JSON. A generated form's enum is words a document carries -- GET, all_success -- so the row and the trigger read GET, not "GET"; a number, a boolean and null are drawn as the JSON they are, which is what keeps 2 and "2" two choices in an enum holding both. The quoted spelling is the token a select addresses a choice by and it is never on screen: the trigger draws the option, not the token.

A choice between looks is made on the looks themselves. Where the values of a setting are appearances -- the three palettes -- the control is one card per value showing that value, and the card is the radio: role="radio" inside a radiogroup, one tab stop for the group, the arrows moving and choosing in one gesture, Space and Enter choosing what has focus, and a two pixel accent ring on the chosen card and on the focused one. The chosen card also carries a check in the accent, because a ring alone is a colour doing a mark's job. A swatch is drawn from the tokens it stands for and never from a colour written down beside it.

A surface is inset once, and 16px is the inset

Every surface this app draws -- a dialog, a card, a panel pane -- holds its content 16px in from its own edge, and the generated primitives already say so: DialogContent is p-4 and Card is py-4 with px-4 on its content. So a surface writes no padding of its own. A py-2 or a py-8 laid over a card is a second answer to a question the card has already answered, and the three states of PageState proved it: the same card carried 24px of vertical padding while reading, 24px when empty and 48px when refused, for no reason anybody could state.

A surface that lays out its own body zeroes the primitive's padding and spends the same 16px itself. The command palette is the one: a search row, a scrolling list and a footer rather than a form, so index.css states the inset once as --palette-inset and every part of the dialog stands on it -- the search glyph, a shelf heading, a row's icon tile, the footer's key chips. A strip that crosses the whole width (the generated footer's -mx-4 p-4, the palette's search row and footer) takes the inset back inside itself, so the seam runs edge to edge and the words still land on the edge. A strip's height is the inset above and below what it holds, which is what makes a 24px line of search text a 56px row.

Half the inset is the only other number, and it is the gutter a floating box gets. A list of rounded rows needs room or the highlight touches the surface's sides, so the list is padded 8px and each row is padded 8px again: the wash is inset 8, its content lands on the 16 like everything else. A box nested inside a surface that is already inset takes the same 8 -- Refusal inside a form or a dialog is p-2, not a second 16.

A surface scrolls; a block inside it does not. A dialog above the breakpoint has no height of its own, so one that outgrows a short screen is capped at max-h-[calc(100vh-4rem)], laid out as a column, and its body is the part that scrolls -- the header and the verbs stay where somebody reaches for them, which is the same arrangement index.css gives every dialog below the breakpoint. A block that caps itself instead puts a second scroll inside the first and cuts its own content off at a line nothing drew: a settings group capped at 40vh hid the last password box under its own fold, and what was on screen was a label with no control beneath it. So the three tall dialogs -- a new rule, a new connection, and an apply whose offer opens a form in a row -- cap the surface and scroll the body, and nothing between the header and the footer declares a height.

A preview of what already exists is the one exception, and it takes a pane's height. A surface that asks for a decision has to draw the thing being decided about -- the schema this instance holds, under the sentences saying what replacing it would do -- and that reference material has no length of its own. Given its own height it grows until the sentences and the controls that act on it are below the fold: the apply dialog's differs row drew a 283-line shape as 5110px of pane and left its own Cancel and confirm 4904px past the foot of the dialog's scroller, and even a sixteen-line shape with ten dependants left them 98px past it. A reader must never have to go looking for the control the surface is asking them to press. So a preview declares a height and scrolls inside itself, which is what holds the sentences and the controls still, and the whole of it stays one press away in the window every pane offers.

The height is the one that leaves the controls on screen, and it is measured. It is a pane's height rather than a share of the viewport -- max-h-40 on the block of JSON in that row, nine lines, which is what the dialog's own cap leaves on a 768px screen once the sentences have had their room. What it is measured against is the scroller the controls sit in, found by walking up from the control: compared against the dialog's own box a clipped control reads as visible, which is how this got past two rounds of review.

A preview, and nothing else. The exception covers read-only reference material -- a block of JSON, a stored body, the definition a field's code resolves to -- and it does not reach a form, a field, or anything else with a control inside it. That is the fault the rule was written for: a settings group capped at 40vh hid the last password box under its own fold, and what was on screen was a label with no control beneath it. Everything else between a header and a footer still declares no height -- and it came back in three dialogs at once, each in that same shape: the run dialog capped the parameter form it wrapped at 50vh, the new source capped its sample at 32vh and the new schedule its pinned parameters at the same, every one of them inside a surface that declared no height of its own. So two of the three stood taller than a 768px window with nothing to scroll, and the third hid 1372px of its own form under a fold nothing drew. A share of the viewport named anywhere but on the surface is the tell, and it is what a scan can read.

A preview's height is the block's, not its callers'. Where one component draws every preview of a kind -- JsonBlock is the block of JSON everywhere -- the height is stated there once, so a call site cannot leave it off and two call sites cannot disagree about it. A surface with less room to give narrows it from outside and says what it measured, which is what the apply dialog's carried row does; a surface with more room does not widen it, because the window is where the whole of a thing is read.

A row expands under itself rather than opening a second dialog

Where a row's verb needs a form -- changing a password on the settings dialog, minting the connection an apply's carried offer says this instance has not got -- the form opens in that row's own place, under it: its fields on one line, and Cancel beside the verb, right aligned at its foot. A dialog raised over a dialog puts a scrim over the thing it is about, and the row that offered the verb is already the sentence saying what the form is for. The button that opened it is replaced by the section while it stands, so there are never two controls that shut it, and the refusal is Refusal inside the section, where the fields it is about are. One row at a time: two open forms in one list are two half-typed things with one confirm behind them. The exception is the connection dialog and the account dialog, which are one dialog being tried and not a second one: Edit is the same surface, and the only thing ever raised over one is the ConfirmDialog every verb that takes something away goes through.

What the row already says, the form does not ask. The row is the summary, so a box repeating a value drawn a line above it draws that value twice and offers to change something the row's own case depends on. A form that stands inside another decision opens on what is required and folds the rest, because the list the row belongs to has to stay on screen beside it.

A form reached this way is the same form it is anywhere else. Its state, what it sends and why its confirm is shut live in one place that both surfaces read -- lib/connection-draft and hooks/use-connection-draft under ConnectionFields, drawn by the New connection dialog and by that row -- because a second copy of a form is a second set of rules about what a blank box means, and one of the two will be the one with the credential in it.

The step panel's engine groups are the same gesture at rest: the row is the group's own summary, and its fields open under it rather than in a pane of their own.

An account's tokens are listed under its row. A token authenticates as one account, so it is filed there rather than in a second table with an Account column. A chevron at the head of the row opens and shuts it, with a tooltip saying which, and does nothing else; pressing the rest of the row opens the account's dialog, and the heading's own tooltip says so. Several accounts may stand open. What opens is the same ListTable every listing is, inset under the row and reading GET /users/{username}/tokens when it is opened and not before. It keeps a cursor of its own and is capped at 24rem with its heading stuck, so an account holding hundreds does not push every other account off the page. A closed row says how many live tokens it holds, from one read of GET /tokens, and says nothing at all where that read did not reach the end, because a number off part of a listing is not a count. An account with none draws No tokens. when opened. New token is on the row, so a token is never made for the wrong account, and the row opens to show what it made.

A token row is its name, the three times it has, and Revoke. There is no Prefix column: the name is what a token is known by. The prefix is drawn beside the name only when two live tokens of one account share that name, which is the one time the name stops telling them apart, and its tooltip says what it is. The expiry is a chip beside the name, only where a token has one; Last used and Created are columns. Revoking asks first, and takes the one token the row is. The API revokes by id, so a row's own Revoke reaches that row and no other. The question names the token the way the row does: its name, and its prefix beside the name where two live tokens share it.

A control only ever claims what the situation supports

Three faults are one fault: a button labelled with its mechanism rather than its outcome, a heading that enumerates a column the rows already carry, and a confirm that cannot confirm. Each is a control asserting something that is not there. What a control says and offers is what the situation can actually give, and everything below is that rule applied.

A dialog that asks is a decision, and a decision needs both paths. A way back, and a way forward that does something. Where the forward path does not exist, the control that would open the dialog is shut and says why, and no dialog opens: the pipeline editor's Apply is shut on a document that is already the version the instance holds, because a dialog whose own answer is that its button would write nothing is friction with a question mark on it. Where there is nothing to decide -- the outcome is certain and harmless -- the action happens and reports itself in one line, the way every other action that completed without incident does.

A footer has one dismissal word, and it is Cancel. A dialog that asks carries Cancel and its confirm, and nothing else in its footer. A dialog that only tells carries no footer at all: it is dismissed by the corner's close and by Escape, so no footer ever says Close, Done or Dismiss. DialogShell is that rule written once -- confirm given, the footer and no corner close; confirm absent, the corner close and no footer. A dialog that reads one thing is the third case, and it is a connection's: the corner close, and a footer of verbs on that thing rather than a confirm, so nothing in it says Cancel because nothing has been started. A dialog that asks and then tells changes case when it has answered: a token made, whose secret it now shows, and a test message sent, whose delivery it now reads, take the corner close from that moment, because nothing is left that a Cancel could take back.

One dismissal, one control. A dialog whose footer carries the way out does not also draw the corner's own -- showCloseButton={false} -- because two ways out of one dialog are two answers to one question, and a reader counting controls is counting wrong.

A confirm is drawn only where pressing it would do something. A plan the instance would refuse, a carried code that collides with what the instance holds, a connection nobody has created: in each the apply cannot proceed, so the button is absent and the sentence beside the rows is what says why. The exception is a control shut by who somebody is, which stays drawn and shut with its reason, because that is a courtesy about the account rather than a claim about the document -- and it is how somebody learns the verb exists and is not theirs. A run's verbs are the second exception while the run is unsettled: what it allows changes under the reader as it moves, so Pause, Resume, Cancel and Rerun keep their places and are shut there with the reason. A settled run has stopped moving, and draws Rerun alone, as the run screen says below.

A shut control looks shut. It drops its fill for a hairline ring on no ground, its word goes to the muted ink at weight 400, and the pointer over it is not-allowed -- so a shut verb beside an open one is never read as the quieter of two open ones.

A form that is not filled in yet is not this rule. A confirm shut because a required box is empty, because a write is in flight, or because the reader has not typed what the kind asks for, is a forward path that exists and is not ready -- it stays drawn and shut, and filling the form opens it. What is not drawn is a confirm whose outcome the situation cannot produce however long somebody sits in front of it.

A shut control stays reachable. disabled takes a control out of the tab order and stops it taking a pointer, so the sentence saying why is readable by nobody on a keyboard and by nobody hovering the control itself. A verb on a screen's strip is shut with aria-disabled instead: assistive technology hears the same thing, the control keeps its place in the tab order, the title is reachable both ways, and components/ToolbarActions swallows the click.

A write that can be refused says so, and a shut control says why

Every write states its refusal, and there are two places to state one. A dialog or a form has room beside the thing that was refused, and that is components/Refusal; a row's button and a panel's verb have none, and that is sayRefusal, the one toast this app raises about a request. A write whose rejection handler is a comment saying the row keeps what it had is a write that failed in silence. Nothing dismisses on a refusal: a dialog that closed and a panel that emptied would both have said the request went through.

The refusal is read from the problem document, not from the status. Problem.title is the status phrase this server sets it to, so a dialog headed by it says "Unprocessable Content" on every refusal it can make; problems is the list behind the sentence. lib/refusal is where that is decided once -- including that a detail which is only its own problems joined is drawn as the list alone rather than as the same sentence twice.

A refusal is drawn in this interface's words, not in the ones the server sent. A refusal arrives with a dotted code, the params its sentence was written out of, and that sentence already rendered into detail by whichever Python process minted it. The code and the params are the translatable pair, so what is drawn is LABELS.refused[code] written out of the params; detail is drawn only where neither this table nor the instance's contributed one holds a sentence. Drawing detail by preference is how a translated interface ends up saying translated chrome around an English refusal. A pack's codes are not in labels.ts -- this bundle cannot know which packs are installed -- so lib/wording reads those once from GET /labels, and a read that fails leaves every refusal on the fallback, which is what was drawn before it landed. scripts/check_refusal_labels.py is what keeps the fallback from quietly becoming the rule again.

A control an account's role would have refused is shut, with the sentence saying why. The API has two role gates and so does lib/roles: an operator applies, runs and schedules, and connections, schemas, accounts and tokens are an admin's. useMayWrite is what a control asks, and it is the only reader of the role, so no screen can have its own idea of what a viewer may press. This is the same courtesy the rail's admin section is -- the server is what refuses -- and what it buys is nobody pressing a button to be told off. A disabled control takes no pointer events, so the sentence goes on components/Refusable around it as well as on the control.

A required field is not a complaint until somebody has been there. A form marks and states a problem only for fields that have been left or that a submit asked about; the button the caller owns is what stays shut in the meantime, saying why. A box drawn red the instant a dialog opens is telling somebody off for opening it.

Nothing wears interactive chrome unless it does something

A row lights under the pointer whether or not it can be opened -- .row-hover, one wash from the surface ladder, weaker than anything meaning chosen, spanning the row's full width the way a menu item's does. The cursor is left alone on a row that opens nothing: a pointer finger is a promise. A panel nobody has filled has no tabs, so its strip carries a plain label rather than one lone tab shaped like a control. A button that takes something away -- revoke, deactivate, cancel -- wears .destructive-action, which tints critical on hover and says what the click costs.

Where a stock behaviour exists, it is the behaviour. A menu that marks a choice puts the mark in a reserved gutter on the left, in the same column on every row, and hangs off its own trigger. The generated files in src/components/ui/ are pristine, so what this app wants of them is said in index.css against their slots, never by editing them.

A graph fits what it draws

A node is 44 tall, two lines, and as wide as its words, on every canvas. The title in mono, and under it the block it runs, with the key in front where the title is a name. No key is ever cut: before elk sees a box, lib/node-size measures each of its lines in the face the node sets it in -- on a canvas of its own, in the loaded font, read off the characters until the faces have loaded -- with every glyph, mark, count and dot that stands on that line, and elk is handed the widest plus the box's chrome, between a floor of 96px and a cap of NODE_MAX_WIDTH. Only past the cap does a word truncate, and it carries its whole value on hover. The editor's box holds the room of its edited mark before it is edited, so the first keystroke into a step moves no box. A box is filled in --node, edged in a line a step above the canvas, and lifted by a shadow of its own; a status is the only colour on it -- a succeeded box takes the succeeded edge, a failed one the failed edge at 1.5px over a wash of the same hue, a skipped one a dashed edge on no fill -- and a chosen box is ringed in the foreground, which is no colour at all. A document's config is a map of any size and one truncated line of it crowds the box, so the step's own pane holds the whole of it. A step whose for_each fans it out says so on the second line -- a stacked glyph and the count, or the word "each" where the list is an expression a run resolves -- in the neutral ink that line is already in, because a status is the only colour a node carries. On a run the state's dot stands at the end of the title line, and the duration at the end of the second. What a step is doing -- what it waits for, how a retry went, why it failed -- is the box's tooltip and the panel's, not a third line, because a line nobody can read at 11px across 138 pixels says less than the colour of the box. A run's fan-out holds its elements in a strip beneath the two lines, and an element's key wraps rather than truncates: the chips widen the box up to STRIP_ROOM and then wrap onto as many 16px lines as they need, and the box is that much taller. A retry's countdown is given its widest room, so a box does not move every second. Past ITEM_STRIP_LIMIT elements the strip counts them on one line. Before a grid read from a step's output has expanded, that strip is the faint words waits for <step>. Elk is told the height each is actually drawn at, or the rows come out spaced for a box that is not there.

Every word on a box carries its whole value on hover. A title, a key and a block each carry their full value on their own title, with what the step is doing after it on a run.

A source says what it listens to where a block would be. A step with source: runs no block, so its second line is what it listens to and its title leads with ICONS.source, on a dashed box. In the editor that is the instance's own reading of the source -- listensTo, POST /endpoints/signups or what a polled kind says of itself -- and the document's kind until an apply has made one. On a run it is the kind, which is what the pinned graph carries. A reply says reply there, on the editor's canvas and its step panel as on a run. A step that is none of the three says no block.

A canvas on a page is as tall as its drawing, or as tall as the room the run screen leaves it. The pipeline's page draws the graph in a box whose height follows the layout's own proportions at the zoom the width allows, so a five-step chain is a strip and not a stage with a strip floating in it. The run's page is a column whose graph takes every pixel between the heading and the docked terminal -- less the failure panel, where there is one -- so there is no dead ground under a small graph, and dragging the terminal's seam is a resize the canvas refits to. It is never shorter than its drawing or 240px, whichever is less, and a drawing taller than that is fitted at the floor and panned. The editor keeps a stage, because it is a workspace somebody adds to.

A canvas on a page pans and zooms; a plain wheel is the page's unless the canvas is the screen. Dragging the ground pans every canvas. A pinch, and a wheel with Ctrl or the platform's modifier held, zooms a canvas on a page about the pointer, each event held to one WHEEL_ZOOM_LIMIT step, so a mouse wheel's notch is a step and a pinch follows the fingers. On the pipeline's page, whose canvas is as tall as its drawing and has panels under it, a wheel with neither scrolls the page, so a long page never traps it. On the run's page the filled canvas is the screen and there is nothing to scroll past it, so a plain wheel zooms it the same way, one WHEEL_ZOOM_LIMIT step per event about the pointer, and the page under it never scrolls. A trackpad's two-finger scroll is a plain wheel to the browser and cannot be told reliably from a mouse's, so on the run's canvas it zooms too, as it does on React Flow's own default canvas; the ground is dragged to pan. Below the breakpoint a finger on a page's canvas scrolls the page, and the graph's window is where it is panned. The zoom controls say how else to zoom on their tooltips, by the screen they stand on, and nothing is drawn over the graph to say it.

A view's layout is the reader's for the visit. The run's graph and the pipeline page's carry a Layout control: the direction (left to right, or top to bottom), the edges (the canvas's own stepped line, elk's orthogonal routes around the boxes, or elk's splines) and the spacing (normal, compact, roomy). The default is exactly the layout every canvas opens on; a routed or curved edge is drawn along the points elk answered, with each edge into a step arriving on its own rather than merged into a trunk. The choice is held by the screen and nowhere else -- not in storage, not in the address -- so the next screen, and the next visit, open on the default. The editor has no such control, because a document's arrangement there is the author's.

A view opens in a window. Under a canvas on a page stands WindowedPane's strip with the window's button. The window is a stage: the wheel zooms, the ground pans, and its corner carries the zoom, the fit and the layout, which is the same choice as the page's. A box chosen in the window is chosen on the page -- the address and the panel follow it -- and the window stays open until it is dismissed.

Every canvas opens with the whole DAG in view: fitView with padding, held between the two bounds in lib/dag-layout. FIT_MAX_ZOOM is 1, so a fit never draws a box above its own size and a pipeline of two boxes is not magnified to fill the space. FIT_MIN_ZOOM is 0.8, so a fit never draws a 13px title under 10.4px or an 11px word under 8.8px. Past that floor the graph is not shrunk: it stays at the floor, hangs off the canvas, and is panned. A view too wide or too tall for the canvas opens with the start of the graph at the top left and the rest off the far edges, because a graph is read from its first steps and a centred one has both ends cut, and dragging the ground is how the rest is reached. Zooming further out is still there by hand, down to MIN_ZOOM, and that view is the reader's. It re-fits when the node set changes, when the reader changes the layout, and when the canvas is resized -- the right panel opening and the terminal's seam moving are resizes, a page scrolling is not -- and stops the moment the reader pans, zooms or drags a box: after that the view is theirs, and the fit control, or Re-layout in the editor, is how they hand it back. A press that moves nothing, on the ground or on a run's box, is not a pan. A resize of a view the reader took over only brings the chosen box back onto the canvas, at their zoom, if the resize cut it. The browser suite asserts every node's box lies inside the canvas for a graph that fits at the floor.

A deep graph is wrapped onto rows rather than drawn as one. Seventeen steps in one left-to-right row is three thousand pixels of canvas, and a fit answers that by putting a node's text under six pixels. Past WRAP_WIDTH -- which rankDepth measures a shape against before elk sees it -- elk is asked to cut the layering into chunks and stack them: the ranks still read left to right, and the height nothing was using carries the rest.

The zoom controls are this app's. React Flow's own step by a fixed twenty percent, which on a graph read at half size is four presses to nothing, and its fit button fits with the library's options rather than the ones both canvases share. ZOOM_STEP is one press either way, and the fit button is fitCapped, the same one every other fit goes through. A canvas with nothing on it has nothing to zoom, so it draws none of them, only the control the screen adds. On a page they stand in the graph panel's bar -- zoom out, zoom in, fit, layout -- rather than over a drawing that is only as tall as itself; a stage carries them in its corner.

The canvas edits

One canvas, two modes. GraphCanvas is a view until it is handed editing, and then it is an editor: draw an edge, delete one, move a box, finish moving it, drop a connection on empty ground. The run's graph passes nothing and is what it was -- a step is selectable there and nothing else, because what it draws already happened and no verb would change it. The same split is on the box itself: StepPorts draws the two anchors React Flow routes an edge to, and only the editable mode makes them ports -- visible, connectable, .dg-port in index.css. A run's graph keeps the anchors and shows none, and the browser suite asserts that.

An edge is a depends_on entry, and it is one edit however it was made. Dragging between two ports and choosing a step from the chips in its own pane end in the same withDependsOn against the same local document, so the editor's strip counts a drawn edge with everything else unapplied and nothing is written until an apply. lib/graph-edits is that seam: withEdge and withoutEdge answer with the document they were given when it already says what was asked, so drawing an edge twice is a gesture rather than an edit. A selected edge and the Delete key take one away, and so does a selected step -- withoutStep takes its name out of every other step's depends_on in the same edit, because a depends_on naming a step the document no longer declares is what an apply refuses. Only one thing on the canvas is chosen at a time, or one press of the key would take two away. React Flow answers neither key while the focus is in a text box, so a Backspace meant for a config field is never a step.

A loop is refused before it is drawn, and the refusal names it. cycleThrough walks the document's own edges and answers the shortest loop the new edge would close -- parse → active → report → parse -- which is what the toast says. $apply refuses the same document; a canvas that accepted the gesture would have answered a pointer with a round trip.

elk places a graph; a reader places a box. A dragged position is that pipeline's own, kept in lib/canvas-layout under dirigent.layout.{pipeline} as px-intent, exactly as the right panel's width is -- what somebody dragged a box to was a decision about that pipeline's shape, and it survives a reload. A step nobody has moved keeps elk's suggestion, which is what a step added to an arranged canvas gets. Re-layout clears the arrangement and fits the view to what elk decides afresh.

A step is added from one menu, wherever the asking started. A right-click on empty ground opens it at the pointer -- on a document with no steps as much as on a drawn one -- the button in the corner opens the same menu under itself, and a connection let go over empty ground is "and then this": it opens where it was dropped, says which step the new one will wait for, and the step lands there. A right-click on a box opens that box's own menu instead: one more step after this one, which is the same add-step menu with the edge already decided, and this one taken out.

The menu is shelved the way the catalog is, and searched the way the palette is. The rows are the groups the blocks themselves declare, which is what the Blocks screen shelves by, so the menu invents no filing of its own and map.jq and transform.jq land on one shelf. A sensor sits in its group like anything else and wears the chip saying it waits, because a chip on every row would say what the absence of one already says. Typing replaces the shelves with flat breadcrumbed results -- shell ▸ run -- every term narrowing, sensor and operator matching by kind; the arrows walk them and the return key places one. The first Escape clears the box and the second closes the menu. lib/add-step is all of that as pure functions, so what the shelves hold and what a search finds are decided in Node.

The key is derived, not asked for. A menu that places a step on one keystroke cannot stop to ask what to call it, so transform.jq lands as jq and the next one as jq_2, and the step's own pane is where it is renamed -- alongside everything else about it.

The run screen

What failed, why, and in which step lands above the fold. The heading is the pipeline's title with its code and the run's instant in mono under it; one line under that says the status word, the step it ended at, how long it took and who started it; then the graph, filling the room down to the terminal. Under the graph a run that did not end well stands its failure panel across the width -- the step in mono, the block, the catalogued code, the sentence the step failed with, the connection the step named with its health where it named one, and the parameters the run was given -- and the terminal opens at the foot with every line the run wrote. At 1440 by 900 the failure starts above the fold. A run that ended well has no failure to stand. Choosing a box on the graph narrows the terminal to that step and opens it in the panel.

A run's verbs stand in one place while the run can still move. While a run is unsettled, Pause, Resume, Cancel and Rerun are drawn at the right of the heading, in that order, and a verb the run's state does not allow -- Pause on a paused run, Resume on a running one, Rerun on either -- is shut in place with aria-disabled and its reason on hover and on focus, because the run's state changes under the reader and a row that grew and shrank with it is a row nobody can learn. A settled run draws Rerun alone, as the primary: it will never be paused, resumed or cancelled again, and three shut verbs that no state of it will ever open are controls claiming what it cannot give. Cancel takes the critical wash while it is open.

The chosen step is the address. Choosing a box sets the address to /runs/<run id>/<step>, and that address loaded chooses the step and opens its panel; the ground, or closing the panel, puts it down and the address goes back to /runs/<run id>. A fan-out's element and a try of the chosen step ride in the query as ?item=<run item id> and ?attempt=<attempt id>, drawn open and marked and brought into view, and they are dropped when another step is chosen; ?tab= is kept. Every move between steps replaces the address rather than adding one, so Back leaves the run. An address naming a step the run does not have opens the run with nothing chosen and one line saying so. lib/step-address is that, for the run and for the pipeline's page alike.

A try and an element carry their ids. Each attempt row in the step panel draws its attempt id, and each element of a fan-out's output its run item id, in mono with the copy button the run id has. The step's code is the panel's heading and is not drawn again on either.

A run is named by when it started. A run has no name of its own and its id is a handle for machines, so what heads one is its pipeline's code and the instant it began, spelled the house way and with the id on hover. Run is the word that stands in over the pipeline's name until there is a run loaded to name -- the palette's shelf while the read is in flight -- and it is followed by the pipeline and the instant the moment there is one. A queued run has not started, so every one of those readings takes started_at ?? created_at and none of them can draw a blank.

How a run was started is one fact, said once. startedAs in lib/runs is the one phrase, on the runs listing's Trigger cell, the pipeline page's Recent runs, the run's header and its Trigger section. A person or a token is named by the label alone -- agent (token mcp-trial) already says it was a token, so api token is not drawn beside it -- and the header reads it after by. A schedule, a backfill or a source is its kind with the code it names, schedule nightly. A run started by a step of another names that run by its short id, a step of run 32ee6409, and a rerun the run it repeated, rerun of 32ee6409 by admin; on the run's Trigger section that run is the link. What the server recorded, uuid and all, is on hover. The Trigger column is prose: it truncates in its share rather than widening the table.

The runs grouped by pipeline. The Runs screen's View switch shows one row per pipeline that ran in a window (?group=pipeline&window=24h; 1h, 24h or 7d): the title by the quartet, the newest run's state, a dot and a count per state it has runs in -- a state with none is not drawn -- the most recent runs as dots, oldest left and newest right, each a link to its run with its state and when on hover, when it last ran, and the median duration of the settled ones. A row opens the flat list of that pipeline over the same window. The strip is cut from the left where the cell is narrower than it, so the newest run is the one always shown.

Two durations, measured at two altitudes. The run's is started_at to finished_at, beside its status chip; a step's is the earliest start of any of its attempts to the latest finish of any of them, drawn at the far end of its node and again as the step's took. They are not one number seen twice, and the step's is a bound rather than a first and a last: a fan-out's elements settle out of arrival order, so what the stream said first is not what the step began with. A running step counts against the screen's own one-second tick, which only exists while the run is unsettled, and a duration with an end missing reads -- rather than a number that would keep climbing after nothing was happening. formatDuration is the one ladder -- 840ms, 9.4s, 42s, 2m 4s, 1h 12m -- and a decimal survives only under ten seconds.

A step is titled by the quartet's rule and its block sits under it. The first line is the step's name where it has one and its map key otherwise, wearing the mono face when the title is the key; the second is the block the step runs, with the key in front of it and a middot between where the title was a name. So the key is on screen exactly once, whichever way round the step was written, and what a step actually runs is never a click away. What the step is doing -- what it is waiting on, what it saved to, what went wrong -- is on the box's tooltip, and the panel says the same things as facts rather than as a second sentence.

A live wait's fraction is a foot line, never a number. A step whose attempt reports how far its wait has come draws that as a 3px line in the hue of the box it sits in: along the node's bottom edge, inside its border and taking no height, and directly under the attempt row's waiting message. It is a progressbar with aria-valuenow, the node's tooltip gives the percentage, and no figure is drawn beside it, because the message already says how far. It is drawn only while the attempt is waiting or running: a settled attempt still carries the last fraction it reported, and progressOf in lib/run-detail is the one reading that drops it. No progress draws nothing.

The terminal drawer

A run's screen stacks: the canvas above, and a console across the foot of the content area at the height somebody dragged it to. The right panel is beside both and is untouched by either -- what is beside a run is a different question from what is under it. It is opened from a glyph in the run screen's own strip, from a row in the palette, and by pressing T, which is a bare letter because every chord this app has is already spoken for.

The terminal is the run's one log. The run screen draws no second list of the same lines. A run that failed opens on the terminal, for that visit and without writing the preference; any other run opens it as the reader last left it. Choosing a box on the graph narrows the terminal to that step, the way its own step filter does, and a running run tails live in it. The failure panel says what failed in its own words -- the step, the block, the catalogued code, the sentence -- and the terminal carries every line, the failing one among them, so the two repeat each other in that one line and nowhere else.

The drawer docks in the page's scroller. It stands at the foot of the body, held to the bottom of the one scroller while the body above it scrolls, so the panels above it and the drawer share one right edge.

The lines are the run's own state, not a second read. runs/{id}/$events already carries every line the run wrote; the drawer filters what the reducer holds, opens no connection of its own, and never will -- run-stream.test.ts drives the whole of the drawer's data path through follow and asserts the concurrent-connection counter still says one.

Every step interleaved, in arrival order. What happened to a run is a sequence, so the console is one, and the step select is there for whoever is asking the other question. Each line is time, level, the step as a clickable prefix, the message, and whatever fields the line carried rendered muted after it. The prefix opens that step in the panel through openPanelTab, because somebody clicking one is not asking for whichever tab they last had open.

It is dark in both palettes, and it is the only thing in this app that is. A console is not a card -- it is a code block's cousin -- so the six --terminal-* inks are declared once and the dark palette leaves them alone. The ground alone has two depths: graphite on the light palette, where black reads as a hole in the page, and a rung below the dark palette's own background, where the drawer has to read as deeper than the app. Its header strip is ordinary app chrome on the surface ladder, which is what keeps every control in it a design-system control with nothing re-inked.

Three filters compose, and the count says how far. A level threshold, one step, and a match over the line are three predicates over one list; a foot line says "{shown} of {total} lines". Client-side narrowing here is not the listing rule being broken: a listing's filter must be the server's because a listing is a window onto rows nobody has read, and this is the opposite case.

Copy takes what is on screen and download takes everything. $logs answers a page of JSON with no content-disposition, so the download walks the cursor through apiFetch and writes the NDJSON out of a blob rather than pointing an anchor at a path that would navigate to a listing.

Open, dragged height, and the tail-following preference are all held the way the panels are: pixels rather than a fraction, under dirigent.terminal*, with the drag on a separator that answers the arrow keys.

Every screen teaches

A page is a title, and a section heading is a heading. An empty state states the fact -- "No runs." -- and adds a second plain sentence only where the way in is not on the screen, such as a document key or a CLI verb. It never narrates a button that is on the screen: what New does is what New says. The second one is a sentence, not a clause hung off a dash: "No blocks installed. Plugins contribute them." is the shape, and every empty state on every screen is that shape or the bare fact alone -- one of them punctuated its own way for long enough that the catalogue could show it beside the others. A way in that is off screen is stated wherever it is off screen, so a listing whose rows are declared by a document says so even where a button beside it declares one too. A listing carries an API chip linking /docs#/<tag>, the fragment Swagger UI writes on its own tag headings, so the requests behind a screen are one click away. Every screen states its own two facts in the top strip through lib/screen-status, the address nothing answers for included.

A heading names the category, in one noun where a noun exists, and never lists the values of a column beneath it. The Status column is already telling every row which state it is in, and a heading that enumerates two of them breaks the moment a third qualifies. Nor does a heading name the reader's situation: that states an obligation the screen cannot know anybody has. Where a noun for the category already exists anywhere in this product -- in the wire, in the engine, in the CLI -- that is the noun, because a screen may not coin a second name for something already named. A chart is titled with what it plots, and the window, the bucketing and the axis stay in the line beneath it.

The palette's anatomy

A 768px card at 15% from the top: a search row, shelved rows, a footer of key chips. The screen's own shelf leads -- shelve in lib/palette puts every shelf whose rows claim screen first, and the heading is free to name what it is scoped to, such as the run being read. A row is an icon tile and a title, nothing else. What an action says about itself (hint) feeds the filter and is never drawn: beside every title it is a column of glosses saying what the titles already say. A row with no icon gets the neutral glyph rather than a gap.

Adding a screen

  1. A component under src/pages/, built on PageState -- loading, refused, empty, content, four states that stay distinct all the way to the screen. Collapsing "refused" into "empty" is how an instance whose database is unreachable gets told it has no pipelines.
  2. A <Route> in src/App.tsx, inside the AppShell route.
  3. An entry in NAV in src/lib/nav.ts. The rail draws that array and the command palette offers every entry in it, so nothing else has to be told the screen exists. An admin-only screen goes in the section whose requires is admin -- which hides it, and hides nothing from the server, because role gating here is a courtesy rather than a control.
  4. Anything the screen alone can do, registered with registerActions from an effect that returns the unregister.
  5. If the screen has something to show beside itself, fillPanel from an effect; if it has one thing to say about what it is doing, setScreenStatus. Both are undone the same way.
  6. If it needs a dependency no other screen needs, a lazy route rather than an import.

A test asserts on a lib function, never on rendered markup. What is worth testing is the wire layer and the pure decisions -- the fetch choke point, the SSE parser, the palette's filter, the shortcut rules -- and all of it runs in Node with no DOM. Rendering is the browser suite's job, and that suite drives a real dg dev rather than a mock.

Every word has a code, and they all live in one file

src/lib/labels.ts is the only place a user-visible string may be written. Every heading, button, field label, placeholder, empty state, tooltip, aria-label, column header, confirmation, palette row and settings row is a label in that catalogue, and a component reads one rather than holding one. It is the same shape the server has had all along: dirigent_common.messages keeps one Catalogue per area, mints a dotted code per message, and offers a walk over all of them at once, so that a refusal can be rendered in another language by code. This is that in TypeScript's idiom -- a section per area, the path through LABELS as the code, a duplicate name refused by the compiler, everyLabel() as the walk.

A sentence the wire drives is a template, not a function. Everywhere else a value in a sentence makes that label a function, because a caller in this bundle passes the value and the compiler checks the call. A refusal's values arrive as a map off the wire, so there is no call to check and nothing for a signature to buy: the refused section names its holes instead -- No run {run_id}. -- and the name has to be one the server's own template writes. That is the one place in the catalogue where a value is not a parameter, and scripts/check_refusal_labels.py holds both halves to each other so a renamed param fails the gate rather than sending the renderer quietly back to English.

The code says what the string is for, never what it says. connections.check_row is still right when its words become Ask again; connections.check_now_button_text is wrong the moment anybody edits it. Concretely: name a label after the outcome it is on the way to, not after the mechanism that gets there or the phrase currently in it. Codes are lowercase words joined by underscores, nested one level further where a single concept has more than one surface form.

A concept holds its surface forms together. A run that ended with failed items is a chip on a row, a number on a tile and a clause in a sentence, and those were three phrases in three files until they became state.run.completed_with_errors.{chip,tile,sentence}. Putting them under one code is what makes a fourth spelling visible while it is being written. The same goes for the health words, the worker words and the run states: one concept, one code, its forms beneath it.

A noun is one entry, and the case belongs to the surface rather than to the word. A field's label, a column header, a card's fact and a section heading are Sentence case; a definition-list term, a chip and a fragment inside a sentence are lower case. Where the product draws one noun in more than one of those, the entry holds each form it actually draws, under the surface it is drawn on: label for the singular a control or a column wears, heading for the plural over a section or a table, term for the lower-case singular a definition list reads, count for the sentence a listing's foot counts in. A form nothing draws is not written down, and nothing derives one form from another -- capitalising in code compiles English's capitalisation rule into the app, which is the one thing this catalogue exists to keep out. Started beside started, and Blocks beside a per-screen blocks, are that rule not yet applied.

A counted surface holds both numbers, so count is a {one, many} pair. A foot counts rows and a row may be one of them, so the entry spells the singular as well: count: {one: 'schedule', many: 'schedules'}, and counted picks. A word that also stands alone in a sentence is written again under term, because that is a surface of its own -- the two hold the same string in English and need not in a language that declines a counted noun, which is exactly the difference a shared entry would hide.

A verb is one entry in action, and the states of its own control sit under it. The word on the button, the word while the request is in flight and the word once it has landed are one concept, so they are verb, busy and done beneath one code -- never a Creating minted again on each screen that writes something. A pair of verbs that toggles one thing, Pause and Resume or Enable and Disable, is two entries side by side there rather than a pair per screen.

A counted sentence is a {one, many} pair, never a ternary and never two sibling codes. `${count} field${count === 1 ? '' : 's'}` is English's plural rule compiled into the catalogue, and a language with three plural forms, or with none, cannot write itself into that string; two codes a stem apart, stores_one beside stores_many, are the same pair with the concept pulled apart. So both forms are spelled whole under one code and the component picks between them -- the shape five entries already had while five more were still branching inside the string.

A read in flight says Reading, and says nothing else. Naming what is being read -- the artifacts, the report, the corpus -- adds no fact the pane it is drawn in has not already given, and "from the server" adds none at all, because every read in this app is from the server. One code on every card, button and pane whose answer has not landed.

A string with a value in it is a function taking named parameters, so a language that puts the value somewhere else can. Nothing here reads a locale and nothing chooses a table; a second language is const nb: typeof LABELS = {...}, which the compiler checks for a missing code, a stray code, and a sentence that takes different parameters. That is the whole reason the catalogue exists, and the reason it is not worth arguing with.

What is not a label: a wire value (completed_with_errors, cron), an element id, a data-testid, a class name, a URL or a path, a media type, an icon name, the key cap in a <Kbd>, and the keywords a palette action is filtered on but never draws. One word a person reads is spelled outside the catalogue and it is named there: a state a plugin contributed, which this bundle was built before and cannot hold a table of, is spelled from the wire by statusLabel.

scripts/check_ui_labels.py holds the rule, in make static and make ui-lint: it refuses a literal between JSX tags, at an attribute or property a person reads, in a toast, or anywhere at all if it is sentence-shaped, and it refuses a label in the catalogue that no source reads. It cannot see a bare lower-case word returned from a lib function, because nothing reading the text can tell one from a wire value -- review and scripts/ui_copy.py cover that.

Documentation that quotes a label backticks it and marks it { .label }. A released note once named a screen's heading in running prose, the heading was renamed, and the note went on saying a word the product had stopped saying with nothing to catch it. Nothing could: docs/ holds 652 distinct backticked phrases and a handful of them are labels, so a check that guessed would be mostly false alarm. The author says which instead:

The account dialog's `Reset password`{ .label } button ends every session of the account.

scripts/check_docs_labels.py, in make static and make ui-lint, then refuses a marked phrase the catalogue no longer holds, exactly as written, naming the page and the line. It is attr_list, so the phrase renders as the inline code it already was; a marked page needs nothing else, and a marker inside a fenced block is teaching the syntax rather than quoting the product. A phrase quoted with no marker on it is not checked, which is the price of not guessing -- so a label named in prose is named this way or the sentence is written not to need it. What the server says is not a label and takes no marker: a refusal's own wording is held to Python by scripts/check_refusal_labels.py and by dirigent-cli/tests/test_shared_words.py.

Interface copy is plain, and most of it is absent

A label, a hint, a description in the interface is plain product English: "Timezone", "Follow logs", "Change your password." The house voice -- the wry sentence, the deliberate turn of phrase -- belongs in this documentation and in commit messages, never in the words a person reads while working. If a native speaker would not put it on a settings row, it does not go on a settings row.

The context says most of it; a sentence survives only by adding a fact. Concretely:

  • A screen carries no subtitle. "Pipelines" needs no line saying it lists the pipelines.
  • The top strip's note exists only when it is stateful -- the editor's shape and warnings, the run's settled tone, the workers' health. A static note restating the screen is noise.
  • A section heading over a headed table carries no gloss; the headers name the columns.
  • A dialog's description earns its lines by teaching semantics the controls cannot show (what sealing a secret means), never by describing the dialog.

A copy defect is a pattern, not an instance. Fixing one gibberish line means sweeping every screen for the same pattern in the same change -- the ui-review skill says the same.

The door greets, and its eyebrow is the one line of text in the app set in the accent. It is the body size, uppercase and tracked out, in --primary-ink: the door is the one screen with nothing on it to act on but the button beneath it, so the colour that means action is free to name the product there. The login screen alone carries that eyebrow, a heading, a one-line subtitle, placeholders and leading field icons, because it is the one screen a person meets before the product's own facts are on it: there is no data to read, no title to take a heading from and no shell around it, so the screen has to say where somebody has arrived and what to do next. No other screen may copy any of it -- an eyebrow over a page title, a subtitle under a heading, a placeholder repeating a label or the heading it sits under, or an icon inside a field is a defect everywhere behind this door -- with one exception, the search glyph that leads every search box, which says what kind of box it is rather than repeating a label. A listing's search box says Search and nothing more, from components/ds/SearchField: the heading over it already names what is being searched, and the noun and what a match is made over stay on its accessible name, which is read without that heading beside it. A box on a surface whose subject no heading states says what it matches over instead -- a picker's Search by name or code, the terminal's Search lines, the palette's own row.

The brand pane never carries a tagline. What is on it is the mark, the word dirigent, and the instance and the version the door answers for. A line saying what the product is for is marketing on the one screen whose only question is who is asking, and the graph behind the lockup already says what is through the door.

Design system

Every recurring shape on a screen is one component in src/components/ds, and a page does not hand-build what one of them covers. A lead cell, a chip, a dot, an icon-only button, a search box, a heading inside a screen, a list of facts, a field, a notice, a dialog, a page heading and a link are each drawn one way, by the component below, and a screen that typed the classes out again is a second answer to a question the system has already answered. /design draws every component in every variant and state -- enabled, shut with a reason, hovered, focused, empty, loading, long text truncating -- in both modes; it is offered by the palette ("Design system") and not by the rail. A component that needs a new variant gets it in ds/ and on that screen first, then on the page that asked. The gallery is named for what somebody is looking for, not for how the code files it: Text (Titles, Headings, Links), Marks (Labels, Status dots, Icons), Controls (Buttons, Search, Form fields, Switches), Containers (Notices, Dialogs, Lists of facts), with an index beside them that follows the scroll and a plain sentence under a heading only where it adds a fact. A component keeps its own name in the code. A specimen stands on the section's card with no frame of its own.

Component When
EntityHeading Any addressable thing headed by its name and code. row-stacked for a listing's lead cell, row-inline where a row has one line, panel for a panel's head, dialog inside a dialog's title. The name, else the code; the code always on screen in mono and never twice (headingOf); chips beside the title; an optional one-line description. One title weight in all three
Chip A word on a chip: neutral, mono for a key, dashed for an absence, good, critical, edit. onSelect makes it a button, onRemove adds an X with its own focus ring. It replaces the hand-typed chip and the pill Badge for a word. KindChip and StatusChip stay, on the same 20px and the same radius
ToneDot Every state drawn as a dot, from one map: a tone (good, critical, warning, info, quiet, unknown, the last hollow) or a run status. A dot alone is the answer -- a green dot says connected -- and its word is its name and its tooltip. The word is drawn beside it only when it is news: failed, unknown. tip adds a sentence to the tooltip, and focusable puts the dot in the tab order where nothing around it is a control. StatusChip takes a label where a screen names the state in its own terms
IconButton Any button that is an icon and nothing else, which is why every icon button has a tooltip. Its type requires label; the tooltip is the label, a longer tooltip, or why when it is shut. Sizes xs, sm and md. Shut with why, never with disabled, so it stays reachable
SearchField Every box that narrows a screen. A leading search glyph, a clear button once there is text, Escape to clear, and the / key cap: / moves the caret into the first box marked for it, never while another field or the editor has focus
SectionHeading A heading inside a screen. section is sentence case at text-sm font-semibold; group is the one faint uppercase label over a run of rows. An optional aside and link
FactList, Fact Facts about one thing. grid is a term column beside a value column, a panel's and a dialog's, whose term is a key in mono where termMono says so; row is small facts wrapping on one line, a heading's strip
FormField, SwitchField Every field: a prose label or a mono key, the required word in body ink, a hint, the default, help in one ink, an error, one 6px gap. SwitchField is a boolean setting as a row, the whole row its label
Notice, Well, Inset, CodeFrame Notice is a sentence that must not be missed, critical, warning or neutral, with the tone's mark. Well is a group that belongs together, on the panel ground at 8px. Inset is reference material -- JSON, a code block, a log -- on the page's ground. CodeFrame is a code editor drawn as the field it is, and WindowedPane puts its pane in one. A refusal off the wire is still Refusal
DialogShell Every dialog: a width from sm, md, lg, xl, a title and description, a body that scrolls under a fixed head and foot, and a footer of Cancel and one Refusable confirm, or of verbs on a dialog that reads one thing
ConfirmDialog Before every verb that takes something away -- revoke, deactivate, delete, rotate. The title is the question and names the thing, its code in mono and nowhere else: Delete ops-hook-2?. There is no body, because the verb says what is lost. It holds one line only when that line changes the answer: what stops the verb, which shuts the confirm with the reason on its title, or what the verb takes beyond the thing named. A refusal off the wire is one short line, with no box and no second naming of the thing. A verb that cannot go without one value holds that one field: a password reset's new password. No such verb is immediate
SecretReveal A secret the server answers with once: the value in mono, a copy button that says it copied and resets, and the line saying it is shown once
CopyButton The icon button beside a string somebody pastes elsewhere -- a secret, a URL, a command line: it puts the string on the clipboard, says it copied, and resets
PageHeader, SplitButton A screen's heading, at text-page. It owns the primary action through actions, last of them, which is a SplitButton when it carries more; a count beside the title; a code under it in mono for a detail screen; a trail for a detail screen. No subtitle
TextLink Every link that is text. inline in prose, in ink and underlined, data for a number or a reference in a table, aside in the muted ink beside a section heading, external with its mark and a new tab
Code Every code, key, block and kind in mono. One line, truncated with an ellipsis, the whole value on title; tone is the muted ink beside a title or the body ink where the code is the value
Panel Every section of a page: a title bar with the label left and a count or fact right, a hairline, a body on the panel ground, a hairline edge and no shadow. graph puts a dot grid on the body, critical is the failure with its top rule and warning mark. inset is off where the body's rows lay out their own padding. A listing is ListTable, which draws the same panel
MiniGraph A pipeline's shape at the scale of a row: the miniature rung, sources dashed, fan-outs stacked, a failed step filled, the scale shared and capped

A mark comes from lib/icons and nowhere else. ICONS maps every concept the interface draws a mark for -- create, edit, delete, run, rerun, pause, resume, cancel, refresh, recheck, clear a filter, search, copy, external, settings, fit the view, open in a window, upload, a template, the sidebar, and the nouns: pipeline, run history, step, block, connection, schema, trigger, schedule, source, user, token, worker -- to one lucide icon, named for what it means rather than where it was first drawn, and no two concepts to the same one: fitting the canvas is Scan and opening a pane in a window is Maximize2. A page or a component that needs a mark with no concept adds the concept there. A kind's mark is still lib/glyphs.

Nothing is fully round but a status dot and an avatar. A chip, a control, a card, a dialog and a switch take the radius ladder's rungs, and no class names a radius of its own.

scripts/check_ui_system.py holds the system, in make static and make ui-lint. Under src/pages and src/components, the generated primitives and components/ds excepted, it refuses a hand-typed word chip (one class string holding rounded-sm, border, px-1.5 and text-xs), a Button sized icon* rather than an IconButton, an import from lucide-react (which it refuses in components/ds as well), and an arbitrary rounded-[...]. What predates the check is recorded in scripts/ui_system_offenders.txt, one line per rule and file with its count. It is a ratchet: a file may hold exactly what is recorded, one more is refused as new and one fewer as stale, so the change that moves a screen onto the system shrinks the record with --write in the same commit.

An ellipsis means the action asks for more before it acts

A control's word ends in … when pressing it does not yet do what the word says, because it needs something more from the person first: a file, a choice from a picker, a form. From a file…, From a template…, Add step after…, Run…, Send a test…, Reset password…, Change…, Set up slack…. The same action is written the same way on a button, a menu item and a palette row. What it opens is titled without the mark, because by then the question is on screen.

These do not wear it, and each is a decision:

  • A create following the New rule below. New, New token and a palette's New alert rule or Create an account say what they open, and every create is one.
  • A verb raising ConfirmDialog, and an Apply opening its plan. Those ask for a yes and nothing else, so Delete, Deactivate, Revoke, a source's New token and Apply stay as they are. A confirm's own verb is the act. Reset password… wears it because that question also needs a new password.
  • A word whose outcome is the opening. Edit is true the moment it is pressed: you are editing. Add a window adds the section. Change… wears it because nothing has changed yet.
  • A place. Settings and Keyboard shortcuts are somewhere to go, not an action.
  • A control with no word. The canvas's + is an icon, and its tooltip names it.
  • An action that acts. Use as template copies and goes there, and Validate answers at once.

A create button says New

On a screen whose heading names the noun, the primary create button says New alone: the screen supplies the object. The full name stays on the control's aria-label, on the palette action, which is offered from every screen and so needs the noun, and on whatever the button opens. A screen offering more than one create -- Triggers -- keeps the noun on each button, because New alone would not say which. The create is the strip's primary either way, and wears the fill. A create that belongs to one row is on that row and names its noun: New token, on the account it is for.