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. Both are loaded lazily -- 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 IBM Plex 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 A JS linter cannot see inside a className, so the type scale gets a check of its 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 and the pipeline editor, because React Flow and elk together are larger than the whole of the rest of this bundle; 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 four 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 React Flow and elk put together, 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 has no monaco grammar and is registered in CodeEditor as a Monarch tokeniser. Its three web workers are ?worker imports, which vite emits as chunks of their own.

A chunk two screens want is fetched before either is asked for. warmEditor is that same import, 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. Nothing waits on it, it happens once, and the login screen is outside the shell and asks for none of it.

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 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.

Three type sizes, and the named sizes outside them

The scale is 12px, 14px, and 16px. Nothing arrives beside it without a name.

Class Size What wears it
text-xs 12px Chips, badges, counts, timestamps, identifiers -- anything read at a glance rather than as prose
text-sm 14px Any sentence. The body size of the app
text-base 16px A page heading, and the command palette's search field
text-stat 28px mono A stat tile's number, and nothing else on any screen
text-wordmark 48px The login brand lockup's wordmark, and nothing else on any screen
text-display 36px The sign-in heading, and nothing else on any screen

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.

scripts/check_ui_classes.py fails the gate on anything outside them, whether the fourth size arrives as text-[13px] or as text-lg. The check has no allowlist -- it knows text-stat, text-wordmark and text-display by name, and reads index.css itself so a fourth 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 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 in index.html 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, and nowhere else. 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 topbar's toggle is the two-way flip over Appearance, and System is reachable only here.

Three palettes ship, ordered quiet to loud: dirigent, paper, contrast. The default is near-achromatic surfaces at hue 255; 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 identity hue in every one of them. --primary, --ring, --terminal-accent and the run status hues keep the hue they have; a palette moves the amber's lightness only where its own ground needs it, and "one accent, spent where the app acts" holds unchanged in all three. 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.

The surface ladder runs --background under --sidebar under --card under --secondary, and each rung has to be seen. A control has a ground of its own, --field, a rung below the page in a dark mode and above it in a light one, and it is edged in --input; --muted is the wash a row takes under the pointer. Two lines divide: --border between rows inside a panel, --border-strong between panels. Ink runs --foreground, --muted-foreground for a hint, --faint for a timestamp beside a name. Every surface holds hue 255 at chroma at or below 0.017, so a long list reads as a surface.

The identity colour is amber, and it is spent on action and nothing else: a primary button, the active rail entry, a focus ring. It sits on --primary / --primary-foreground, because that is what every shadcn primitive reaches for. --accent keeps shadcn's own meaning -- the wash under a pointer -- and is a quiet tint of the same hue.

--primary-ink is the amber as text, and it is worn by the door's eyebrow and by a link. A fill is a ground with its own ink on it, so a light palette's --primary is taken down far enough to carry light text and does not hold 4.5:1 as text itself; the ink twin is the same hue taken further down until it does, and in a dark mode, where the accent already is an ink, it is --primary. It is the same -ink pairing every status hue has. Behind the door the amber stays a fill and an inline link's ink and nothing else: a link is a thing the reader can press, so it wears the colour that means action, and it wears the ink twin because it is text. A screen that set a heading, a label or a hint in it would be spending that colour on something nobody can press.

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 -- a primary that reads as the brand on near-black can read as mustard on near-white, a control filled by a rung that is a clear step in one mode can be invisible in the other. --primary carries dark ink in the dark mode and light ink in the light one, for that reason and no other. 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, so what a wider window buys that column is spent evenly either side of the one question it asks.

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, 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 4px for a chip, 6px for a control and a card, 8px for a panel, stated in @theme rather than derived. Tailwind's middle rungs are flattened onto it -- rounded-md through rounded-2xl are all the card's 6px -- so a shadcn primitive that ships rounded-xl lands on the ladder without being edited. The login's brand tile is the mark, not a card, and keeps its own radius.

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 webhook, 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 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 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 heading row, the striping, the row that reaches for the next page and the line along the foot; a screen decides its columns and what each cell says, and nothing else. Where a row opens something beside the table -- a connection's form, a trigger's history -- 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.

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.

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. Three screens go stale standing still -- the runs listing, the dashboard and the admin overview -- 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 starter'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. 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 table is already narrowed by rather than replacing it. The pipelines table is where that lives, because a tag drawn on a row somebody cannot press is a fact with a gesture missing from it.

The lead column keeps half the table at every width. A row's identity -- its title, its code, its description -- is what somebody scans a listing for, so the first column is w-full max-w-0, truncates with the whole value on hover, and carries a floor of half the listing's width that nothing beside it may bid down. The Tags column takes at most a quarter, and every other column on the row is shrink-to-content and says its piece on one line -- bounded too, so a column whose own words would push the identity under its floor is cut with the whole of it on hover rather than taking the room from the title.

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. Below lg a row is a card and the 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.

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 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, and the hook that asks elk for positions -- 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 identity amber 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 amber 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 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 -- 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 four are spelled, and the schema is what decides this and nothing else: every other string stays one line, however long a value it holds.

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 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 <kind> · <state> 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.

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.

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.

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 starter and From 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 file… on the listing hands the editor what it read and From a starter 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.

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 starter'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.

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 folds 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 240px with labels and 56px of icons with the labels back as tooltips, and the width it shows labels at is dragged between 160 and 320 -- narrower and the labels it exists for truncate, wider is dead space the longest label left behind. lib/panels holds those numbers beside the two widths and the open flag, 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.

The status bar is the shell's; the two facts on it are the screen's. 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 bar stays one row 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 all three edges. The rail's right edge, the right panel's left edge and the run terminal's top edge are the same three 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 has two rules, and each is one line

Rail, content and right panel meet in a strip along the top and a bar along the foot. Every strip in a row is the same height -- h-shell-top, h-shell-foot, stated once in index.css -- and the rule between the strip and the work is drawn once, never once per column: the top rule is a single full-width element the shell owns, and the foot is a single full-width bar with two cells inside it, the settings cell tracking the rail's own width store. Two elements each drawing half a border is a line that comes apart at a column edge, and the bottom of the app then reads as a step. e2e/shell.spec.ts measures every strip with getBoundingClientRect, in both palettes, with the rail collapsed and the panel open.

A fact appears once in the shell. The instance's name, environment and version belong to the corner identity and the health popover behind it; the status bar carries the screen's own two facts and the account, and never repeats them. What a screen states there is what nothing else on it already says -- how many rows were read is the foot of the table's line, not the bar's -- and an instant is the house's relative form rather than a locale string.

Settings lives at the bottom of the rail's column, in that bar; the instance's identity -- a dot, its name and environment from /system/info, its version -- lives at the right of the topbar, and opens the health popover. The rail's own entries are single-line labels -- Dashboard, Pipelines, Runs, Triggers, Connections, Blocks, Examples and Schemas, with the admin section under them -- and a word that names one of those needs no gloss under it. lib/nav is the array the rail draws and the palette offers, and the line an entry carries about itself is data on the entry that the palette reads.

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.

A table has a second breakpoint, lg at 1024px, and the width is why. At 768 the content column is what is left of the window after a 240px rail and the page's own padding -- about 500px -- which is a phone's width with a rail in front of it, and a listing of four or five columns drawn into it puts a tag chip over the title beside it. So a listing takes the card form below lg while every other rule on this page turns at md, and useNarrowTable is that question rather than useSmallScreen.

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. Settings lives at its foot, where the rail's own cell in the status bar puts it above the breakpoint.

A listing row becomes a card below lg. The table is one component and so is its small 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 with nothing in it for that row is left out of the card rather than drawn as an empty label. What a row does above the breakpoint 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.

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 along the foot 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.

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 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.

Kind glyphs are drawn on the alerting screen's channel strip, and nowhere else today. The notifiers this repository ships wear an envelope for email, a scroll for log, the Slack mark for slack and a webhook for webhook. 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.

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.

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. lib/glyphs is where both are decided, once, as a pure function over the code the wire answers with: notifiers arrive from packs, so a channel this bundle was built before gets a card with a mark on it rather than a hole where every other card has one.

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.

The channel strip

The strip is one wrapping line of chips, and a chip holds three things. The kind's glyph at 16px in muted ink, the code in mono, and an 8px dot: 36px tall, pill-shaped, on the card's ground with the app's own border. Nothing else is on it -- no state word, 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 words are the tooltip's, and they are the whole vocabulary: ready, failing, never checked, not verified, not set up. 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 · failing), 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.

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 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 -- 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.

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.

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; what a person acts on is detail, and problems is the list behind it. 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 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

An editor node is two lines and a run node is three. A document's node is the step's title -- its name if it has one, its map key otherwise -- and the block it runs; its config is a map of any size and one truncated line of it crowds the box without answering anything, so the step's own pane holds the whole of it. A step whose for_each fans it out says so on its mono 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. A run's third line is live state -- what a step is waiting for, how a retry went -- and stays. Elk is told the height each is actually drawn at, or the rows come out spaced for a box that is not there.

Both canvases open with the whole DAG in view: fitView with padding, held between the two bounds in lib/dag-layout. FIT_MAX_ZOOM keeps a pipeline of two boxes at its own size rather than magnified to fill the space, and FIT_MIN_ZOOM keeps a node above the size its 14px title stops being readable at -- a graph too big for that is panned to rather than shrunk past legibility. It re-fits when the node set changes and when the canvas is resized, and stops the moment the reader pans, zooms or drags a box -- after that the view is theirs, and Re-layout is how they hand it back. The browser suite asserts every node's box lies inside the canvas.

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.

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 topbar 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

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.

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. The third line is what the step is doing -- what it is waiting on, what it saved to, what went wrong -- and the panel says the same things as facts rather than as a second sentence.

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 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. 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 along the foot through lib/screen-status, the address nothing answers for included.

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.

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 status bar'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 an icon inside a field is a defect everywhere behind this door.

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.

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 -- Users and tokens, Triggers -- keeps the noun on each button, because New alone would not say which.