UI conventions¶
The web UI is a single-page bundle served by the API server itself, from
packages/dirigent-server/frontend. This page is authoritative for how it is built; where the
code disagrees with this document, one of the two is a bug.
Nothing here is about what the screens show. That is decided per screen, from a design board, and each one arrives as its own change. What follows is the frame every screen is built inside.
Every UI change is walked against this page in the live browser before its PR merges, which
is what the ui-review skill does. Build the branch first -- make ui-static, because a checkout's
dg dev serves the packaged bundle where there is one -- then open every screen the diff touches
in the dark mode and again in the light one, at 390x844 as well as at a desk width: the finger
rule, a listing's card form and the panel sheet exist only below the breakpoint. Screenshots go
under .playwright-mcp/review/, which is never committed, and the PR body says the pass ran and
what it changed. A change to a token is read in all three palettes. A convention that proves
wrong is changed on this page first and in the code second.
The stack¶
| Piece | What | Why it and not the obvious alternative |
|---|---|---|
| Build | vite 8, TypeScript 7 with project references | tsc -b gates the build, because vite strips types without checking them |
| UI | React 19, react-router 7 with BrowserRouter |
The server answers a navigation nothing claimed with the shell, so paths stay clean and a deep link can be pasted |
| Styling | Tailwind v4, CSS-first | There is no tailwind.config.js and there must not be one -- the theme lives in src/index.css under @theme |
| Components | shadcn on Base UI (@base-ui/react), style base-nova |
The generated files in src/components/ui/ are pristine and never hand-edited |
| Graphs | @xyflow/react for the canvas, elkjs for the layout |
React Flow draws what it is given and decides no geometry; elk's layered algorithm is what places a DAG. React Flow rides in the graph screens' chunk and elk is a chunk of its own, in a worker -- see below |
| Editor | monaco-editor with monaco-yaml |
A pipeline document is YAML checked against a schema, and squiggling an unknown key where an apply would refuse it needs a language server rather than a textarea. Its own lazy chunk, behind CodePane, which is what every screen that writes source mounts -- a document, and a config field whose schema says it carries a program |
| YAML | yaml |
A document crosses the wire as JSON and is written by people as text, and lib/pipeline-document is the one place it is parsed and rendered. Nothing in the entry chunk reaches that module, so the parser rides in the chunks of the screens that read a document |
| Markdown | marked, for its lexer only |
A description is authored markdown and has to render as prose. Nothing here produces an HTML string, so what is drawn is a token tree React escapes -- see lib/markdown |
| Palette | cmdk | |
| Toasts | sonner | |
| Mode | next-themes, class strategy |
|
| Fonts | IBM Plex Sans and JetBrains Mono, self-hosted through @fontsource |
No CDN: a UI served from a private network has to work on one |
| Lint | oxlint, plus scripts/check_ui_classes.py and scripts/check_ui_system.py |
A JS linter cannot see inside a className, so the type scale and the design system get checks of their own |
| Format | oxfmt, pinned exactly, configured in .oxfmtrc.json |
Prettier's output from the linter's own project, and it sorts Tailwind classes against src/index.css rather than guessing at the theme |
| Tests | vitest in environment: node, playwright for the browser |
|
| Packages | bun, always. Never npm, yarn, or pnpm |
Base UI is the primitive layer, and app code never imports it. Pages and components import
from @/components/ui/* and nothing else reaches for @base-ui/react directly. That boundary is
what lets the component layer be swapped without a change anywhere above it, and other apps in
this family are on a different primitive underneath the same imports.
A screen whose dependencies no other screen has is a lazy route. src/App.tsx reaches it
through React.lazy and a Suspense whose fallback is the same PageState loading card every
read shows. The entry chunk is what every reader pays for on every screen, so it holds the login
screen, the two listings this app is mostly read through -- pipelines and runs -- and the 404,
and everything else is fetched when it is opened: run detail, a pipeline's page and the pipeline
editor, because React Flow is the canvas nothing else draws on; the dashboard at /, which composes a screenful
of reads nothing else composes; connections, triggers, schemas, blocks and examples, each
carrying the forms, the dialogs and the panels nothing else uses; and the three admin screens,
which most readers of this app are never offered.
A chunk inside a lazy route is the same rule again. Monaco is larger than everything else
this app depends on, and every pane in this app that writes source or windows it mounts
components/pipeline/CodePane: the editor's source pane, a step config field whose schema
published a contentMediaType, a schema document, an alert rule's expression, an example's text,
and the read-only window a produced value is opened in. CodePane is the one React.lazy
reaching CodeEditor, so opening a pipeline does not fetch an editor and opening its source tab
or a jq step does. monaco-editor as a whole registers every language it ships, so what is
imported by path is the api entry, the suggest controller a schema-checked buffer cannot do
without, and the languages a field can carry -- YAML, shell, twig, SQL and JSON. jq, FHIRPath and
CQL have no monaco grammar, so lib/program-grammars writes each as a Monarch tokeniser and
CodeEditor registers them. Its three web workers are
?worker imports, which vite emits as chunks of their own.
An engine is fetched when the screen that needs it mounts, and not with the screen's code.
elk is the second of them: components/graph/elk is the one dynamic import of
components/graph/elk-engine, so the graph screens' chunk carries React Flow and the canvas, and
the 1.4 MB of layout algorithm arrives only once a canvas asks for geometry. It arrives in a web
worker -- elkjs/lib/elk-api wrapping elkjs/lib/elk-worker.min.js -- so placing a fifty-step
graph is not work the thread that draws is blocked on. Nothing waits on it to draw either:
placedByRank places the ranks by hand, wrapped onto rows at the same WRAP_WIDTH elk is handed,
and that is what a canvas shows until elk answers and what it keeps showing if elk never does.
A chunk two screens want is fetched before either is asked for. warmEditor and warmLayout
are those same imports, fired by the shell once there is a session and the browser is idle --
lib/idle is requestIdleCallback where there is one and a timer where there is not -- so the
first source tab, jq step or output window of a session opens against a chunk that has already
landed, and the first canvas is placed by a worker that is already running. Nothing waits on
either, each happens once, and the login screen is outside the shell and asks for none of it.
build.chunkSizeWarningLimit clears monaco and nothing else. Monaco's chunk is the one this
bundle has that is over vite's default 500 kB; elk's worker is larger still and is not a chunk at
all. Every other chunk, the entry included, stays far under the limit, so the warning still means
something when one of them grows.
oxfmt owns whitespace, quotes, semicolons and the order of Tailwind classes, at four spaces,
single quotes outside JSX, no semicolons and a print width of 110. Nothing about that is worth an
opinion in review: run make ui-fmt. The generated files in src/components/ui/ and
components.json are ignored, as they are in oxlint. make ui-lint runs the check first, so an
unformatted file fails the gate before anything else is read.
make ui builds the bundle, make ui-dev serves it with hot reload against a running dg dev,
make ui-lint and make ui-test are the gate, make ui-e2e drives a real server in a browser
and make ui-shots photographs every screen for the gallery. make docs-shots is the other camera:
nine screens, the dirigent palette in dark mode at 1440x900, shot against the showcase shelf
and committed under docs/images/screens/ because Screens renders them. make ui-static puts the built
bundle where the server wheel packages it, and it comes before every e2e run and every live
review, because that packaged copy is what a checkout's dg dev serves. The gate is wired into
make check behind a check for bun, so a machine without bun gets a loud skip rather than a
failure.
Five type sizes, and the named sizes outside them¶
The scale is 12px, 13px, 14px, 15px and 26px. Nothing arrives beside it without a name. The faces stay IBM Plex Sans and JetBrains Mono; what moved is that a page now has a heading somebody can find from across the room, and that the working size of a dense screen is a step under its prose.
| Class | Size | What wears it |
|---|---|---|
text-xs |
12px | Codes in mono, chips, counts, timestamps, a status word -- anything read at a glance rather than as prose |
text-sm |
13px | The working size: a control's word, a rail entry, a row's description, a fact, a table cell |
text-base |
14px | Prose: a description's body, a failure's message, a dialog's sentence, the command palette's search field |
text-title |
15px | A section heading and a listing row's title, at weight 600 |
text-page |
26px | A page heading, at weight 500, and nothing else on any screen |
text-stat |
28px mono | A stat's number, and nothing else on any screen |
text-wordmark |
48px | The login brand lockup's wordmark, and nothing else on any screen |
text-lockup |
20px | The rail's wordmark beside the mark, at weight 600, and nothing else on any screen |
text-display |
36px | The sign-in heading, and nothing else on any screen |
--text-graph-title |
12px | A step's title on a graph, in mono at weight 500, and nothing else on any screen |
--text-graph-small |
11px | Every other word drawn on a graph: the block or what a source listens to, a badge, a count, a duration, a fan-out's strip, words standing over an empty canvas |
Mono is for what is typed somewhere else: a code, a step's key, a block, a kind, a duration, a
log line, a JSON value. A title, a heading and a sentence are in the sans face, and a code inside a
sentence is mono at the sentence's own size through text-[0.88em].
The wordmark is a brand mark rather than text: the login screen draws the product's name at display size beside its tile, and no other screen may set a heading there.
A graph has two sizes of its own, because it is drawn at the scale of a diagram rather than of a
page. They are set on .step-node and .graph-words in index.css rather than on each word, so a
word added to a box inherits its size and no word on a canvas can come out at the page's.
scripts/check_ui_classes.py fails the gate on anything outside them, whether the sixth size
arrives as text-[17px] or as text-lg. The check has no allowlist -- it knows text-title,
text-page, text-stat, text-wordmark, text-lockup, text-display and the two graph sizes by name, and
reads index.css itself so another size cannot be minted there either. A named
exception can be counted; an allowlist is a check people argue with. It skips
src/components/ui/, because those files are generated and regenerating them is how they are
fixed. A size one of them carries is said back onto the scale in index.css, against its slot: the
small button's 0.8rem is text-sm.
A relative size composes with the scale rather than escaping it, so text-[0.85em] on code inside
a sentence is deliberate and passes.
Tokens¶
Everything colour is a CSS custom property in src/index.css, exposed to Tailwind through
@theme inline. Nothing anywhere else may write a colour.
Two theme axes, kept apart. The mode is light or dark: next-themes owns it, writes it as a
dark class on <html>, and follows the operating system until somebody says otherwise. The
palette is which set of colours the app spends inside that mode: src/lib/theme.ts owns it,
writes it as data-theme on the same element, and a small script, public/palette.js, applies it
before the first paint so there is no flash. Neither axis knows about the other.
A palette is two blocks of CSS and one row, and nothing else in the app learns it exists. The
blocks are html[data-theme='<name>'] and html.dark[data-theme='<name>'] in index.css, beside
the base ones; the row is in PALETTES in src/lib/theme.ts, carrying the name and the label.
Each block answers to [data-palette='<name>'] as well, base palette included, so an element
inside the app can be painted in a palette the document is not wearing -- which is what a swatch
card is, and why a swatch cannot drift from the tokens it stands for. A test asserts the pre-paint script's list and that array
agree. Because a palette's selector outranks .dark, a token the base sets in both :root and
.dark has to be set in both halves of a palette that touches it at all -- a token set in the
light half alone leaks into the dark mode. A token a palette does not touch falls through to the
base, which is how a palette that only moves its surfaces keeps the status and kind hues.
Both axes are chosen on the settings dialog's Theme pane. Appearance is
Light, Dark and System, one segmented control rather than a menu, because a choice from a fixed
set of three is one control and not a list to open. Palette is three swatch cards, because
what a palette is is what it looks like, and a card showing the ground, the surface, the line,
the ink and the accent says in one glance what a sentence under a segmented control took a
paragraph to approximate -- so no palette carries a line about itself any more. Neither row holds
a copy of what it sets -- Appearance writes next-themes, Palette writes choosePalette, and both
read back through the store. The mode is also the account menu's Appearance submenu, the same
three writing the same store, because it is the one look somebody changes in passing; the palette's
Appearance rows are the third door to it. The top strip carries no mode flip: the account menu
stands on every screen, and a control said twice on one screen has to earn the second time, which a
two-way flip that cannot reach System does not.
Three palettes ship, ordered quiet to loud: dirigent, paper, contrast. The default is
the Flow first palette: neutral blue-grey surfaces, softly raised, in a dark and a light mode;
paper is the same app on warm paper with warm ink; contrast is white on black and black on
white with every line and every ink pushed to meet 4.5:1. The amber is the brand in every one of
them, and a palette moves its lightness only where its own ground needs it. The surface ladder
keeps its order and roughly its steps in every palette, so a component designed against the
rungs still reads -- except where a palette's whole point is the ground it commits to, and
contrast collapses page and card onto one white and one black, drawing every card by its edge
instead of its shadow.
Every section of a page is a headed panel. components/ds/Panel is the one surface a page
draws: a title bar on --card with the section's label at the left and its count or a fact
about it at the right in mono, a hairline in --panel-line under the bar, and the body on
--panel-ground, a rung off the bar, edged all round in the same hairline and lifted by no
shadow. A panel holding a graph has a faint dot grid on its body in --panel-dot, because a graph
is drawn on a ground and not on a card. A failure is the one panel that says so: a rule in the
failed ink along its top and the warning mark beside its title, which is the only colour a panel
ever carries. Nothing draws a left border in a status colour, and nothing on a screen styles a
surface of its own: a treatment is changed in Panel and in the .dg-panel rules in index.css,
and the generated card follows the same rules.
Every panel's title bar is one fixed height, so bars side by side line up. The bar is
--spacing-panel-head tall -- 40px, and 46px below the breakpoint, where a control in it is a
finger tall -- with no vertical padding: its label, its count and whatever it carries are centred
in that height, so two panels standing beside each other put their labels on one baseline. A
control in a bar is compact, xs or sm, and fits the bar rather than growing it. A failure's rule
along the top is drawn inside the panel's edge, over the bar, and adds no height. The run
terminal's toolbar is the one bar that may wrap onto a second line, and only below the breakpoint,
because it stands alone and carries filters rather than a label.
A listing is a headed panel too. ListTable draws the .dg-panel edge and ground: its row of
column headings is the panel's bar, on --card over the hairline, and its rows stand on the panel
ground with a --border hairline between them and no stripe. Given a title it carries the
panel's own title bar above the headings, with the count where the line along the foot would be,
so a screen with two listings -- Triggers' sources and schedules, the catalog's families -- is two
panels rather than two headings over two cards.
Inside a panel or a dialog there are three boxes, and each is drawn once. A Well is a group
that belongs together -- a form opened under its row, a minted secret, a sample -- on the panel
ground, edged in --panel-line. An Inset is reference material -- a block of JSON, a code block
in a description, a log -- on the page's own ground, edged the same way, so a preview reads as cut
into the surface it is on. A code editor is a field, so CodeFrame edges it the way every field is
edged -- the --input hairline at the control's corner -- around the editor's own ground, and
WindowedPane puts every pane that opens in a window inside one. A box that says how something is going is a Notice,
and a refusal off the wire is Refusal. No screen types a border, a corner and a ground onto a
div of its own: the boxes are .dg-well, .dg-inset and .dg-frame in index.css, beside
.dg-panel.
The rest of the ladder. --sidebar is the rail, a rung under --background, the page.
--secondary is raised: a button's fill, a chip, the active rail entry; --popover is a dialog
and a menu, raised by --shadow-pop over a --scrim, which is the one shadow left. --node is a
box on a graph. A control has a ground of its own, --field, which is the page's, edged in
--input, so an empty box reads as a box in both modes. --muted is the wash a row takes under
the pointer. --border is the hairline between rows inside a panel body; --border-strong is the
shell's rule. Ink runs --foreground, --muted-foreground for a hint and a code, --faint for a
timestamp beside a name. Every surface stays near-achromatic, so status colour is the only colour
a screen carries.
The amber is the brand, the reader's avatar and the focus ring, and nothing else. --brand
fills the mark and the avatar at the foot of the rail, and --ring is every focus ring. An action is drawn in ink: the primary button is --primary,
which is the foreground as a fill with the page's colour on it, so the one loud thing on a strip is
the verb somebody came to press rather than a colour. A secondary button is --secondary, a
quiet one is ink on nothing, and a verb that takes something away is the critical ink on
--critical-wash. Selection is neutral: the active rail entry is a --secondary fill and
weight 600, a chosen box on a graph is a ring in the foreground, and a chosen row is the
--muted wash held.
--primary-ink is the amber as text, and it is worn by the door's eyebrow alone. A link is
ink, underlined on hover, and an aside link beside a section heading is the muted ink: a link
names a place, and a place is not news. scripts/check_ui_classes.py refuses a bare
text-primary anywhere in the app's own source, because the primary is a fill.
Status is named for the state machine. --status-succeeded, --status-failed,
--status-running, --status-waiting, --status-queued, --status-pending,
--status-completed-with-errors, --status-cancelled, --status-skipped. The names are
RunStatus and AttemptStatus from dirigent_client.enums, so a status string off the wire
indexes a token with no translation table. Each has an -ink twin: the token fills a chip at 14%
alpha and the ink is what is legible on it -- the .status-chip class in index.css is that
rule, written once. A delivery is a machine of its own, so --status-sending and --status-sent
are NotificationStatus's live states written as the run states they mean -- in flight is
running, arrived is succeeded -- and a palette that moves those moves these. skipped is drawn
in neutral with a dashed edge, because a skipped step did not happen and has not earned a
colour.
Four semantic aliases sit over the top for anything that is not a run: --good is succeeded,
--critical is failed, --warning is completed-with-errors, --info is running. Each has an
-ink twin as well, for text on its own 14% fill.
A kind is not a status, and has its own family of hues. What a thing is -- a block kind, a
connection kind, the clock a schedule fires on -- takes --kind-<family> and the .kind-chip
fill rule, which is .status-chip's written once more against tokens that cannot be confused
with a state. lib/kinds maps a kind to a family and hashes the ones this bundle was built
before, so the mapping is total and stable; no component writes a tint inline. Every family is
far from hue 80, because that hue is the identity colour and means action.
Both modes, every time. This app was designed dark first and the light one is its inversion, not a second design: the same hues, the lightness ladder turned over. A token changed on one side is checked on every surface that consumes it on the other -- an amber that reads as the brand on near-black can read as mustard on near-white, and a raised fill that is a clear step on the dark page is white on near-white in the light one, which is why a secondary button there also carries a hairline ring. The light mode's shadows are a fraction of the dark mode's, because a shadow on a pale ground reads as dirt long before it reads as depth. A palette is checked the same way, on both of the modes it has.
The app has one decorative element, and it is on the login screen. The brand pane carries a
graph of steps above the lockup, filled in --terminal-node and edged in --terminal-node-edge
-- one and two surface rungs above the pane's own ground, per palette -- joined by
--terminal-graph-edge and set over arcs in --terminal-graph-faint. One run through it is lit
in --terminal-accent, with a glow under the lit links. One step wears --status-failed,
because a graph in which nothing ever goes wrong is not the graph this app draws, and the head,
the only solid node, wears --status-completed-with-errors under its halo: the state a run is
in when it reaches its end past a failed step, in the same token the run screen gives it. Nowhere else in the app is a shape drawn that does not
stand for something the reader can act on or read a value from.
The brand pane is bounded. It is 52% of the window, never under 560px and never past
1056px, the width at which the graph reaches its largest scale, so a display wider than that
spends what it gains on the form rather than on the mark. The two columns begin at lg, not
md: a window under 1024px cannot hold the pane's floor beside the form and its padding, and a
pane drawn narrower than its floor squeezes the graph into a strip. Between lg and xl the
pane holds that floor and the form column takes what is left; below lg the two stack and the
pane is a strip. The form pane bounds its form the same way and centres it across the column,
so what a wider window buys that column is spent evenly either side of the one question it
asks. Down the column it is centred only where the two columns are: stacked, the form stands at
the top of its section, under the brand strip and the section's own padding, and whatever height
is over falls below the button. A form centred in what is left under the strip opens a band of
ground above the greeting, and the greeting is the first thing read on the screen that greets.
The door's refusal takes no room. A wrong password and a rate limit arrive as problem
documents, and the sentence is drawn in a critical-edged bar the height of a field, hung below
the button and positioned out of the flow: the form is centred in its column above lg, so a
notice that took space would move every field the moment somebody got a password wrong. It is
the one refusal in the app not drawn by Refusal, for that reason alone.
And the seam between the two is dragged. It is a separator like every other dragged edge
here -- eight pixels of hit area over the line, drawn as nothing until a pointer, a focus ring or
a drag asks where it is, answering the arrow keys, Home and End as well as the pointer -- and it
moves between the pane's own two bounds, never past what the form column needs of the window. A
width somebody chose is that browser's from then on, kept as pixels rather than as a fraction for
the reason every panel here is: what they dragged it to was a decision about what is in it. A
double-click on the seam, or Delete on it, gives the pane its clamp back; a window that narrows
holds the choice inside the new bounds rather than forgetting it. Nothing else on any screen may
grow a handle this quiet -- a control nobody can find is a control nobody needs, and this one is
the door's alone.
That graph is fitted to its box and centred in it. The box is the pane's width from below the top down to a fixed gap above the lockup, and it is measured; the drawing has a shape of its own -- the table's extent plus its padding -- and it is scaled uniformly to fit, never past 1.5, so a wide pane does not magnify it into a poster. A box taller than the fitted drawing gives the rows up to 1.3 times their spacing and never the columns anything, because what a taller pane is worth is a little more air between the ranks rather than a different graph; whatever height is still over is ground, split evenly above and below. A box smaller than the shape needs is the one case the drawing is spread across it and shrunk instead, which is what keeps a step's box off the one beside it. The lockup and the facts are anchored to the foot of the pane, so neither moves between a short pane and a tall one.
The radius ladder is crisp, and it is the floor as well as the ceiling: 4px for a surface --
a card, a listing, a panel, a dialog, a menu, a graph's ground -- and for a box on a graph and the
brand tile; 3px for a control; 2px for a chip and for a box on a miniature graph. They are stated
in @theme rather than derived, as --radius-surface, --radius-node, --radius-control,
--radius-chip and --radius-mini, and every component takes its corner from them: no class
names a radius of its own. A ladder of 1 and 2 read as square and one of 8 and 6 as soft, so this
one goes neither below nor above. Nothing but a status dot and an avatar is fully round: not a
chip, a switch, a port on the canvas or a scrollbar. Tailwind's rungs are mapped onto the ladder
-- rounded-sm is the chip's 2px, rounded-md and rounded-lg the control's 3px, which is
what the generated buttons and fields ship, rounded-xl through rounded-4xl the surface's 4px
-- so a shadcn primitive lands on it without being edited, and a generated rounded-full is said
back onto a rung in index.css against its slot.
apiFetch is the only fetch¶
src/lib/api.ts is the one place in this app that calls fetch. Every read goes through it, and
a page component that called fetch itself is a bug.
It exists because three things have to be right on every request and a page will silently get one of them wrong: the versioned prefix, which this bundle cannot know at build time; the cookie credential; and the fact that a refusal arrives as a problem document rather than as the resource.
The three shapes every resource module is written out of live here too -- Page, Problem and
JsonMap -- because a wire primitive declared twice is two of them, and a resource module that
had to import one from another resource module would say the two resources are related when they
are not.
- The prefix is read, not written down.
Settings.api_prefixis configurable, so the server answersGET /config.jsonwith 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
ApiErrorcarrying the problem document:status,title,detail,code,params,problems,instance.codeis the dotted code of the catalogued message the detail was rendered from andproblemsis a list of issues, each with its owncode,message,paramsandlocation. There is notypemember; this server emits none, so nothing may key off one.apiJsonthrows it;apiFetchhands back theResponsefor a caller that cares about a particular status. - A path outside the versioned API is read through
rootFetch. The probes under/healthanswer 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 theResponseand lets the caller decide, and it lives here because this file is the only one that callsfetch.lib/server-statusis its only caller and the only reader of the probe: the corner dot paints from that store and the settings dialog reads the same one, because two readers of one probe are two answers that can disagree on the same screen. - A 401 is the shell's, not a page's. The session expired, and the answer is the login page
rather than a refusal card on whatever happened to be open. The shell registers the redirect
through
onUnauthorized.
src/lib/sse.ts is the streaming half, and it is fetch plus a reader rather than EventSource:
the browser's own client cannot set the Last-Event-ID header these streams resume with, cannot
be aborted, and reconnects on its own schedule rather than the caller's. A caller passes a path
and gets frames -- event name, data, and the id the stream last stated -- and decides for itself
what attempt, log, run, end and expired mean. A stream ending is not a failure: end
says the run settled, and expired says the server closed the tail at its own wall-clock limit,
which lib/run-stream reopens from the cursor without showing the frame to the screen.
One stream per open thing, never two. runs/{id}/$events is already multiplexed, and the
server caps a principal at eight open streams: a second connection for a log pane would spend that
budget delivering the same story twice, with no ordering between the copies. lib/run-stream is
the loop that holds one open -- sequential by construction, so there is no path through it with two
in flight -- and its test double counts concurrent opens, which is what makes the rule fail a test
rather than a review. What a frame means is a pure reducer beside it, so a replay being idempotent
and a stale frame not moving a settled state backwards are decisions a Node test makes.
Stores, not a query library¶
A page holds its own reads in useState, and a fact that genuinely spans screens -- who is
signed in, whether the rail is collapsed, which palette is painted, how often a watched screen
reads itself again -- is a module store from src/lib/store.ts, read in a component through
useStore. store.ts imports no React, so a store is exercised in plain Node. That is the whole
of the state layer: no react-query, no swr, no zustand.
The rule that makes it work: a store publishes only when its value actually changed by Object.is,
and holds the reference it was given, so useSyncExternalStore sees a stable snapshot and does
not loop.
A setting every screen spends is read by lib/format, not by every screen. Which clock an
instant is rendered against is lib/times, and the formatters ask it -- so one setting moves every
timestamp in the app instead of each screen deciding for itself. Nothing under src/pages reads
that store, so the subscription is in App.tsx, on the route table: a change there builds every
route element afresh and the screen in front of somebody repaints. Subscribing in the shell would
not do it, because react-router hands the shell the same element object each time and React skips
a subtree whose element has not changed. lib/preferences is the same shape for a behaviour
rather than a rendering -- whether a log pane opens following the tail -- and the pane reads it
itself.
A thing is titled by name and addressed by code¶
Every addressable thing the API answers with -- a pipeline, a connection, a schedule, a
source, an alert rule -- carries the same four fields, and the UI renders them the same way
everywhere: id, which nothing draws; code, the kebab-case key that appears in the URL and
in every document that references the thing; name, an optional human title with no identity
at all; and description, the long-form body.
The title is the name when there is one and the code when there is not, and the code is
always on screen. lib/identity is where that is decided, once: titleOf answers the
title, headingOf answers it with the code to draw in mono beneath it -- or with null, when
the title already is the code and wears the mono face itself. Every listing, panel header,
breadcrumb, graph node and palette row asks there rather than spelling name ?? code out
again, so no two screens can disagree about a name that is whitespace, the code is never drawn
twice, and the string somebody would paste into a URL or type into a document is in the same
place on every screen. A screen that showed only the name would be a screen nobody can act
from.
A code in mono never wraps mid-word. report-daily-digest broken onto two lines at daily- is
a different string to somebody copying it, so a code is one line and nothing else: where it does
not fit it truncates with an ellipsis and carries the whole of itself on title. components/ds/Code
is that rule written once, and every code on a screen -- beside a title, in a fact, on a node, in a
row -- is drawn through it rather than through a bare font-mono span.
A listing headed by titles is ordered by them. The API answers a listing in code order, and a
screen that drew the names but kept the codes' order would read as no order at all -- so the
pipelines listing sorts by titleOf, without case, with the code breaking a tie. A run row is
headed the same way: RunOut.pipeline is a code and nothing else, so the runs screen reads the
pipeline names once and joins, and a code the read did not reach is still headed by its code.
description renders as markdown, sanitised by construction rather than by filtering.
lib/markdown uses marked for its lexer alone and answers a tree of blocks and inline runs;
components/Markdown turns that tree into React elements. No HTML string is ever produced, so
there is nothing for a sanitiser to clean and nothing dangerouslySetInnerHTML could be
handed -- a <script> in a description arrives as a token and leaves as text React escapes.
Paragraphs, headings, emphasis, inline code, code blocks, lists, quotes, tables and links are
the subset a description needs; anything else is drawn as the source it was written as, and a
link is followed only for http, https and mailto. A table is set the way its column
declared and scrolls inside its own box, because a description is drawn in a panel narrower than
some tables are. A listing row is the exception, because a
cell is a glance: oneLine closes up the whitespace and the row truncates the words rather
than rendering a heading cut in half.
The chosen row's code is in the address. Schemas and Connections put the code of the row somebody opened, in a panel or in a dialog, into the URL and read it back from there, the way Pipelines does, so a schema or a credential is a link that can be sent rather than a selection somebody else has to find.
A step is the one exception to the shape, and it is the same rule underneath. Its map key is
the reference -- what depends_on and ${steps....} read -- so the key plays the part code
plays elsewhere, and the step's optional name is the title drawn above it.
One way to say a time¶
How long ago on screen, the exact instant on hover. What somebody asks of a timestamp is how
recent it is, so formatRelative is the visible text everywhere -- in a listing cell, in a panel
fact, beside a version -- and the wall clock is the element's title. components/Instant is
that pair written once, and a screen that spelled a wall clock out where the listing beside it
said "2m ago" would be two answers to one question on two halves of the same screen.
An exact instant is written largest field first: 2026-09-02 11:36:05, and UTC after it
when lib/times is set to that clock. formatInstant is the only place it is spelled, and it is
not the reader's locale: an instant is read here to be compared -- against a log line, against
another screen, against what a machine wrote -- and 9/2/2026, 11:36:05 AM sorts by nothing and
means two different days to two readers. A relative reading that has run past its recency window
falls back to the date half of the same spelling rather than to a locale date.
Time speaks in both directions. formatRelative measures either side of now -- 3m ago
behind it, in 3m ahead of it -- through the same units and the same thirty-day horizon, past
which both fall to the date half of the exact spelling. When a schedule fires next is the same
question as when a run started, so a next fire time is an Instant like every other moment in
the app rather than a wall clock spelled out where the column beside it says "2m ago". The
window either side of now is one instant: a clock a few seconds out of step with the server's
reads as "just now" rather than as a firing already overdue.
A schedule says what is true of it where the next firing would go. Pausing keeps the
computed next_fire_at on the row -- resuming recomputes it from now, so it has to carry on
from somewhere -- and a paused schedule that drew that instant would promise a firing the
scheduler will not make. It says "paused" instead, muted, in the listing cell and in the panel
fact both; the chip beside its name is what a chip is for, and this is the answer to the
question the column asked. A schedule with no next instant at all says nothing is scheduled
rather than drawing a dash, and that reading comes first: a one-time schedule pauses itself
once its moment has gone by, so the flag alone would have a spent clock read as a stopped one.
A window is the exception, and it is read exactly. Every other instant on a run answers how
recent it is, so it wears the relative form; the interval a run covers is what a step filtered its
query on, and what somebody checks it against is a date on a source rather than the distance from
now. formatWindow is that pair -- both ends through formatMoment on the clock the app is set
to, the zone stated once after the two rather than on each -- with the instants as the wire wrote
them on the element's title.
A box that takes a wall clock names the clock it is read against. A rendered instant carries
its own suffix, so nothing beside it has to say which zone it is in; a datetime-local control
carries none at all, and what somebody types there is read against the app's own setting before it
is sent. So a section of them states that zone once, in mono beside its label, and each box says
nothing about itself. lib/times answers what it is called and how far from UTC it is at a given
instant, which is what turns a typed wall clock into ISO 8601 with a zone on it. What the
browser draws inside such a box is its own locale's spelling, and it is the one place in this app
an instant is not written largest field first: the control is the platform's, and a box drawn by
hand to spell it the house way would be a date picker this app then owns.
A one-time schedule is a moment, and it is read like one. Its at is drawn through
Instant -- "in 2h" before, "3d ago" after -- rather than as a raw clock string, and once it
has fired the cell says so in front of the instant. What decides that word is last_fired_at
and never the calendar: a moment that has passed with no firing behind it is one the scheduler
has yet to reach, and saying "fired" of it would be a second untruth in place of the first.
A listing is a cursor walk¶
Every screen that draws a listing draws it with one component. components/list/ListTable is
the panel, the heading row, the hairline between rows, the row that reaches for the next page and
the count, along the foot or in the title bar where the listing has a title; a screen decides its
columns and what each cell says, and nothing else. Where a row opens
something -- a trigger's history beside the table, a connection's dialog over it -- the screen passes
onSelect, and the row answers Enter and Space as well as a click, because a row only a pointer
can open is a row some people cannot. The column header is sticky: rows scroll beneath it
inside the listing's own scroll container, and the header keeps the card's ground so no row ever
shows through it.
A row on most listings fills the panel, and that is a disclosure, so pressing it again takes it
back. What a press
opened a press closes: the row loses its tint, the screen stops filling the panel, and an
unfilled panel is not drawn -- so the thing opened from the listing can be shut from the listing,
rather than only from the panel's own control. A row whose selection is the URL navigates back to
the bare listing instead, which is the same gesture said in the address bar. ListTable decides
this for every listing, from selected and panelStanding, and a screen says only what taking
its own selection back means: onClose. Pressing the open row while the panel is down opens it
again rather than clearing a tint nothing is showing, because a control that does nothing is
the fault this rule exists to fix. A row that navigates is not a selection and does not
toggle: the runs listing goes to a screen, and a press there has already left. Connections and
Users are the two listings whose row opens a dialog instead, and the pattern is still being tried:
see below. No other listing has moved, and none should until that trial has been read.
Every listing is a keyset walk, answering rows and an opaque cursor and no total, so there
are no page numbers and what the foot can honestly say is how many rows have been read and
whether there are more. lib/paging is what an answer does to the rows already held, as pure
functions over the state; hooks/use-paged is the part that needs a browser, and it holds the
question beside the rows so an answer to filters somebody has since changed cannot land on the
screen.
A trigger row is two fixed lines. The lead cell is headingOf's pair -- the title, and the
code beneath it in mono only where the title is not already the code -- and everything else the
row has to say about itself goes on that second line as a chip: the document that owns it, or
managed, and paused or disabled where that is true. There is no kind chip, because the
heading over the table is the kind, and no id, because a row's key is not something a person
reads. A row whose height depended on how many chips it wore would make a listing of twenty rows
a listing of twenty heights.
A connection row is two lines and three cells. The lead cell is headingOf's pair drawn
across -- the title, and the code in mono where the title is not already the code -- with the
description's first line under it in the muted ink, truncated, which is what a credential is for
said in the words of whoever set it up. Then the kind, its mark and its code in mono, and the
health. What a credential is pointed at is read on the connection's own dialog: a settings line
per row is a column of url= and timeout= to scan past before the health cell is reached.
A healthy connection is a bare dot, and failed or
unknown gets the word, which is the top bar's rule: words only when something is wrong. The dot
is 8px; the words are healthOf's -- failed, never checked, not verified, and healthy as
the bare dot's tooltip -- in the channel strip's three colours: green where the last check
passed, red where it failed, a hollow ring where nothing was proved. One vocabulary, decided in
lib/connections and drawn by HealthSaid on every screen that draws a credential's health: this
listing, a pipeline's requirements, the reference row under a field. The check's own sentence is
not on the row: it is the tooltip on the health cell, on hover and on keyboard focus alike, and
it is in the connection's dialog in full, where there is room for the whole of it. Checked
is how long ago, and nothing at all where nothing has ever asked -- so a row nothing has checked
has no Checked line on its card either. Asking again is the dialog's Check now, not a button on
every row.
Connections opens in a dialog, shown first and edited second, and this is a trial, of which Users
is the second use. Pressing a
row opens one dialog over the listing, at /connections/<code>, and there is no panel on this
screen. It reads first: the title, the kind and the code; the description; then the health with
the check's own sentence, every setting that holds a value under its own key, every secret as dots
or not set, and when it was made and changed. Its footer is Delete, and Check now and Edit
at the other end, with the corner's close as the way out. Edit is the same dialog in its edit
state, at the same width, so nothing moves under the hand: the schema-driven form in two columns,
a required text field, a map, a JSON value and a program taking both, a footer of Cancel and
Save, and no corner close. Cancel, Escape and a press outside all go back to the reading rather
than closing, and a press outside does nothing at all while a form is open, because a typed
credential is not something to lose to a stray click. A save checks the connection, because the
health on screen was about the settings that were just replaced. Delete raises ConfirmDialog
over the dialog. What uses the credential shuts it: GET /connections/{code} answers used_by
-- the pipelines whose current version names the code with the steps and sources that do, the alert
rules delivering through it, the storage schemes configured from it -- and where any of that is true
the reading draws it as a Used by fact, each one linked where it has a screen. The same read is
made again as the question opens, and the question says Used by <what>. with Delete shut and saying
why. The server checks again at the delete and holds what it finds, so a refusal for what that read
did not see stays in the question as one line. GET /connections carries nothing about what uses a
credential, because a listing would have to walk every document for every row.
An account opens the same way, and is the trial's second use. A press on the row, anywhere but its
chevron, opens one dialog over the listing that reads first: the title, the role, the email where
there is one, when it last signed in and when it was made. Its footer is Deactivate (or Activate),
and Reset password and Edit at the other end. Edit is the same dialog at the same width: name and
email side by side, the role as one compact select with what each role may do on its option, a footer
of Cancel and Save, and no help line that does not add a fact. There is no panel on this screen.
There is no single read of an account, so the open one is not in the address. A role is a word and
not a code: plain in the Role column and in the reading, and never a chip beside the mono username
it could be mistaken for.
hooks/use-read is the one-page sibling of use-paged, for a read that is a document rather than
a listing -- what this instance has installed, how the last day of runs came out. Same rule: the
read function's identity is the question, and an answer to a question nobody is asking any more is
discarded rather than left on screen.
A screen that watches beats, and the rest are read when they are opened. A run's own screen
holds one event stream and needs nothing beside it. Two screens go stale standing still -- the
runs listing and the dashboard -- so each reads itself again on the one
cadence lib/refresh holds for the whole app: off, or five seconds to five minutes, thirty by
default, chosen from the split refresh control and answering the same everywhere it is offered.
hooks/use-heartbeat is the beat. It fires only while the tab is visible, because a screen
nobody can see asking anyway is heat, and it fires at once when the tab comes back, so what a
reader returns to is the present. A beat is quiet: what is on screen stays until the answer
lands, a beat that fails changes nothing and the next one asks again, and a listing's beat folds
page one in at the head matched by id -- so a row already on screen is refreshed in place rather
than moved, and the count of what arrived is the pill the runs screen offers.
A filter is the server's or it is not offered. GET /runs narrows by pipeline, by status,
by how far back to look and by the tags the run's pipeline wears, and those four are what the
runs screen has. A control for something the server cannot filter would narrow whichever rows
happen to have been loaded while appearing to answer the question it asks. Where a screen does
narrow what it has loaded -- finding a pipeline among the pages read so far -- the foot already
states how many rows those are. A corpus is the exception, and it is one because nothing in it is
unread: the block catalog and the examples screen are answered whole by what this build
installed, so every control on them narrows all of it and their empty state can say a search
found nothing rather than that there is nothing yet.
An examples row says what a copy of it would have to change. A document carrying a top-level
connections: or schemas: section is what runs alone under dg run --local, and an apply
refuses it, so the row states carries connections; a copy names them -- carriesNote in
lib/examples -- and a template's copy names them under requires instead. A document carrying
neither says nothing, because a column saying "nothing" of a hundred rows is a column of noise.
A tag filter is a set, and its members stand beside the control. tag repeats on the wire
and repeating it narrows, so the control is a menu of checkboxes rather than a choice, and each
chosen tag stands next to it as a chip that removes itself. Where a row already draws its tags,
the chip on the row adds it to that set: the filter is reached from the thing that shows what to
reach for. Where a row's tags would be the same words repeated down a column -- every run of one
pipeline -- the rows carry none and only the filter does, and a pipeline's row carries none
because its shape and its sentence are what it says about itself. On the pipelines screen the set
lives in the address (?tag=) so a narrowed listing is a link somebody sends, and it is replaced
rather than pushed, because a filter being assembled is one destination and not five. The runs
screen carries the same control beside its own three, seeded from the address once and held in
state after: a control that wrote every keystroke back would put a history entry behind each one.
A tag on a row is the filter's own door. components/TagChip is a chip until it is given
onSelect, and then it is a button saying "Filter by
A column's heading is a label, whatever the cells under it are. A column of codes draws its values in mono and its heading in the sans face, because the heading is the same word in the same voice on every listing.
A column holds a value, a title or prose, and that is what decides its width. A value is
drawn from a fixed vocabulary -- a chip, an instant, a duration, a count, a mark -- so it is
shrink-to-content and declares the floor it needs to say itself on one line. Text is whatever
somebody typed, and it comes as one of two things: the title the row is known by, or the prose
beside it -- a description, a path, a code it refers to, the sentence a run failed with. A screen
says which through the column's kind, and lib/column-width decides the width from it, so two
listings of the same shape lay out the same way and no screen sizes its own columns. A column
of text declares no width of its own: max-w-0 is what lets it shrink and its content
truncate, the floor under every one of them is 144px, and inside the cell the content carries
min-w-0 and truncate and says the whole of it in title, or the cell shrinks and the words
are simply cut off.
A title reads whole before a description takes the rest. A title is short and it is the identity -- a reader who cannot read the whole of it cannot tell one row from another -- and a description is long and written to be cut. So the title column asks for exactly what its longest title needs and every column of prose beside it cuts up what is left. Where a title has no prose beside it there is nobody to hand the rest to, and it takes the room the way any other column of text does.
The text columns ask in shares that add up. A column of text that asks for the whole table,
as w-full does, asks for more than there is, and a browser answers that by scaling every such
ask down together -- which is how a title beside a description came to be given half of what it
asked for, and a failure sentence 144px of a 1240px table. So the listing measures itself once --
what its longest title needs, and what the value columns take -- and hands each column of text a
percentage of its own width: the title its need, the prose an equal cut of the remainder each.
What a title needs is read off its cells on the one layout the column stands at its floor in,
where the whole of the title runs past the cell and the width it is missing can be read off it;
a cell with room to spare says nothing, so a column given what it asked for measures the same
again, and one whose rows grew under it -- the runs listing, when the pipeline names it was
waiting for land -- measures the difference and asks again.
A need is measured in fractions of a pixel and asked for in whole ones. scrollWidth and
clientWidth answer in whole pixels, so a cell short of its content by a hundredth of one reads
there as a cell with room to spare -- while the browser, which lays out in fractions of a pixel,
has already drawn the ellipsis, and the ellipsis eats the word in front of the character that did
not fit: The reading, as Postman Echo answers it lost it to a shortfall of a twentieth of a
pixel. So every edge is read off a client rect and every run of text off a range over it, the
need rounds up to a whole pixel, and the share stating it rounds up while the share of the
remainder beside it rounds down -- the asks still have to add up.
A share is a width, never a floor. A floor is what a listing measures itself against before it gives up on drawing a table at all, so a title column that declared the width it wants would turn the narrow listings into cards. What it asks for yields instead: where the floors under the columns beside it take what the table had left, the browser gives the title what remains of it, and at 1024 every listing is still a table.
Text that declares no bound is a table as wide as the longest thing anybody ever wrote in
it. An unbounded failure sentence is what made the runs table 910px wide in a 712px column, and
the identity beside it 164px. A column's floor is also how a listing knows it has run out of
room, so a table whose columns declare widths says them as floors rather than locking them with
fixed, which honours a width exactly and a floor not at all. The Tags column takes at most a
quarter of the table.
A card's rows follow the same rule as a listing's columns. The dashboard's health card
lines a mark, a code, what the thing last said and when up on one line in a card a third of the
screen wide, which is a table's problem in something that is not a table. A healthy connection says
nothing there, its dot's tooltip carrying the word and the check's sentence: the mark is a value
and holds its width, the code is the identity and keeps a floor of six characters, what it said
is prose and truncates in what is left with the whole of it on its title, and the instant is a
value out of a fixed vocabulary and holds its own. Nothing on such a row is shrink-0 unless
it is short by construction, or the row's minimum is wider than the card and the card scrolls
inside itself -- the same defect as a listing that scrolls sideways. A card whose row cannot be
read at a third of the width takes the whole width instead: the chart's column and the health
column share a row at xl and stack under it.
The dashboard says what needs somebody first, then how things are going. Its first section is
the runs that need a look -- every pipeline whose newest run failed or completed with errors,
one row each, led by the pipeline's miniature with the failed step filled, then the title and
code, the step and the sentence it failed with, the status word, how long ago, how many runs
that is where it is more than one, and the catalogued code. Where nothing needs a look the section
is not drawn, because a heading over nothing is a wall of one zero. Then two columns: the runs of
the last day by the hour, with the failed and the troubled parts of each bar in their hues and the
rest in a neutral ink, because a green bar is the loudest thing on a page where nothing is wrong;
and under it how long each pipeline took, the median of its runs in that day. Beside them the
health of what the runs depend on -- each connection that is not healthy by name with its word,
a count of how many are, and every worker. Then the runs in flight, the most recent runs, one
line each, with All runs beside the heading, and the next firings -- each of the three
only where there is any. There are no stat tiles: a number that is zero on a good day is a
number nobody reads on a bad one.
The tags fold to the room they have rather than wrapping into it. Chips are one line: a row
whose height depends on how many words it wears makes a listing of twenty rows a listing of
twenty heights, so what does not fit that quarter folds into one trailing +N chip -- which
says how many it is holding, spells them on hover, and opens them as the same filter every
other chip on the row is a door to. A second line is allowed only where a table is 1280px or
wider, and never more than eight chips are drawn whatever the room. The fold is computed, not
guessed: a chip is mono at 12px, so how wide one is is arithmetic over its characters, and
lib/tag-fold fits the words -- the +N counted as the chip it is -- into the room one
ResizeObserver on the listing's own card measured. One observer for the listing rather than
one per row, and a share of the table rather than the cell's own width, which is the width the
chips already took. Where the listing is drawing cards a row's tags have a row of their own, so
nothing folds there.
A choice closes on its choice; a set stays open. components/list/Choice is one value out of
a menu of them, and picking one shuts the menu, because the question has been answered. A set is
the other case: several tags mean AND, so TagFilter's rows are checkboxes and the menu stays
where it is until somebody is done with it. Neither trigger wears a count -- what has been chosen
stands beside the control as chips that take themselves off.
A count says what it counts. rowsRead writes "1 pipeline", "50 runs, more to load": the noun
is the row's, singular where there is one of it, and a listing with a cursor left says so. A bare
number along a foot reads as a total, and a keyset walk has no total to state. The noun reaches
the foot in both numbers, as the {one, many} pair its count entry holds, and ListTable's
noun prop takes that pair rather than a word: a foot handed one plural would have to reach the
singular by rule, and the rule that makes a batch out of batches makes an entrie out of entries.
A pipeline is listed by its shape¶
A pipeline's row leads with its miniature graph, because what a pipeline does is the shape of
its steps before it is any sentence about them. components/graph/MiniGraph draws it from
PipelineOut.shape -- the current version's steps reduced to a key, a block or the kind a source
listens to, what each depends on and whether it fans out -- and from nothing else, so a listing of
fifty pipelines is one request. Then the title, the code in mono beside it, and the description's
first sentence under them, which is what a description's first sentence is written for. What
fires it and how it last ran stand after that only where they exist: a schedule count and the
kind of each source as chips, and the last run as its status word and how long ago. A pipeline
that has never run says nothing there, never "never run", because a column of the same words is
a column that says nothing. The row is the link to the pipeline's page, so it draws no arrow, and
the listing draws no panel.
A miniature is drawn the same way everywhere. A box is 14 by 10 at the miniature rung, 2px; a step that fans out is a box with a second one stacked behind it; a source is a dashed box on no fill; a step the last run failed in is filled in the failed hue, which is the only colour a miniature ever carries. Ranks are the longest path to a step, read left to right, and a rank's steps stack down. An edge is drawn only where it changes the shape -- an edge another path already implies is left out, so a step that waits for everything before it does not draw a fan of lines -- while the full graph on the pipeline's page keeps every one. The scale is shared and capped: every miniature is drawn at the same 22px per rank, so a longer strip is a longer pipeline, until the lead cell's width is reached, and a pipeline deeper than that is drawn smaller rather than cut.
A person's own pipelines and the shipped examples are two lists. PipelineOut.example says
whether a pipeline's code is one the installed example corpus ships. The person's own come first,
in their own panel, at a row's full height, ordered by title. The examples come after, in a panel
of their own with denser rows: a short set, the ones
that have run first and most recently, and a link counting all of them to open the rest in place. A screen
whose examples outnumbered the person's own by twenty to one would otherwise be a screen about
the examples. Where every pipeline on an instance is an example, the person's list is not drawn,
and where none is, the band is not.
The search box and the tag filter narrow both lists, the tags on the server and the search over what was read, and the counts beside each heading are what is left.
The pipeline's page leads with its graph¶
A pipeline's page is its graph first. The title, the code in mono under it, and Edit,
Validate and Run… at the right of the heading; the description; then the graph on the canvas
ground; then what the pipeline did, side by side -- Recent runs, its newest runs one line each
(the state, what started it, how long it took and when, each line the link to its run) with All
runs beside the heading opening the runs screen narrowed to this pipeline, and No runs. as
the whole body where there are none; and, only where the document declares a report: and a run
has settled, Report, the latest settled run's document rendered as the run's Report tab renders it,
at a pane's height with the window for the whole of it, the run it came from beside the heading,
and the settled runs before it as links to their own Report tab at its foot. A document declaring
no report, or a report and no settled run, draws no Report panel. GET /runs?settled=true is how the page finds
that run, because no single status asks for it. The two rows take the same flex bases, so the seam
between Recent runs and Report lines up with the one under it. Then three panels side by side -- Blocks, which block each step runs with the steps as
chips that choose them on the graph, drawn only where a step runs one; Triggers, the schedules and sources that fire it; and
Connections, what it reaches and how each one is. Edit opens the editor at
/pipelines/<code>/edit, which is the screen the rest of this page's editing rules are about;
Validate dry-runs the stored version against the instance as it is now and says the answer in
one line.
The graph is fitted, never scrolled. At 1440 wide a pipeline's graph is drawn whole on the
page with no scrollbar of its own: the canvas is as tall as the drawing it holds rather than a
fixed stage, the drawing is scaled down where it is wider than the page and never up past its own
size, and a graph too deep for the width is wrapped onto rows the way every canvas wraps one. It
pans, zooms, lays out and opens in a window as every view of a graph does.
Choosing a box says under the graph which block it runs, what it waits for and what it feeds, and
puts it in the address as /pipelines/<code>/<step>, replaced rather than added to.
An endpoint says where it is called, and a run says who it answered¶
An endpoint source is drawn as the address a caller is given. Wherever a source a request
reaches is described -- its row in the pipeline page's Triggers panel, the editor's step panel
for it, the triggers screen's panel -- it says its methods and the full URL in mono, wrapped rather than truncated: this
instance's origin, the endpoint prefix and the path, which is the string a caller pastes, with a
copy button beside it. Under that, how a caller authenticates, as configured: a bearer token by
its prefix, an HMAC signature by the header it arrives in, or public. components/triggers/Endpoint
is that block, written once, and lib/sources is where the URL and the line are decided.
New token is how a source's credential is replaced, and it asks first. The verb is New
token, or New secret on a signed source, on whatever surface describes the source; it raises
ConfirmDialog, Replace the token of <code>?, whose one line is that the old one stops working,
because that is what the verb takes beyond the thing named. The answer is the minted-token dialog
every source credential is shown in: SecretReveal, the URL, how a caller presents it, and for a
bearer token a curl line that makes one call with it -- the first method, and the source's sample
body where it has one -- because the first thing anybody does with a new token is try it.
A run says how its source was entered and whether its reply went anywhere. A run of a pipeline
with a source was entered one of three ways a reader cares about: by a call, by hand with an input,
or by hand from the source's sample. The received attempt carries it as via, and the run's status
line says it after who started it: by admin, from the sample, by admin, with an input given,
by a call to ask. A reply renders whether or not anybody is waiting, and its output carries
delivered: the reply's box says sent or not sent after the word reply, in place of its duration, and its step panel
says sent to the caller or recorded, not sent. A hand run is answered nowhere, and the screen
never lets it look as though it was.
Run… says what a hand run of a pipeline with a source does, in one line over the footnote:
that it starts from the source's sample, or with an empty input where there is none, and that
nobody receives the reply where the pipeline has one.
A graph is a shape, and each screen reduces its own to it¶
lib/dag-layout knows nothing about runs or documents. A run's graph is its pinned definition
with a state on each node; the editor's is the stored document with an edit mark on each. The
geometry of the two is one problem, so what elk is handed is an id, a width and a height per node
and a pair per edge, and each screen reduces its own shape to that. components/graph is where React
Flow and elk are actually imported -- the canvas with this app's props on it, the hook that asks
elk for positions, and the door that fetches the engine -- and both graph components are lazy, so
the two of them share one async chunk rather than shipping two copies.
Motion lives on edges, and it means data travelling. A run's graph is the one place in this
app that moves, and only while the run is live: an edge out of a step that has produced its output
into one still reading it is dashed in the ink and travels along its path, and the
moment a step produces its output every edge out of it into a step that could still read it plays
one short handover before settling. An edge stills as soon as either end is done -- a source that
failed or skipped never had an output to send, and a target that has settled is not reading one.
A node gains nothing from any of it -- status colours stay the only colour voice
there, and selection stays neutral -- so what a reader watches is where the data is, not which box
is loud. A terminal run is still everywhere, and so is one opened after it settled. Somebody who
has asked their machine for less motion gets the same reading standing still: a travelling edge is
lit in the same ink and a handover is not drawn at all, decided in lib/run-detail from the
setting rather than left to a stylesheet to hide -- and the stylesheet stops the animations as
well, so a frame rendered before the preference lands is still still.
Layout is recomputed on the shape, not on the state. signatureOf is what the elk call is
keyed on: which nodes there are, how wide and how tall each is, and what joins them. A step going from running
to succeeded moves nothing, and neither does typing into a config field.
A form is a JSON Schema, read once¶
lib/schema-form turns a JSON Schema into field descriptors: which control a field takes, what
its bounds are, whether it is required, what its enum offers. A block's config schema and a
pipeline's parameter schema are both fed through it, so the step form and the run dialog are one
decision rendered twice, and every shape the shipped catalog publishes has a test.
What no control fits is edited as JSON. A list and a schema with no type at all are a
textarea rather than a wrong control -- guessing at a list of integers with a text box is how a
document ends up carrying "[200]". The fallback has a test of its own, so a shape this bundle
was built before degrades rather than misleads.
A map of scalars is a key/value table. An object schema with no properties of its own whose
additionalProperties names one scalar type, or a union of them, is the pairs kind: two
columns, one row per entry in the document's own order, a blank row at the foot to type the next
pair into, and a remove control on each row that holds a pair. That is headers and query on
http.request, env on shell.run and on each docker verb, inputs and outputs on
docker.run, and headers on webhook.post and on the webhook connection kind. What the table writes is the plain object, so the document, a
run's parameters and a connection's config keep the shape they had and nothing downstream learns
a form was involved. A cell is a switch where the map holds booleans alone and a box otherwise,
and what is typed is read as the narrowest shape the map takes -- a whole number where it takes
integers, a number where it takes numbers, true or false where it takes booleans, and text
last -- so 2 in query is the number 2 while 2 in headers is the text. A key written twice
wears the same marking a refused box does and is not written until it is fixed. Every other map
stays JSON, and the reason is always that a cell cannot hold what the values are: pipeline.run's
params takes any JSON value, a map of lists takes a list, a map whose values may be null takes
something an empty cell is not. A reference in place of the whole map is not a table. The
document language lets ${...} stand wherever a value goes, so a map written as one is the text
it was written as, with a reference, not a table beside the label; clearing the box brings the
table back.
A field that carries a program is edited as one. A string whose schema published a
contentMediaType -- application/jq for the three jq verbs, text/x-shellscript for the shell
string shell.run and docker.run take, application/sql for a query, text/x-jinja for a
report's template, application/x-compose+yaml for an inline compose file, application/fhirpath
for the FHIR pack's FHIRPath verbs, text/cql for its CQL library -- gets the same Monaco a
document is written in, through the same CodePane, so a jq program is read on the lines it was
written on. dirigent_common.programs is where the
seven are spelled, and the schema is what decides this and nothing else: every other string stays
one line, however long a value it holds.
Each of the seven is highlighted in its own grammar and in the house inks, the same in every one:
a keyword and a number wear the warning ink, a string the good ink, a function -- and jq's
.path -- the info ink, a comment and the punctuation the muted ink, and a name bound from
outside the program -- jq's $name, FHIRPath's %name and $this -- --binding-ink, because
it is the one thing a reader of an expression has to look for elsewhere. It is the kind family's
hue with more chroma than a kind ink, oklch 0.7/0.18 dark and 0.48/0.17 light, because a thin mono
glyph at the kind ink's chroma reads as the foreground beside it. Every palette holds it at 6:1 or
better on the editor's ground and 2:1 or better against the text, so none overrides it. Any other
name is
foreground, and so is one written in backticks or in CQL's double quotes, define "Numerator"
included: the quotes are what tell it from a string, which is green.
A document is written in one editor wherever it is written. The editor's source pane and a
step config field whose schema says it carries a program are the same Monaco against the same
GET /schema/document -- a key no block takes is squiggled where it was typed rather than
reported a round trip later. Each pane names its own buffer, because monaco holds one model per
uri and two panes sharing a name would share a document.
A pane too small for what it holds opens in a window, and the button is at the top. components/WindowedPane
is where that gesture is written once -- the button, its place, and the window, which is the
viewport less a gutter -- and every pane that offers one comes through it: a config field carrying a
program, a value edited as JSON, a report document, an alert body, an example's source, the block of
produced JSON on a run's step, a schema's page, and the graph on a run's page and a pipeline's. A
pane in a panel with a head hands the button to the head, beside the panel's other tools
(WindowButton, with button="none" on the pane). A pane with no head keeps it inside its own
top-right corner, in a row of no height held to the top of whatever scrolls it, so the button is on
screen wherever the reader has scrolled to and covers no more than the end of the first line. A
panel clips its corners with overflow: clip rather than hidden for this reason: a hidden overflow
is a scroller of its own, and a button held to it would never move.
The side panel's toggle is at the screen's right edge. While the panel is shut it is the last control in the top bar; while it is open it is the last control in the panel's own strip, over the panel it shuts. The rail's own collapse is beside the logo.
A field that names a thing shows the thing. A string property carrying x-dirigent-ref holds
the code of a connection or a schema, and under the box the form draws what that code resolves to,
in the step panel's own group gesture: a shut row with a one-line summary, and the definition under
it against the group rule when it is opened. A schema the document carries in its top-level
schemas: resolves before one the instance holds, which is the order the engine resolves them in,
and says carried by this document; one the instance holds says instance and opens with a link
to /schemas/<code>, a connection with its kind, its health as every screen draws it, and a link to /connections/<code>. A
code nothing holds is not stored or not configured, muted, and not a button -- there is nothing
under it to open. Nothing is called missing while the listing behind the answer is still being
read, and a value written ${...} names no code at all, so neither draws a row. The code itself is
never repeated: the box above the row already holds it.
A JSON Schema is edited against its own meta-schema. The media type
application/schema+json is what asks for it: a buffer holding a schema document is checked
against JSON Schema 2020-12, the draft the server validates with, so its keywords complete as
they are typed and a value the draft does not take is marked where it was written. Two buffers
hold one -- the dialog a schema is written in, and the schema's own page at /schemas/<code>,
where a stored one is corrected beside a value it is tried against -- so a shape is fixed in the
editor it was authored in rather than read in one place and rewritten in another. What that page
may change is what SchemaUpdate carries: the name, the description and the body. The code is the heading and never a box, because the route addresses the schema by it
and every validate.schema step and requires.schemas entry names it.
The label is the key. What is being edited is a document, and the word an author writes in it
is max_response rather than "Max Response". description is the help under the label, and whether a
field is required is said beside the label rather than inside it, so the control's accessible
name stays the key. The required word is body ink, never amber: amber is the brand's and
the focus ring's, and a field label is neither. FormField draws it.
Every field is a design-system control, and an empty one looks like an empty one. A field
renders through ui/input, ui/textarea, ui/select or ui/switch and never a bare element,
and the treatment is checked in both palettes: the dark one fills a rung of the ladder and edges
it with a hairline the ground shows, and the light one has to say the same thing with a fill and
a stronger line, or a value floats in whitespace and an empty box is not there at all. A row is
the key as its label, the annotation beside it, the control, and one muted line under. An unset
secret is an empty password box with a placeholder saying so -- never the words "not set" as
prose where a control belongs.
A section the document requires is open, and one it does not is a link. A run dialog asks for
a window because the document it runs reads one -- a step writing ${run.window.start} stops the
run it is given none in -- so where the document references it the section stands open, every field
in it is required, and the verb is shut with the sentence saying what is missing. Where the document
references none, the same fields sit behind one plain link and nothing is asked. A dialog that hid a
field the document cannot run without would have the server refuse what the form could have asked
for; one that opened every optional section would make every dialog the longest form it could ever
be.
A form opens on what the document needs. A block such as docker.run publishes twenty config
keys and a step takes two of them, so the step form draws the keys the schema requires and the
optional ones the step already sets -- required in body ink with its marker beside it, optional in
muted ink -- and the optional keys nothing has answered sit behind one plain link reading N more
fields, which opens them in place. The split is partition in lib/schema-form and it is read
from the values the form opened with, so a key being filled in does not jump over the fold under
the hands typing it; it is above the fold the next time that step is chosen, because choosing
another step is another form.
A value written as a reference is not checked against the field it stands in. The document
language lets ${...} stand wherever a value goes, and what one stands for has no text, no bounds
and no type until the run resolves it, so a field carrying one is checked for nothing else --
not its pattern, not its enum, not even that it is a number. That is the rule the server applies:
defer_references swaps every such value for a sentinel that every JSON Schema keyword passes
over, so a form that refused one would be refusing a document that applies. What counts as one is
hasReference in lib/references and it is the engine's own grammar: a run of dollars before
braces with something in them, the dollars collapsing in pairs, so $${...} is a literal and
${} is text. It holds where a document is being edited and nowhere else -- a step's config, and
not its clocks or its retry budget, which are the engine's own keys -- because a run's parameters
and a connection's config are values, and the server checks those literally. Whether the reference
names something the run will have is the whole document's question, and Validate is what asks it.
The source pane holds to the same rule from the other end: GET /schema/document publishes each
block's config with the reference beside every field's own type, so the two halves of the editor
agree about one document.
A control that cannot hold a reference gives way to a box: a key/value table, a choice and a
switch each draw a value the schema describes, and drawn by their own control a reference would
read as empty, unset or off and the first touch would write that over the document. So the text is
drawn, what it is is said beside the label -- a reference, not a table, not a choice, not a
switch -- and clearing the box brings the control back.
The refusal here is the client's half. A field is checked against its own schema so a form can say what is wrong before it asks. Whether a document applies -- its graph, its references, the blocks it names -- is the server's, and the editor's Validate and Apply are the same dry run asking it.
The pipeline editor edits a copy¶
A pipeline is a chain of immutable versions and the only verb that changes one is $apply, so
the editor holds a local document in lib/pipeline-document: the graph, the step form and the
source pane are three readings of it, and the count of unapplied edits is the difference between
it and the version the instance holds. It is a module store because those three are not inside
one another.
A pipeline that does not exist yet is edited in the same screen. /pipelines/$new is the
editor with no pipeline behind it: the local document is a skeleton nothing has applied, so
everything in it is editable, the code among it. Validate is the same dry run against the draft;
Run is shut, because $run runs the version the instance holds and there is not one. The first
apply creates the pipeline and the screen goes to the address it will answer at from then on --
which is why a static pipelines/$new route sits beside the dynamic pipelines/:code, and why
New is the primary half of the listing's split button while the two doors for a document
written somewhere else, From a template… and From a file…, are in the menu beside it.
A document opens on the source that holds it. The editor's panel opens on the step tab,
because a pipeline is read a step at a time, and choosing a box on the canvas opens that tab
rather than whichever one was last in front of somebody. The exception is a document that arrived
as text: From a file… on the listing hands the editor what it read and From a template… hands
it the copy it made, and the editor opens on the source pane, which is where that document
actually is. A blank /pipelines/$new opens on the step tab like every other screen -- the canvas
says how to add the first step, and the source, whose schema would mark an empty steps map
before anything had been done, is a tab away.
The step panel reads the config first, then the engine, then the name. What a step does is the
block's own config, so the panel opens on that form, folded the way every generated form is. The
engine's half sits under it as five groups in one order -- Waits for, Fan-out, Timing, Retry, Rule
-- because for_each, the timings and a retry policy are the setting a step runs in rather than
the question it answers, and a flat list of twelve controls buried the two keys the step was about.
A shut group is one row: its title, and a mono line of what would run, with the values the document
sets in body ink and the defaults it leaves alone in muted -- timeout 30s · no deadline ·
on_timeout fail, off for a fan-out over nothing, all_success for a step that says nothing about
its rule, the prerequisite keys as chips. Clicking the row opens that group's own fields under
itself, against one rule in the identity colour marking what is being edited; the line goes while
the row stands open, because the fields under it say the same thing, and a group holding a
refusal stands open with no button to shut it, because a refusal folded away is one nobody can
answer. poll belongs to a sensor, so the timing group neither says nor draws one for an operator
unless the step carries it anyway. Which key belongs to which group, and how each line reads, is
lib/step-groups over the schema in lib/step-keys, so a member added to the definition is filed
rather than appended to a list. The display name is last and it is one small field: nothing
references it, and asking for it first was asking the least of the step first.
Apply is quiet until it would write. The button takes the identity colour where the document differs from the version the instance holds, and where there is no version at all; where applying would write nothing it stands in outline beside Validate, which is what it would do.
A plan says what an apply takes away before it is pressed. The dry run answers with the source
rows and the managed schedules a document has stopped declaring, and those are the one consequence
in the dialog that cannot be read back from the document: a source goes with its inputs and its
token. They stand under the changes in the warning ink, with the words a reader would ask for, and
only where there are any. What an apply did to its sources and schedules is the note that follows
it. A token an apply created is shown once, the way a rotated one is, because the answer to that
apply is the only time it can be read: MintedToken stands over the editor before anything moves on.
A refusal the dialog can answer is answered in the dialog. A top-level connections: or
schemas: section is what lets a document run alone under dg run --local, and an apply refuses
one that carries either -- a refusal that stays, because a reference by code is the single
contract and a stored document that carried a credential would carry it into every version and
every export. What stood beside that refusal was a Cancel and an Apply that could never work, and
the remedy the sentence named was a command line, so the way forward is under it now.
lib/carried is the decision and components/pipeline/CarriedOffer lays it out: one line saying
what an instance stores and what applying does, then every carried entry with its kind, its code
and what becomes of it. The server's own refusal is not drawn beside it. A message is
rendered by the CLI, by this screen and by every other client, so it states the fact and names no
tool; printing it over a list and the controls that answer it would put one fact on screen three
times with the least actionable copy loudest. lib/starters.uncarry is what lifts the sections
into requires: -- the same text rewrite dg pipeline new makes, so the teaching comments
around everything that stays survive it -- and the button says Apply, because a button says
what somebody wants and not the steps behind it.
Four cases, and the row says which. A carried entry meets an instance that has nothing under
that code (create), one holding the same thing (already here), or one holding something else
under the same name (differs); the fourth is a document that only names a code under
requires:, which carries nothing to create from and is what the plan's own issues and the
Requires chips say. Applying never replaces a stored schema. A stored schema is what every
other pipeline naming that code checks against, from its next run, so an apply that wrote over
one would change what those pipelines check without anybody having decided that -- and the line
over the list, which counts what applying stores, would stop being true. differs shuts the
confirm and the sentence beside the rows says which code collided.
So differs is the row's own decision, and it opens in the row like every other. What stood
beside it was a sentence naming two remedies, both of them the document author's: change what the
document carries, or carry it under a code of its own. The third -- update the stored schema to
what the document carries -- is the right one often enough that leaving it out made the dialog a
dead end, and it was the one thing the browser could not do at all. So the row expands over what
the instance holds, in the pane a schema is read in everywhere else and at a pane's height, with
the window for the whole of it; says that every pipeline naming that code checks against the new
shape from its next run, which is what a run pinning its shapes at creation actually means --
and, under the names, that a run in flight keeps the shape it started with, which is the half a
reader mid-run needs; and offers the write. It is a write of its own and not a step of the
apply, because the two answer to different people -- one pipeline's author is applying, and
every other pipeline naming that code lives with the result. The identity travels with the
body: storing a schema reads its own title and description where the write names neither and
PATCH /schemas/{code} reads nothing, so a replacement sends all three or the instance keeps the
labels of a shape it no longer holds. The row then reads already here and the confirm is drawn,
the collision it was shut for having gone -- and that is the whole report, in the place the reader
is already looking.
A carried schema is stored and a carried connection is not. A schema is a shape: it holds no
credential, and storing it under the code the document already names puts the same resource where
an instance keeps one -- under that code and no other, because it is what every validate.schema
step in the document references and nothing here invents or renames it. A connection's config can
hold a credential in a field the kind declares secret and in one it does not, since a sql or a
git connection's url carries a password or a token inline, so nothing can read a carried
connection and say no credential is in it; nor can it be compared with a stored one, because a
read redacts every secret field. It is named under requires: and the way forward is the form
that mints one -- never a sentence telling somebody where to go while a button goes there.
And that form opens in the row that said so, because leaving this screen costs the document.
A pipeline nothing has applied is held in lib/pipeline-document and nowhere else, and the
editor forgets it as it unmounts, so a link out of this dialog was a control that destroyed the
work it was offered to rescue -- which is the same fault as a confirm that cannot confirm, read
from the other end. The row expands under itself instead, the gesture below for a row whose verb
needs a form, and nothing navigates. The code and the kind are the document's own, the row
already draws both, and no box asks for either again: a create under a different code would
leave the row exactly as it was, and the code is never on a screen twice. What the form asks for
is the credential -- the kind's required settings, its optional ones folded behind the one link
every generated form folds them behind, and a write-only box per secret field -- because
nothing is read out of the carried connection, which is the whole reason a connection is not
created from a document. A kind this instance has not installed publishes no config schema, so
that row offers no form at all and says missing alone. The instance is asked again the moment
the credential exists, which turns the row already here and is what was in the way of the
confirm being drawn; nothing says it a second time as a line or a toast. A remedy that has to
send somebody to another screen belongs on a document the instance already holds a version of,
and there is no such remedy in this dialog.
What a copy of a template still needs is said where the copy lands. A copy rewrites the two
lines that make it somebody's own and leaves what the document carries in it, because the apply
is what refuses a carried section and the dialog it raises is where the shapes in it can still be
stored; a copy that lifted them first would name schemas nothing holds, and an apply refuses that
too. The editor draws what is still missing as chips under Requires -- lib/requirements checks
connections and schemas alike against listings the screen has already read, and claims nothing of
a listing that has not landed.
The source pane may hold text that is not a document, and while it does, the local document is the last one that parsed and nothing else may write to it. A form quietly replacing text somebody is halfway through fixing is worse than a form that says it cannot.
The source pane holds the text it was handed, then renders the document. A document that
arrived as text -- a picked file, a template's copy, something typed into the pane -- is shown in
that text, comments and blank lines and all, until a structural edit moves past it; from the first
edit on the canvas or in the step form, the pane renders the local document, because no renderer
can put a comment back. GET /pipelines/{code}/$export stays the canonical text: it orders steps
topologically and keys the way a digest is taken over, while the pane renders the document in the
order it is held in, so what is edited and what is read back are the same thing. An apply
canonicalises it.
The editor's schema comes from the instance. GET /schema/document answers with
dirigent/v1 composed with this instance's own block config schemas, one if/then case per
installed block, so monaco-yaml marks a config key the named block does not take at the character
it was typed at. An instance with a plugin another lacks answers with a different schema, which is
the point: the editor refuses exactly what that instance's apply would.
The palette is a registry¶
src/lib/palette.ts holds actions as data: id, title, group, keywords, hint, run.
The shell registers what is always there -- every screen the account is offered, the view rows, sign out. A screen registers what only it can do, from an effect, and returns the unregister so a row cannot outlive the screen that would carry it out. Re-registering an id replaces the row.
The settings dialog is the same idea for a different surface: src/lib/settings.ts holds every row
as a record -- what it is called, which category it is filed under, what else it can be found by --
so its search box is one pure function across every category rather than a traversal of markup, and
the shortcut rows are derived from lib/shortcuts rather than written down twice. What a row puts
on its right edge is the dialog's, keyed by the row's id.
General holds the behaviour preferences, each one a store the screens read rather than a copy this dialog keeps: which clock every timestamp is read against, whether a log pane opens following its tail, and whether an editor marks the row the caret is on. A preference defaults to what this app did before it existed, so nothing moves under a reader who has never opened this dialog, and an editor already on screen takes the change without being rebuilt.
Its nav reads Preferences, You, This instance: General, Theme and Shortcuts are preferences,
Account is the reader's, and Server is the instance's. There is no About, because a version stated
on two panes of one dialog is the same fact twice; the documentation and the API reference are two
links at the foot of the Server pane, where the instance they answer for is. No pane carries a
line under its heading and almost no row carries one under its label. A row's description is
optional and survives only by adding a fact the control cannot show, such as which timestamps a
clock setting moves and which keep their own -- so a line restating the label is not written.
filterActions is a pure function and cmdk's own scoring is turned off, because cmdk ranks by
fuzzy match over rendered text and would put a screen's own name below whichever row shares more
letters with the query. Every term must match, so typing more words narrows; a title that starts
with the query sorts above one that merely contains it.
Every chord this app binds is a letter. On a Norwegian layout [, ], {, }, | and \
all need Alt to reach at all, so a binding over one of them is a binding nobody here can press.
Cmd/Ctrl+K opens the palette, the platform's own modifier plus B hides and shows the rail, T shows and
hides a run's terminal, and ? opens the list of every key -- each matched as the character it
produced, never as Shift plus a physical key.
src/lib/shortcuts.ts decides every press as a pure function; hooks/use-app-shortcuts.ts is the
one place a real KeyboardEvent is read.
Panels are sized in pixels¶
The rail is dragged by its right edge, and opens at 168px. It holds between 152px, where
its longest word still fits, and 320px. What it was dragged to is kept in pixels per browser, a
double-click on the edge gives it back its 168px, and the edge is a separator like the panel's:
it takes the arrow keys, Home and End, and says what it resizes. Hiding it whole is still the
platform's modifier plus B and Show navigation; a hidden rail has no edge. Below the
breakpoint the rail is a drawer and is not dragged. The rail's width and whether it is hidden,
and the right panel's width and whether it stands open, are what lib/panels keeps, each read
out of storage as its store is built, so the shell renders at its settled size on the first paint
rather than snapping into it.
What is in the right panel is the screen's. The panel itself is drawn once, in
components/RightPanel, and a screen fills it with fillPanel from an effect that returns the
unregister -- the same shape as registering palette actions, and for the same reason: a panel
cannot outlive the screen whose data it is drawing. A tab is an id, a label and a render, and
the open tab is remembered by id, so a screen that gains a tab does not move the reader onto a
different one. A screen that fills nothing gets the line saying so.
Whether the panel stands open is the reader's; a screen opens it for one visit. The toggle is
the only thing that writes the stored preference. A selection, or an address that names a row,
opens the panel through openPanel for that screen alone, and the next screen starts from the
preference, so a link into one screen never decides how every other screen first draws.
The top strip carries the screen's two facts. lib/screen-status holds one note and one
identifier, stated for as long as a screen is mounted -- where a run's event stream is, and the
trace the run is on. The note is data with a tone rather than markup, so what a connecting stream
says is a pure function and the strip stays one line whatever it holds.
The right panel is dragged, and what is stored is pixels, not a fraction. What somebody
dragged it to was a decision about the content in it -- wide enough for a log line, narrow enough
to leave the table readable -- and a fraction re-decides that every time the window is resized. A
window narrower than the stored width clamps for that session and leaves the stored number alone,
so the intent comes back with the window. The drag handle is a separator and answers the arrow
keys, because a panel that can only be sized by pointer is a panel some people cannot size.
One hook drags every edge. The rail's right edge, the right panel's left edge and the run terminal's top edge are the
same sentences with a different coordinate read off the pointer, so hooks/use-drag-size takes
the axis as a parameter rather than being copied per direction. The pointer is followed on the
document rather than on the handle, and the size is written to the element's own style while the
drag lasts -- a React render per pointer event leaves the edge visibly behind the hand -- with the
store hearing one committed number on release.
The shell is a rail and a strip¶
The rail, the screen and the right panel are three columns, and nothing runs across them: there
is no bar along the foot and no rule under the top. The rail stands on --sidebar, a rung under
the page, and that step is its edge; the top strip stands on the page's own ground. Every strip
in the top row is one height -- h-shell-top, stated once in index.css -- so the rail's head,
the top strip and the right panel's tab strip put the mark, the crumb and the tabs on one line.
e2e/shell.spec.ts measures every strip with getBoundingClientRect, in both palettes, with the
rail hidden and the panel open.
The rail is the mark, the screens and the account, top to bottom. Its head is the brand tile
and the word dirigent at text-lockup, which is the way to the dashboard. Its entries are
single-line words with no icon, and a word that names a screen needs no gloss under it. They
are grouped by what somebody is there to do: Dashboard alone at the head; Operate (Runs,
Triggers); Build (Pipelines, Connections, Schemas); Reference (Blocks, Examples); and, for an
admin, Admin (Users, Workers, Alerting). A group's name over its entries is the faint uppercase
label at text-xs. An entry is muted ink, the pointer lifts it to --card and ink, and the active
one is the --secondary fill at weight 600. lib/nav is the array the rail draws and the palette
offers, and the line an entry carries about itself, and its mark, are data the palette reads.
The account stands at the foot of the rail, and the whole row is the control. A round avatar
in --brand carries the username's first letter upper case in --brand-foreground -- one pair in
every palette and both modes, at 8.1:1 -- then the username, and an up-and-down chevron at the
row's right edge saying the row opens a menu. This is the one place a code stands in the UI
face. The username is the account's code, and wherever it is addressed -- the accounts listing,
the Account pane, a token's owner -- it is mono; here it is the reader's own name said back to them,
which nobody copies or types from, so it reads as a name. GET /auth/me carries no display name,
so the username is the title in the row and in the menu alike.
The account menu opens upward from the row. Its head is the avatar beside the username and the
role, a plain word as everywhere else, with the instance's name, environment and version on the
line under them -- said there once, and nowhere else in the menu. Then Settings, Keyboard shortcuts
with the key that opens them, Appearance, a separator, and Sign out, each row led by its mark from
lib/icons. A key is drawn beside a row only where lib/shortcuts binds one. Appearance is a
submenu of Light, Dark and System with the one in force checked. The keyboard is the menu
primitive's own: focus moves into the menu on open, the arrows walk its rows, the right arrow opens
Appearance and the left one shuts it, and Escape closes the menu and gives focus back to the row.
The rail hides whole. The platform's modifier plus B and the palette's row take it off screen
and bring it back, and while it is hidden the top strip carries Show navigation at
its left, where the drawer's button stands below the breakpoint. Whether it is hidden is the
reader's, and kept.
The top strip is the screen's crumb and the instance's corner. At its left stands the crumb:
where a screen is one thing, the trail back to the listing it belongs to, drawn by Breadcrumb
into the strip rather than onto the page, so the page opens on its heading. A listing has no
crumb. At the right, the screen's note and identifier come first, and nothing a screen states
there is something its own page already says -- how many rows were read is the table's, a
standing fact is which version the instance holds rather than who applied it and when, and an
instant is the house's relative form. Then the palette's button, which is the search glyph with the chord on its
tooltip; the panel's toggle, where the screen has filled a panel; and the instance's dot, last.
A page's head stands still and its body scrolls. Under the top strip a page is two parts:
the head -- the title, the code under it, a line of facts and the screen's verbs, which PageHeader
and PageHead draw into the shell's head slot -- and the body, which is the one scroller. So the
track starts below the verbs, never beside them. The scroller spans the content column to the
window's edge with the page's padding inside it, and its thin track, in the line tokens, sits in the
right margin that padding leaves: it takes no width the content had, nothing moves sideways when it
appears, and the body and a docked terminal end at one right edge, the same edge as the head.
A fact appears once in the shell. How this instance is doing belongs to the dot, and nothing true of the session rather than of the screen stands beside the crumb.
The dot is the whole of what it says while the instance is well. Its details are its tooltip:
the name and environment from /system/info, the health and when it was asked, the version, and
every check. Words appear only when something is wrong: Server degraded, Server unhealthy, Server unreachable, retrying, beside the dot, in
that state's ink. statusWords in lib/server-status decides it. A press opens the settings
dialog's Server pane, which is what a finger has where there is nothing to hover with.
Small screens¶
The shell has one breakpoint, md at 768px, and one rule above it: at md and up nothing about
the shell on this page changes. Everything below is what the same screens become on a phone.
Nothing scrolls sideways. A page whose body scrolls horizontally is a defect, whatever is in
it, and so is a listing that scrolls inside its own card.
Above lg a table has no breakpoint: it follows the width it was given. The window cannot
answer for a listing whose box it does not know -- the same columns fit a 1024px window and
overflow the moment a panel takes half of what was left. So from lg up the card form is the
listing's own answer about its own box: a ResizeObserver on the listing's card reads the room
it has, the table drawn in it reports what the columns need -- its scrollWidth at table layout,
which is wider than the box exactly when they do not fit -- and a listing that cannot hold its
table draws its rows as cards wherever it stands. lib/card-form is that decision, useCardForm
is the observer that feeds it, and the form a listing settled on is what its own cells read
through useListCards -- a cell that draws itself differently on a card asks the listing it is
in, never the window. The columns settle first: the layout a title column is measured on is one
it stands at its floor in, and a table narrower than the one that will be drawn says nothing
about whether the real one fits.
Below lg a listing is cards, whatever it measures. Under 1024 the content column beside the
rail is about 500px, and a listing of two text columns measures itself as fitting a table there:
two columns of twenty characters, which is a table by arithmetic and a card by reading it. The
window is a floor under the measurement rather than a second opinion about it -- useSmallWindow
-- and everything above that floor is the box's own question.
The form has slack in it, so it cannot flap. A listing turns into cards when the table it
drew overflowed its box, and remembers what that table took; it turns back into a table only
once it has that width and SLACK more. Without the gap the width at which the table returns is
the width at which it overflows again, and a window dragged across that edge would strobe.
A width is published before it is judged. A cell that fits itself to the listing -- the tags fold, a bounded column -- reads a width nothing has measured yet as no bound at all and draws everything it holds, so the first layout of a listing is wider than the one after it. The measurement sets the width and settles the form on the render that follows, and a face that arrives late re-measures behind it: a form settled on either of those layouts is a listing stuck in cards on a screen with room for its table.
A utility hides; useSmallScreen chooses. md:hidden on one of two renderings leaves both
in the document -- every row and every control twice, two elements with one accessible name --
so where the same content is drawn two ways, the hook is what decides and only one is built. A
class is still the answer for hiding a thing that has no second form.
The rail becomes a drawer. Below md it is off screen until a menu button at the left of
the top strip -- icon only, aria-label "Open navigation" -- opens it over a scrim. It closes
by its own close button, by Escape, by a tap on the scrim, and on every route change, because a
drawer still standing over the screen somebody navigated to is a drawer they have to dismiss
twice. Focus moves into it on open and back to the button on close. The account menu lives at its
foot, as it does at the foot of the rail above the breakpoint.
A listing row becomes a card where the table will not fit, which on a phone is every
listing. The table is one component and so is its narrow form: ListTable draws its first
column as the card's head -- the title as the row's link, the code in mono once -- and every
other column as a labelled fact under it, labelled by that column's own header. A column headed
by nothing is a control -- a check, a revoke -- and it has no label row: it stands under the
facts at the card's content edge, where the head starts, rather than in the value column beside
a blank. cardFacts in lib/card-form is that split. What a row does
as a table it does here: a row that opens a panel opens it, and a row that is a link is one. The
card is the whole listing's form, header row and column widths included, so nothing on a narrow
table has to be told how to shrink.
A column with nothing in it for that row is left out of the card, label and all -- and the
cell is what says so, by returning null. A component that renders nothing is an element all
the same, so a cell handing one back reads as a fact the row has, and the card draws a label
with a blank beside it: a schema with no description under a Description saying nothing. The
column decides, never the component it draws -- a tag strip with no tags, a duration for a run
still going, a description that is empty are the cell's answer and not the component's -- and
lib/card-form leaves out every fact that answered nothing.
The breadcrumb shows its leaf, and the crumb carrying the thing's code where the leaf is not
it -- the code is on screen on every screen. The whole trail is the element's title; the leaf
truncates with an ellipsis rather than wrapping the strip onto a second line.
A toolbar does not wrap, here or anywhere. A screen's verbs are data rather than markup --
ToolbarActions -- so that below md the primary action can stay on the strip while everything
beside it moves into one overflow menu, aria-label "More actions", without a label being
written down twice.
A dialog is a sheet. Full width, full height, its footer pinned to the foot and its body the
part that scrolls. It is the generated dialog's own slots that are restyled in index.css, not
a second dialog.
The right panel opens from the foot. Below md it is a full-height sheet over the screen,
raised from a tab bar across the bottom carrying the tabs the screen filled -- so the graph
screens are the graph, and what is beside them is a tap away. A screen that fills no panel has
no tab bar.
A document is read, not written, below md. The source pane is read-only, the add-step and
delete-step controls are not drawn, and neither is any of the three verbs -- Apply, Run, and the
Validate that would dry-run a document nothing here can change. The strip says "Read only on a
small screen" where the verbs were, and carries the document's identity and nothing else: what
version it is at is in the top strip already, and a fact appears once on a screen. A graph is still
read, a step is still chosen, and a run is still watched.
A segmented control that does not fit wraps; it never becomes a menu. The control is for a
set somebody can see all of, so the set stays on the screen below the breakpoint too: one
standing at a form field's height is a grid two cells wide there -- four options are two rows of
two, an odd last one takes the width -- and the single row it always was at md and up. A label
too wide for its cell wraps onto a second line, because a cut word says less than a folded one.
Folding the control into a picker instead would hide behind a press exactly what it exists to
show.
Every control a finger lands on is at least 42px tall, whatever element it is made of.
The height is one token, --spacing-finger, and below the breakpoint every control takes its
minimum from it: in index.css against the generated primitives' slots, against every trigger
-- a menu's and a tooltip's carry the trigger's slot in place of the button's, so naming the
button alone misses every filter, the instance menu and the palette button -- and against
control-link, which is what a link drawn as a control wears: a drawer entry, a chip on a
header strip, a row that navigates. The canvas's zoom cluster is sized there as well, being
the graph library's button rather than one of ours. A menu opened at a point rather than under a
control -- the canvas's right-click menus -- anchors a zero-sized trigger at the cursor, and
data-menu-anchor keeps the rule off that one, because nothing draws it and no finger lands on
it. Where a component draws a control of its own it is h-finger or min-h-finger. A control
drawn as a square takes the width too, because a 42px tall target 28px wide is a target on one
axis only. No screen sizes a control for a phone itself.
Text inside content is not a control. A card's title link, a timestamp, the link in a
heading and a tag chip a row wears are the row's words rather than something drawn to be
pressed; they keep the size the type scale gives them, and the row holding them is what the
finger has -- which is min-h-finger already. The rule is about what the app draws as a
control, and that is what makes it checkable.
A keyboard chord is drawn only where a keyboard is likely. The palette button is the search glyph at every width, with Cmd/Ctrl+K on its tooltip rather than on its face, because a phone has no modifier key to press and a chord on the strip says nothing to a pointer; below the breakpoint it takes the square the finger rule gives an icon button. The list of shortcuts keeps its keys, being a list about keyboards.
A kind may be drawn as well as named¶
A glyph that identifies a kind is information, not decoration. The one decorative element in this app is the login graph and that does not bend: a mark earns its place here by telling kinds apart at a glance, which a column of identical dots and four words cannot do. So a kind may carry a mark of its own, and only a kind may. A glyph never stands for a state, and one drawn beside a word that already says the same thing is the decoration the rule above forbids.
A glyph belongs to a kind, and lib/glyphs is the one place a kind is given one. A
connection kind and a notifier are the same word -- slack names the credential's kind and the
sender alike -- so there is one map and one function over it, kindGlyph, pure over the code the
wire answers with. Adding a kind is adding one entry there and nothing else: no screen keeps a
table of its own, and no screen works a mark out from a code itself.
The mark is drawn beside the code wherever a kind says what a thing is. The channel strip's chips, a rule's target, the connections listing's row, the connection's own dialog, the New connection dialog's kind rows, the pipeline editor's reference row under a field naming a connection, and the health rows on the home screen: a reader who learned the envelope on the strip reads it on all seven. A screen that says what a thing is and draws no mark is the fault this rule exists to stop, because the same kind looking different from one screen to the next is the reader's problem rather than the screen's. The blocks catalog is the one screen that names kinds and draws none: its rows are the registries themselves, where the kind is the row rather than what the row is, and it lists schemes and notifiers in the same shape beside them.
The mark does not take the kind's word away. Where the kind is said -- the connections listing's row and the connection's dialog, as the mark and the kind in mono, with no hue, because status is the only colour those two carry -- the word stays beside the mark: it is the value somebody types into a document, and a mark cannot be typed. Where it is not said the mark stands with the code alone and the kind is not spelled twice: a channel chip and a rule's target are the mark and the code and nothing else. The dialog's kind rows and the editor's reference row spell it as well, for the reason the picker's rows do -- the kind is half of what the row is found by, and the reference row has no code of its own to stand beside, the box above it already holding one.
A pack declares the mark its own kind wears, and it is one path or it is nothing. A
ConnectionKind carries mark: the d of a single SVG path on the same 24-unit grid, which
reaches the app through the catalog and is held once in lib/marks. kindGlyph(kind, marks) is
still the one place the question is answered -- the built-in map wins for the kinds it names, a
declared path is drawn next, and the neutral glyph is what is left -- so a screen still asks one
function and keeps no table. A declared path is a string off the wire, so it is drawn only when
it is path data and nothing else: non-empty, ASCII, path-data characters, at most 4 kB, which is
what registration held the pack to and what this bundle checks again before a browser sees it. A
notifier declares none, so the channel strip and a rule's target draw the marks this bundle
names.
A kind with no glyph takes the neutral glyph rather than a gap, which is the same one a palette row with no icon takes. Kinds arrive from whichever packs an instance installed, so a pack's kind that declares no mark of its own gets a mark rather than a hole beside the kinds this bundle was built knowing. Something that is not a kind at all carries neither a mark nor a neutral one: a worker row on the health panel holds the mark's width so the codes under it line up, and draws nothing in it.
Two kinds that are the same sort of thing may share that sort's mark. kafka and rabbitmq
are both queues and both wear the queue; the code is always beside the mark, and it is the code
that says which of the two is in front of somebody.
The glyph says what a thing is and the dot says how it is, and neither does the other's job. A glyph never takes a status colour and a dot never says which kind something is, so a card carrying both says two facts rather than one loud one.
What the kinds this repository ships wear: an envelope for email, a scroll for log, the
Slack mark for slack, a webhook for webhook, a globe for http, a branch for git, a
database for sql, a container for docker, a drive for s3 -- a mark says
what the credential opens and an s3 one opens storage, where a cloud would say where a thing is
hosted, which half this list could claim -- and an ordered list for kafka and rabbitmq. A
glyph is size-4 in muted ink wherever it is drawn; what it sits in belongs to the thing drawing
it -- the palette row gives it a size-7 tile on --muted, a channel chip draws it bare.
lucide draws every glyph that is not a brand, and a brand mark comes from the brand. lucide
ships no brand icons at all. A brand mark is Simple Icons' where it carries one and the brand's own
media kit's where it does not -- Slack is the second case, so its mark is the paths off
slack.com/media-kit, kept inline in src/components/alerting/SlackMark.tsx, the shape untouched
and scaled onto the same 24-unit grid every other glyph is drawn on. A brand mark is monochrome
here, which is what a kit's monochrome variant is for: it takes the surface's ink in both
palettes like everything else on the strip, where a mark in its own colours would be the loudest
thing on a screen whose colour means a state. A mark a pack declares is held to the same two
rules: one path on the 24 grid, filled in currentColor.
The channel strip¶
The strip is one wrapping line of chips, and a chip holds three things, and a fourth when
something is wrong. The kind's glyph at
16px in muted ink, the code in mono, and an 8px dot: 36px tall, on the control's rung, on the card's ground
with the app's own border. A credential that failed or has proved nothing says so after the dot,
in healthOf's word; a healthy one, the log and a kind nothing is set up for say nothing. Nothing
else is on it -- no time, no sentence -- because a paragraph per channel is a screenful of channels
on a screen whose subject is rules.
A connection is a channel and a notifier is a kind, so a chip says the connection's code and
the glyph says the kind: five email credentials are five envelope chips reading ops-mail,
billing-mail, and so on. Only a kind nothing is set up for is one chip reading the notifier's
own code, and so is the log channel, which has no credential to name.
The dot has three colours and no more. Green where the channel can deliver -- the log notifier, or a connection whose last check passed. Red where the last check failed. Grey for everything else: a kind nothing is set up for, a credential nobody has checked, a probe that proved nothing. A reader learns three colours once; six would be a legend.
The rest of the words are the tooltip's, and a credential's are the connections listing's: healthy,
failed, not verified and never checked are healthOf's, read through it rather than said a
second way here, so one credential reads the same wherever it is drawn. The strip adds two of its
own, for the two things that are not a credential: ready for the log channel, which nothing
checks, and not set up for an installed notifier no credential exists for. The tooltip is this
app's own primitive, it opens on hover and on keyboard focus alike, and it holds, in this order:
the kind and the state word (email · failed), how long ago the check was where there was one,
the connection's code in mono, and the check's own sentence in full. Nothing on the strip ever
says "connection" or "built in": a reader knows they have not set up Slack, not that a credential
of kind slack is absent.
A chip goes where its channel is. One with a credential opens it at /connections/<code>;
a grey chip with no credential opens /connections?new=<kind>, which is the connections screen
with its dialog already on that kind; the log chip goes nowhere, because there is nothing behind
it to open.
Every chip answers a pointer and the keyboard the same way, the log chip included. It takes the row wash, the focus ring every control here takes, and its tooltip on hover, on focus and on a click alike -- the log chip is a button rather than a link, and nothing else about it differs. A chip drawn inert beside its neighbours would be saying it is a different kind of thing.
The strip is ordered by the dots: green, then red, then grey, and inside one colour the
notifier's code and then the connection's. A notifier's chips stand together only where their
dots agree -- the channel that stopped is what the strip is read for. channelsOf answers in
that order, so no screen decides it.
Below md the heading is the fold, and it carries the answer. A chevron, the word Channels,
and at the right one dot per channel in the same colours and the same order, nothing else. It is
folded on every visit: what a phone is on this screen for is the rules and the queue. Opened, it
is a table of three columns -- the dot; the glyph and the code; and either Set up <notifier>
for a grey chip with no credential or how long ago the check was -- and a row with a credential
is a link to it, like the chip.
An option wears what its value wears¶
A dropdown whose options are values the app already draws somewhere -- a status, a kind -- renders each option as that drawing: the status filter's rows are the same chips the listing shows, and the trigger carries the chip it chose. Choosing becomes recognising. An option never wraps; the menu grows to its longest row instead of folding a label in half.
The menu is floored at its control and capped at the screen. It is never narrower than the control it hangs from, so a menu is at least as wide as the thing that opened it; it is never wider than its longest row needs, nor than the screen less a 16px gutter at each edge. It hangs from the control's start edge, swaps to the end edge where starting there would put it off screen, and slides along where neither edge fits -- so a menu never scrolls sideways and never hangs off the screen. Only where the longest row is wider than that cap does a row give way, and then it is the label that ellipses.
A string option is drawn bare, and every other value wears its JSON. A generated form's enum
is words a document carries -- GET, all_success -- so the row and the trigger read GET, not
"GET"; a number, a boolean and null are drawn as the JSON they are, which is what keeps 2 and
"2" two choices in an enum holding both. The quoted spelling is the token a select addresses a
choice by and it is never on screen: the trigger draws the option, not the token.
A choice between looks is made on the looks themselves. Where the values of a setting are
appearances -- the three palettes -- the control is one card per value showing that value, and
the card is the radio: role="radio" inside a radiogroup, one tab stop for the group, the
arrows moving and choosing in one gesture, Space and Enter choosing what has focus, and a two
pixel accent ring on the chosen card and on the focused one. The chosen card also carries a check
in the accent, because a ring alone is a colour doing a mark's job. A swatch is drawn from the
tokens it stands for and never from a colour written down beside it.
A surface is inset once, and 16px is the inset¶
Every surface this app draws -- a dialog, a card, a panel pane -- holds its content 16px in
from its own edge, and the generated primitives already say so: DialogContent is p-4 and
Card is py-4 with px-4 on its content. So a surface writes no padding of its own. A py-2
or a py-8 laid over a card is a second answer to a question the card has already answered, and
the three states of PageState proved it: the same card carried 24px of vertical padding while
reading, 24px when empty and 48px when refused, for no reason anybody could state.
A surface that lays out its own body zeroes the primitive's padding and spends the same 16px
itself. The command palette is the one: a search row, a scrolling list and a footer rather than
a form, so index.css states the inset once as --palette-inset and every part of the dialog
stands on it -- the search glyph, a shelf heading, a row's icon tile, the footer's key chips.
A strip that crosses the whole width (the generated footer's -mx-4 p-4, the palette's search
row and footer) takes the inset back inside itself, so the seam runs edge to edge and the words
still land on the edge. A strip's height is the inset above and below what it holds, which is
what makes a 24px line of search text a 56px row.
Half the inset is the only other number, and it is the gutter a floating box gets. A list of
rounded rows needs room or the highlight touches the surface's sides, so the list is padded 8px
and each row is padded 8px again: the wash is inset 8, its content lands on the 16 like
everything else. A box nested inside a surface that is already inset takes the same 8 --
Refusal inside a form or a dialog is p-2, not a second 16.
A surface scrolls; a block inside it does not. A dialog above the breakpoint has no height
of its own, so one that outgrows a short screen is capped at max-h-[calc(100vh-4rem)], laid
out as a column, and its body is the part that scrolls -- the header and the verbs stay where
somebody reaches for them, which is the same arrangement index.css gives every dialog below
the breakpoint. A block that caps itself instead puts a second scroll inside the first and cuts
its own content off at a line nothing drew: a settings group capped at 40vh hid the last
password box under its own fold, and what was on screen was a label with no control beneath it.
So the three tall dialogs -- a new rule, a new connection, and an apply whose offer opens a form
in a row -- cap the surface and scroll the body, and nothing between the header and the footer
declares a height.
A preview of what already exists is the one exception, and it takes a pane's height. A
surface that asks for a decision has to draw the thing being decided about -- the schema this
instance holds, under the sentences saying what replacing it would do -- and that reference
material has no length of its own. Given its own height it grows until the sentences and the
controls that act on it are below the fold: the apply dialog's differs row drew a 283-line
shape as 5110px of pane and left its own Cancel and confirm 4904px past the foot of the dialog's
scroller, and even a sixteen-line shape with ten dependants left them 98px past it. A reader must
never have to go looking for the control the surface is asking them to press. So a preview
declares a height and scrolls inside itself, which is what holds the sentences and the controls
still, and the whole of it stays one press away in the window every pane offers.
The height is the one that leaves the controls on screen, and it is measured. It is a pane's
height rather than a share of the viewport -- max-h-40 on the block of JSON in that row, nine
lines, which is what the dialog's own cap leaves on a 768px screen once the sentences have had
their room. What it is measured against is the scroller the controls sit in, found by walking up
from the control: compared against the dialog's own box a clipped control reads as visible, which
is how this got past two rounds of review.
A preview, and nothing else. The exception covers read-only reference material -- a block of
JSON, a stored body, the definition a field's code resolves to -- and it does not reach a form, a
field, or anything else with a control inside it. That is the fault the rule was written for: a
settings group capped at 40vh hid the last password box under its own fold, and what was on
screen was a label with no control beneath it. Everything else between a header and a footer
still declares no height -- and it came back in three dialogs at once, each in that same shape:
the run dialog capped the parameter form it wrapped at 50vh, the new source capped its sample
at 32vh and the new schedule its pinned parameters at the same, every one of them
inside a surface that declared no height of its own. So two of the three stood taller than a
768px window with nothing to scroll, and the third hid 1372px of its own form under a fold
nothing drew. A share of the viewport named anywhere but on the surface is the tell, and it
is what a scan can read.
A preview's height is the block's, not its callers'. Where one component draws every preview
of a kind -- JsonBlock is the block of JSON everywhere -- the height is stated there once, so a
call site cannot leave it off and two call sites cannot disagree about it. A surface with less
room to give narrows it from outside and says what it measured, which is what the apply dialog's
carried row does; a surface with more room does not widen it, because the window is where the
whole of a thing is read.
A row expands under itself rather than opening a second dialog¶
Where a row's verb needs a form -- changing a password on the settings dialog, minting the
connection an apply's carried offer says this instance has not got -- the form opens in that
row's own place, under it: its fields on one line, and Cancel beside the verb, right aligned at
its foot. A dialog raised over a dialog puts a scrim over the thing it is about, and the row that
offered the verb is already the sentence saying what the form is for. The button that opened it
is replaced by the section while it stands, so there are never two controls that shut it, and the
refusal is Refusal inside the section, where the fields it is about are. One row at a time:
two open forms in one list are two half-typed things with one confirm behind them. The exception
is the connection dialog and the account dialog, which are one dialog being tried and not a
second one: Edit is the same surface, and the only thing ever raised over one is the ConfirmDialog
every verb that takes something away goes through.
What the row already says, the form does not ask. The row is the summary, so a box repeating a value drawn a line above it draws that value twice and offers to change something the row's own case depends on. A form that stands inside another decision opens on what is required and folds the rest, because the list the row belongs to has to stay on screen beside it.
A form reached this way is the same form it is anywhere else. Its state, what it sends and
why its confirm is shut live in one place that both surfaces read -- lib/connection-draft and
hooks/use-connection-draft under ConnectionFields, drawn by the New connection dialog and by
that row -- because a second copy of a form is a second set of rules about what a blank box
means, and one of the two will be the one with the credential in it.
The step panel's engine groups are the same gesture at rest: the row is the group's own summary, and its fields open under it rather than in a pane of their own.
An account's tokens are listed under its row. A token authenticates as one account, so it is
filed there rather than in a second table with an Account column. A chevron at the head of the row
opens and shuts it, with a tooltip saying which, and does nothing else; pressing the rest of the
row opens the account's dialog, and the heading's own tooltip says so. Several accounts may stand
open. What opens is the same ListTable every
listing is, inset under the row and reading GET /users/{username}/tokens when it is opened and
not before. It keeps a cursor of its own and is capped at 24rem with its heading stuck, so an
account holding hundreds does not push every other account off the page. A closed row says how many
live tokens it holds, from one read of GET /tokens, and says nothing at all where that read did
not reach the end, because a number off part of a listing is not a count. An account with none
draws No tokens. when opened. New token is on the row, so a token is never made for the
wrong account, and the row opens to show what it made.
A token row is its name, the three times it has, and Revoke. There is no Prefix column: the name
is what a token is known by. The prefix is drawn beside the name only when two live tokens of one
account share that name, which is the one time the name stops telling them apart, and its tooltip
says what it is. The expiry is a chip beside the name, only where a token has one; Last used and
Created are columns. Revoking asks first, and takes the one token the row is. The API
revokes by id, so a row's own Revoke reaches that row and no other. The question names the token the
way the row does: its name, and its prefix beside the name where two live tokens share it.
A control only ever claims what the situation supports¶
Three faults are one fault: a button labelled with its mechanism rather than its outcome, a heading that enumerates a column the rows already carry, and a confirm that cannot confirm. Each is a control asserting something that is not there. What a control says and offers is what the situation can actually give, and everything below is that rule applied.
A dialog that asks is a decision, and a decision needs both paths. A way back, and a way forward that does something. Where the forward path does not exist, the control that would open the dialog is shut and says why, and no dialog opens: the pipeline editor's Apply is shut on a document that is already the version the instance holds, because a dialog whose own answer is that its button would write nothing is friction with a question mark on it. Where there is nothing to decide -- the outcome is certain and harmless -- the action happens and reports itself in one line, the way every other action that completed without incident does.
A footer has one dismissal word, and it is Cancel. A dialog that asks carries Cancel and
its confirm, and nothing else in its footer. A dialog that only tells carries no footer at all: it
is dismissed by the corner's close and by Escape, so no footer ever says Close, Done or
Dismiss. DialogShell is that rule written once -- confirm given, the footer and no corner
close; confirm absent, the corner close and no footer. A dialog that reads one thing is the third
case, and it is a connection's: the corner close, and a footer of verbs on that thing rather
than a confirm, so nothing in it says Cancel because nothing has been started. A dialog that asks
and then tells changes case when it has answered: a token made, whose secret it now shows, and a
test message sent, whose delivery it now reads, take the corner close from that moment, because
nothing is left that a Cancel could take back.
One dismissal, one control. A dialog whose footer carries the way out does not also draw the
corner's own -- showCloseButton={false} -- because two ways out of one dialog are two answers to
one question, and a reader counting controls is counting wrong.
A confirm is drawn only where pressing it would do something. A plan the instance would refuse, a carried code that collides with what the instance holds, a connection nobody has created: in each the apply cannot proceed, so the button is absent and the sentence beside the rows is what says why. The exception is a control shut by who somebody is, which stays drawn and shut with its reason, because that is a courtesy about the account rather than a claim about the document -- and it is how somebody learns the verb exists and is not theirs. A run's verbs are the second exception while the run is unsettled: what it allows changes under the reader as it moves, so Pause, Resume, Cancel and Rerun keep their places and are shut there with the reason. A settled run has stopped moving, and draws Rerun alone, as the run screen says below.
A shut control looks shut. It drops its fill for a hairline ring on no ground, its word goes to
the muted ink at weight 400, and the pointer over it is not-allowed -- so a shut verb beside an
open one is never read as the quieter of two open ones.
A form that is not filled in yet is not this rule. A confirm shut because a required box is empty, because a write is in flight, or because the reader has not typed what the kind asks for, is a forward path that exists and is not ready -- it stays drawn and shut, and filling the form opens it. What is not drawn is a confirm whose outcome the situation cannot produce however long somebody sits in front of it.
A shut control stays reachable. disabled takes a control out of the tab order and stops it
taking a pointer, so the sentence saying why is readable by nobody on a keyboard and by nobody
hovering the control itself. A verb on a screen's strip is shut with aria-disabled instead:
assistive technology hears the same thing, the control keeps its place in the tab order, the
title is reachable both ways, and components/ToolbarActions swallows the click.
A write that can be refused says so, and a shut control says why¶
Every write states its refusal, and there are two places to state one. A dialog or a form
has room beside the thing that was refused, and that is components/Refusal; a row's button and
a panel's verb have none, and that is sayRefusal, the one toast this app raises about a
request. A write whose rejection handler is a comment saying the row keeps what it had is a
write that failed in silence. Nothing dismisses on a refusal: a dialog that closed and a
panel that emptied would both have said the request went through.
The refusal is read from the problem document, not from the status. Problem.title is the
status phrase this server sets it to, so a dialog headed by it says "Unprocessable Content" on
every refusal it can make; problems is the list behind the sentence. lib/refusal is where
that is decided once -- including that a detail which is only its own problems joined is
drawn as the list alone rather than as the same sentence twice.
A refusal is drawn in this interface's words, not in the ones the server sent. A refusal
arrives with a dotted code, the params its sentence was written out of, and that sentence
already rendered into detail by whichever Python process minted it. The code and the params
are the translatable pair, so what is drawn is LABELS.refused[code] written out of the params;
detail is drawn only where neither this table nor the instance's contributed one holds a
sentence. Drawing detail by preference is how a translated interface ends up saying translated
chrome around an English refusal. A pack's codes are not in labels.ts -- this bundle cannot
know which packs are installed -- so lib/wording reads those once from GET /labels, and a
read that fails leaves every refusal on the fallback, which is what was drawn before it landed.
scripts/check_refusal_labels.py is what keeps the fallback from quietly becoming the rule
again.
A control an account's role would have refused is shut, with the sentence saying why. The
API has two role gates and so does lib/roles: an operator applies, runs and schedules, and
connections, schemas, accounts and tokens are an admin's. useMayWrite is what a control asks,
and it is the only reader of the role, so no screen can have its own idea of what a viewer may
press. This is the same courtesy the rail's admin section is -- the server is what refuses -- and
what it buys is nobody pressing a button to be told off. A disabled control takes no pointer
events, so the sentence goes on components/Refusable around it as well as on the control.
A required field is not a complaint until somebody has been there. A form marks and states a problem only for fields that have been left or that a submit asked about; the button the caller owns is what stays shut in the meantime, saying why. A box drawn red the instant a dialog opens is telling somebody off for opening it.
Nothing wears interactive chrome unless it does something¶
A row lights under the pointer whether or not it can be opened -- .row-hover, one wash from
the surface ladder, weaker than anything meaning chosen, spanning the row's full width the way a
menu item's does. The cursor is left alone on a row that opens nothing: a pointer finger is a
promise. A panel nobody has filled has no tabs, so its strip carries a plain label rather than
one lone tab shaped like a control. A button that takes something away -- revoke, deactivate,
cancel -- wears .destructive-action, which tints critical on hover and says what the click
costs.
Where a stock behaviour exists, it is the behaviour. A menu that marks a choice puts the
mark in a reserved gutter on the left, in the same column on every row, and hangs off its own
trigger. The generated files in src/components/ui/ are pristine, so what this app wants of
them is said in index.css against their slots, never by editing them.
A graph fits what it draws¶
A node is 44 tall, two lines, and as wide as its words, on every canvas. The title in mono, and
under it the block it runs, with the key in front where the title is a name. No key is ever
cut: before elk sees a box, lib/node-size measures each of its lines in the face the node sets
it in -- on a canvas of its own, in the loaded font, read off the characters until the faces have
loaded -- with every glyph, mark, count and dot that stands on that line, and elk is handed the
widest plus the box's chrome, between a floor of 96px and a cap of NODE_MAX_WIDTH. Only past
the cap does a word truncate, and it carries its whole value on hover. The editor's box holds the
room of its edited mark before it is edited, so the first keystroke into a step moves no box. A box is filled in --node, edged
in a line a step above the canvas, and lifted by a shadow of its own; a status is the only colour
on it -- a succeeded box takes the succeeded edge, a failed one the failed edge at 1.5px over a
wash of the same hue, a skipped one a dashed edge on no fill -- and a chosen box is ringed in the
foreground, which is no colour at all. A document's config is a map of any size and one
truncated line of it crowds the box, so the step's own pane holds the whole of it. A step whose
for_each fans it out says so on the second line -- a stacked glyph and the count, or the word
"each" where the list is an expression a run resolves -- in the neutral ink that line is already in,
because a status is the only colour a node carries. On a run the state's dot stands at the end of
the title line, and the duration at the end of the second. What a step is doing -- what it waits
for, how a retry went, why it failed -- is the box's tooltip and the panel's, not a third line,
because a line nobody can read at 11px across 138 pixels says less than the colour of the box. A
run's fan-out holds its elements in a strip beneath the two lines, and an element's key wraps
rather than truncates: the chips widen the box up to STRIP_ROOM and then wrap onto as many
16px lines as they need, and the box is that much taller. A retry's countdown is given its widest
room, so a box does not move every second. Past ITEM_STRIP_LIMIT elements the strip counts them
on one line. Before a grid read from a step's output has expanded, that strip is the faint words
waits for <step>. Elk is told the height each is actually drawn at, or the rows come out spaced
for a box that is not there.
Every word on a box carries its whole value on hover. A title, a key and a block each carry
their full value on their own title, with what the step is doing after it on a run.
A source says what it listens to where a block would be. A step with source: runs no block, so
its second line is what it listens to and its title leads with ICONS.source, on a dashed box. In
the editor that is the instance's own reading of the source -- listensTo, POST /endpoints/signups
or what a polled kind says of itself -- and the document's kind until an apply has made one. On a
run it is the kind, which is what the pinned graph carries. A reply says reply there, on the editor's canvas
and its step panel as on a run. A step that is none of the three says no block.
A canvas on a page is as tall as its drawing, or as tall as the room the run screen leaves it. The pipeline's page draws the graph in a box whose height follows the layout's own proportions at the zoom the width allows, so a five-step chain is a strip and not a stage with a strip floating in it. The run's page is a column whose graph takes every pixel between the heading and the docked terminal -- less the failure panel, where there is one -- so there is no dead ground under a small graph, and dragging the terminal's seam is a resize the canvas refits to. It is never shorter than its drawing or 240px, whichever is less, and a drawing taller than that is fitted at the floor and panned. The editor keeps a stage, because it is a workspace somebody adds to.
A canvas on a page pans and zooms; a plain wheel is the page's unless the canvas is the
screen. Dragging the ground pans every canvas. A pinch, and a wheel with Ctrl or the platform's
modifier held, zooms a canvas on a page about the pointer, each event held to one
WHEEL_ZOOM_LIMIT step, so a mouse wheel's notch is a step and a pinch follows the fingers. On the
pipeline's page, whose canvas is as tall as its drawing and has panels under it, a wheel with
neither scrolls the page, so a long page never traps it. On the run's page the filled canvas is the
screen and there is nothing to scroll past it, so a plain wheel zooms it the same way, one
WHEEL_ZOOM_LIMIT step per event about the pointer, and the page under it never scrolls. A
trackpad's two-finger scroll is a plain wheel to the browser and cannot be told reliably from a
mouse's, so on the run's canvas it zooms too, as it does on React Flow's own default canvas; the
ground is dragged to pan. Below the breakpoint a finger on a page's canvas scrolls the page, and the
graph's window is where it is panned. The zoom controls say how else to zoom on their tooltips, by
the screen they stand on, and nothing is drawn over the graph to say it.
A view's layout is the reader's for the visit. The run's graph and the pipeline page's carry a
Layout control: the direction (left to right, or top to bottom), the edges (the
canvas's own stepped line, elk's orthogonal routes around the boxes, or elk's splines) and the
spacing (normal, compact, roomy). The default is exactly the layout every canvas opens on; a routed
or curved edge is drawn along the points elk answered, with each edge into a step arriving on its
own rather than merged into a trunk. The choice is held by the screen and nowhere else -- not in
storage, not in the address -- so the next screen, and the next visit, open on the default. The
editor has no such control, because a document's arrangement there is the author's.
A view opens in a window. Under a canvas on a page stands WindowedPane's strip with the
window's button. The window is a stage: the wheel zooms, the ground pans, and its corner carries
the zoom, the fit and the layout, which is the same choice as the page's. A box chosen in the
window is chosen on the page -- the address and the panel follow it -- and the window stays open
until it is dismissed.
Every canvas opens with the whole DAG in view: fitView with padding, held between the two
bounds in lib/dag-layout. FIT_MAX_ZOOM is 1, so a fit never draws a box above its own size and a pipeline of two boxes is
not magnified to fill the space. FIT_MIN_ZOOM is 0.8, so a fit never draws a 13px title under
10.4px or an 11px word under 8.8px. Past that floor the graph is not shrunk: it stays at the floor,
hangs off the canvas, and is panned. A view too wide or too tall for the canvas opens with the
start of the graph at the top left and the rest off the far edges, because a graph is read from its
first steps and a centred one has both ends cut, and dragging the ground is how the rest is reached.
Zooming further out is still there by hand, down to MIN_ZOOM, and that view is the reader's. It
re-fits when the node set changes, when the reader changes the layout, and when the canvas is
resized -- the right panel opening and the terminal's seam moving are resizes, a page scrolling is
not -- and stops the moment the reader pans, zooms or drags a box: after that the view is theirs,
and the fit control, or Re-layout in the editor, is how they hand it back. A press that moves nothing, on
the ground or on a run's box, is not a pan. A resize of a view the reader took over only brings
the chosen box back onto the canvas, at their zoom, if the resize cut it. The browser suite
asserts every node's box lies inside the canvas for a graph that fits at the floor.
A deep graph is wrapped onto rows rather than drawn as one. Seventeen steps in one
left-to-right row is three thousand pixels of canvas, and a fit answers that by putting a node's
text under six pixels. Past WRAP_WIDTH -- which rankDepth measures a shape against before elk
sees it -- elk is asked to cut the layering into chunks and stack them: the ranks still read left
to right, and the height nothing was using carries the rest.
The zoom controls are this app's. React Flow's own step by a fixed twenty percent, which on a
graph read at half size is four presses to nothing, and its fit button fits with the library's
options rather than the ones both canvases share. ZOOM_STEP is one press either way, and the
fit button is fitCapped, the same one every other fit goes through. A canvas with nothing on it
has nothing to zoom, so it draws none of them, only the control the screen adds. On a page they
stand in the graph panel's bar -- zoom out, zoom in, fit, layout -- rather than over a drawing
that is only as tall as itself; a stage carries them in its corner.
The canvas edits¶
One canvas, two modes. GraphCanvas is a view until it is handed editing, and then it is
an editor: draw an edge, delete one, move a box, finish moving it, drop a connection on empty
ground. The run's graph passes nothing and is what it was -- a step is selectable there and
nothing else, because what it draws already happened and no verb would change it. The same
split is on the box itself: StepPorts draws the two anchors React Flow routes an edge to, and
only the editable mode makes them ports -- visible, connectable, .dg-port in index.css. A
run's graph keeps the anchors and shows none, and the browser suite asserts that.
An edge is a depends_on entry, and it is one edit however it was made. Dragging between
two ports and choosing a step from the chips in its own pane end in the same withDependsOn
against the same local document, so the editor's strip counts a drawn edge with everything else
unapplied and nothing is written until an apply. lib/graph-edits is that seam: withEdge and
withoutEdge answer with the document they were given when it already says what was asked, so
drawing an edge twice is a gesture rather than an edit. A selected edge and the Delete key take
one away, and so does a selected step -- withoutStep takes its name out of every other step's
depends_on in the same edit, because a depends_on naming a step the document no longer
declares is what an apply refuses. Only one thing on the canvas is chosen at a time, or one press
of the key would take two away. React Flow answers neither key while the focus is in a text box,
so a Backspace meant for a config field is never a step.
A loop is refused before it is drawn, and the refusal names it. cycleThrough walks the
document's own edges and answers the shortest loop the new edge would close -- parse → active →
report → parse -- which is what the toast says. $apply refuses the same document; a canvas
that accepted the gesture would have answered a pointer with a round trip.
elk places a graph; a reader places a box. A dragged position is that pipeline's own, kept
in lib/canvas-layout under dirigent.layout.{pipeline} as px-intent, exactly as the right
panel's width is -- what somebody dragged a box to was a decision about that pipeline's shape,
and it survives a reload. A step nobody has moved keeps elk's suggestion, which is what a step
added to an arranged canvas gets. Re-layout clears the arrangement and fits the view to what elk
decides afresh.
A step is added from one menu, wherever the asking started. A right-click on empty ground opens it at the pointer -- on a document with no steps as much as on a drawn one -- the button in the corner opens the same menu under itself, and a connection let go over empty ground is "and then this": it opens where it was dropped, says which step the new one will wait for, and the step lands there. A right-click on a box opens that box's own menu instead: one more step after this one, which is the same add-step menu with the edge already decided, and this one taken out.
The menu is shelved the way the catalog is, and searched the way the palette is. The rows
are the groups the blocks themselves declare, which is what the Blocks screen shelves by, so the
menu invents no filing of its own and map.jq and transform.jq land on one shelf. A sensor sits
in its group like anything else and wears the chip saying it waits, because a chip on every row
would say what the absence of one already says. Typing replaces the shelves with flat breadcrumbed
results -- shell ▸ run -- every term narrowing, sensor and operator matching by kind; the
arrows walk them and the return key places one. The first Escape clears the box and the second
closes the menu. lib/add-step is all of that as pure functions, so what the shelves hold and
what a search finds are decided in Node.
The key is derived, not asked for. A menu that places a step on one keystroke cannot stop
to ask what to call it, so transform.jq lands as jq and the next one as jq_2, and the
step's own pane is where it is renamed -- alongside everything else about it.
The run screen¶
What failed, why, and in which step lands above the fold. The heading is the pipeline's title with its code and the run's instant in mono under it; one line under that says the status word, the step it ended at, how long it took and who started it; then the graph, filling the room down to the terminal. Under the graph a run that did not end well stands its failure panel across the width -- the step in mono, the block, the catalogued code, the sentence the step failed with, the connection the step named with its health where it named one, and the parameters the run was given -- and the terminal opens at the foot with every line the run wrote. At 1440 by 900 the failure starts above the fold. A run that ended well has no failure to stand. Choosing a box on the graph narrows the terminal to that step and opens it in the panel.
A run's verbs stand in one place while the run can still move. While a run is unsettled, Pause,
Resume, Cancel and Rerun are drawn at the right of the heading, in that order, and a verb the run's
state does not allow -- Pause on a paused run, Resume on a running one, Rerun on either -- is shut
in place with aria-disabled and its reason on hover and on focus, because the run's state changes
under the reader and a row that grew and shrank with it is a row nobody can learn. A settled run
draws Rerun alone, as the primary: it will never be paused, resumed or cancelled again, and three
shut verbs that no state of it will ever open are controls claiming what it cannot give. Cancel
takes the critical wash while it is open.
The chosen step is the address. Choosing a box sets the address to /runs/<run id>/<step>,
and that address loaded chooses the step and opens its panel; the ground, or closing the panel,
puts it down and the address goes back to /runs/<run id>. A fan-out's element and a try of the
chosen step ride in the query as ?item=<run item id> and ?attempt=<attempt id>, drawn open and
marked and brought into view, and they are dropped when another step is chosen; ?tab= is kept.
Every move between steps replaces the address rather than adding one, so Back leaves the run. An
address naming a step the run does not have opens the run with nothing chosen and one line saying
so. lib/step-address is that, for the run and for the pipeline's page alike.
A try and an element carry their ids. Each attempt row in the step panel draws its attempt id, and each element of a fan-out's output its run item id, in mono with the copy button the run id has. The step's code is the panel's heading and is not drawn again on either.
A run is named by when it started. A run has no name of its own and its id is a handle for
machines, so what heads one is its pipeline's code and the instant it began, spelled the house way
and with the id on hover. Run is the word that stands in over the pipeline's name until there is
a run loaded to name -- the palette's shelf while the read is in flight -- and it is followed by
the pipeline and the instant the moment there is one. A queued run has not started, so every one
of those readings takes started_at ?? created_at and none of them can draw a blank.
How a run was started is one fact, said once. startedAs in lib/runs is the one phrase,
on the runs listing's Trigger cell, the pipeline page's Recent runs, the run's header and its
Trigger section. A person or a token is named by the label alone -- agent (token mcp-trial)
already says it was a token, so api token is not drawn beside it -- and the header reads it after
by. A schedule, a backfill or a source is its kind with the code it names, schedule nightly.
A run started by a step of another names that run by its short id, a step of run 32ee6409, and a
rerun the run it repeated, rerun of 32ee6409 by admin; on the run's Trigger section that run is
the link. What the server recorded, uuid and all, is on hover. The Trigger column is prose: it
truncates in its share rather than widening the table.
The runs grouped by pipeline. The Runs screen's View switch shows one row per pipeline that
ran in a window (?group=pipeline&window=24h; 1h, 24h or 7d): the title by the quartet, the newest
run's state, a dot and a count per state it has runs in -- a state with none is not drawn -- the
most recent runs as dots, oldest left and newest right, each a link to its run with its state and
when on hover, when it last ran, and the median duration of the settled ones. A row opens the flat
list of that pipeline over the same window. The strip is cut from the left where the cell is
narrower than it, so the newest run is the one always shown.
Two durations, measured at two altitudes. The run's is started_at to finished_at, beside
its status chip; a step's is the earliest start of any of its attempts to the latest finish of any
of them, drawn at the far end of its node and again as the step's took. They are not one number
seen twice, and the step's is a bound rather than a first and a last: a fan-out's elements settle
out of arrival order, so what the stream said first is not what the step began with. A running
step counts against the screen's own one-second tick, which only exists while the run is unsettled,
and a duration with an end missing reads -- rather than a number that would keep climbing after
nothing was happening. formatDuration is the one ladder -- 840ms, 9.4s, 42s, 2m 4s,
1h 12m -- and a decimal survives only under ten seconds.
A step is titled by the quartet's rule and its block sits under it. The first line is the
step's name where it has one and its map key otherwise, wearing the mono face when the title is
the key; the second is the block the step runs, with the key in front of it and a middot between
where the title was a name. So the key is on screen exactly once, whichever way round the step was
written, and what a step actually runs is never a click away. What the step is doing -- what it is
waiting on, what it saved to, what went wrong -- is on the box's tooltip, and the panel says the same
things as facts rather than as a second sentence.
A live wait's fraction is a foot line, never a number. A step whose attempt reports how far
its wait has come draws that as a 3px line in the hue of the box it sits in: along the node's
bottom edge, inside its border and taking no height, and directly under the attempt row's waiting
message. It is a progressbar with aria-valuenow, the node's tooltip gives the percentage, and
no figure is drawn beside it, because the message already says how far. It is drawn only while the
attempt is waiting or running: a settled attempt still carries the last fraction it reported, and
progressOf in lib/run-detail is the one reading that drops it. No progress draws nothing.
The terminal drawer¶
A run's screen stacks: the canvas above, and a console across the foot of the content area at
the height somebody dragged it to. The right panel is beside both and is untouched by either --
what is beside a run is a different question from what is under it. It is opened from a glyph in
the run screen's own strip, from a row in the palette, and by pressing T, which is a bare
letter because every chord this app has is already spoken for.
The terminal is the run's one log. The run screen draws no second list of the same lines. A run that failed opens on the terminal, for that visit and without writing the preference; any other run opens it as the reader last left it. Choosing a box on the graph narrows the terminal to that step, the way its own step filter does, and a running run tails live in it. The failure panel says what failed in its own words -- the step, the block, the catalogued code, the sentence -- and the terminal carries every line, the failing one among them, so the two repeat each other in that one line and nowhere else.
The drawer docks in the page's scroller. It stands at the foot of the body, held to the bottom of the one scroller while the body above it scrolls, so the panels above it and the drawer share one right edge.
The lines are the run's own state, not a second read. runs/{id}/$events already carries
every line the run wrote; the drawer filters what the reducer holds, opens no connection of its
own, and never will -- run-stream.test.ts drives the whole of the drawer's data path through
follow and asserts the concurrent-connection counter still says one.
Every step interleaved, in arrival order. What happened to a run is a sequence, so the
console is one, and the step select is there for whoever is asking the other question. Each line
is time, level, the step as a clickable prefix, the message, and whatever fields the line carried
rendered muted after it. The prefix opens that step in the panel through openPanelTab, because
somebody clicking one is not asking for whichever tab they last had open.
It is dark in both palettes, and it is the only thing in this app that is. A console is not a
card -- it is a code block's cousin -- so the six --terminal-* inks are declared once and the
dark palette leaves them alone. The ground alone has two depths: graphite on the light palette,
where black reads as a hole in the page, and a rung below the dark palette's own background,
where the drawer has to read as deeper than the app. Its header strip is ordinary app chrome on
the surface ladder, which is what keeps every control in it a design-system control with
nothing re-inked.
Three filters compose, and the count says how far. A level threshold, one step, and a match over the line are three predicates over one list; a foot line says "{shown} of {total} lines". Client-side narrowing here is not the listing rule being broken: a listing's filter must be the server's because a listing is a window onto rows nobody has read, and this is the opposite case.
Copy takes what is on screen and download takes everything. $logs answers a page of JSON
with no content-disposition, so the download walks the cursor through apiFetch and writes the
NDJSON out of a blob rather than pointing an anchor at a path that would navigate to a listing.
Open, dragged height, and the tail-following preference are all held the way the panels are:
pixels rather than a fraction, under dirigent.terminal*, with the drag on a separator that
answers the arrow keys.
Every screen teaches¶
A page is a title, and a section heading is a heading. An empty state states the fact --
"No runs." -- and adds a second plain sentence only where the way in is not on the screen, such
as a document key or a CLI verb. It never narrates a button that is on the screen: what New does
is what New says. The second one is a sentence, not a clause hung off a dash: "No blocks
installed. Plugins contribute them." is the shape, and every empty state on every screen is that
shape or the bare fact alone -- one of them punctuated its own way for long enough that the
catalogue could show it beside the others. A way in that is off screen is stated wherever it is
off screen, so a listing whose rows are declared by a document says so even where a button
beside it declares one too. A listing carries an API chip linking /docs#/<tag>, the fragment Swagger UI
writes on its own tag headings, so the requests behind a screen are one click away. Every screen
states its own two facts in the top strip through lib/screen-status, the address nothing
answers for included.
A heading names the category, in one noun where a noun exists, and never lists the values of a column beneath it. The Status column is already telling every row which state it is in, and a heading that enumerates two of them breaks the moment a third qualifies. Nor does a heading name the reader's situation: that states an obligation the screen cannot know anybody has. Where a noun for the category already exists anywhere in this product -- in the wire, in the engine, in the CLI -- that is the noun, because a screen may not coin a second name for something already named. A chart is titled with what it plots, and the window, the bucketing and the axis stay in the line beneath it.
The palette's anatomy¶
A 768px card at 15% from the top: a search row, shelved rows, a footer of key chips. The
screen's own shelf leads -- shelve in lib/palette puts every shelf whose rows claim
screen first, and the heading is free to name what it is scoped to, such as the run being
read. A row is an icon tile and a title, nothing else. What an action says about itself
(hint) feeds the filter and is never drawn: beside every title it is a column of glosses
saying what the titles already say. A row with no icon gets the neutral glyph rather than a
gap.
Adding a screen¶
- A component under
src/pages/, built onPageState-- 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. - A
<Route>insrc/App.tsx, inside theAppShellroute. - An entry in
NAVinsrc/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 whoserequiresisadmin-- which hides it, and hides nothing from the server, because role gating here is a courtesy rather than a control. - Anything the screen alone can do, registered with
registerActionsfrom an effect that returns the unregister. - If the screen has something to show beside itself,
fillPanelfrom an effect; if it has one thing to say about what it is doing,setScreenStatus. Both are undone the same way. - If it needs a dependency no other screen needs, a lazy route rather than an import.
A test asserts on a lib function, never on rendered markup. What is worth testing is the wire
layer and the pure decisions -- the fetch choke point, the SSE parser, the palette's filter, the
shortcut rules -- and all of it runs in Node with no DOM. Rendering is the browser suite's job,
and that suite drives a real dg dev rather than a mock.
Every word has a code, and they all live in one file¶
src/lib/labels.ts is the only place a user-visible string may be written. Every heading,
button, field label, placeholder, empty state, tooltip, aria-label, column header, confirmation,
palette row and settings row is a label in that catalogue, and a component reads one rather than
holding one. It is the same shape the server has had all along: dirigent_common.messages keeps
one Catalogue per area, mints a dotted code per message, and offers a walk over all of them at
once, so that a refusal can be rendered in another language by code. This is that in TypeScript's
idiom -- a section per area, the path through LABELS as the code, a duplicate name refused by
the compiler, everyLabel() as the walk.
A sentence the wire drives is a template, not a function. Everywhere else a value in a
sentence makes that label a function, because a caller in this bundle passes the value and the
compiler checks the call. A refusal's values arrive as a map off the wire, so there is no call to
check and nothing for a signature to buy: the refused section names its holes instead --
No run {run_id}. -- and the name has to be one the server's own template writes. That is the
one place in the catalogue where a value is not a parameter, and
scripts/check_refusal_labels.py holds both halves to each other so a renamed param fails the
gate rather than sending the renderer quietly back to English.
The code says what the string is for, never what it says. connections.check_row is still
right when its words become Ask again; connections.check_now_button_text is wrong the moment
anybody edits it. Concretely: name a label after the outcome it is on the way to, not after the
mechanism that gets there or the phrase currently in it. Codes are lowercase words joined by
underscores, nested one level further where a single concept has more than one surface form.
A concept holds its surface forms together. A run that ended with failed items is a chip on a
row, a number on a tile and a clause in a sentence, and those were three phrases in three files
until they became state.run.completed_with_errors.{chip,tile,sentence}. Putting them under one
code is what makes a fourth spelling visible while it is being written. The same goes for the
health words, the worker words and the run states: one concept, one code, its forms beneath it.
A noun is one entry, and the case belongs to the surface rather than to the word. A field's
label, a column header, a card's fact and a section heading are Sentence case; a definition-list
term, a chip and a fragment inside a sentence are lower case. Where the product draws one noun in
more than one of those, the entry holds each form it actually draws, under the surface it is drawn
on: label for the singular a control or a column wears, heading for the plural over a section
or a table, term for the lower-case singular a definition list reads, count for the sentence a
listing's foot counts in. A form nothing draws is not written down, and nothing derives one
form from another -- capitalising in code compiles English's capitalisation rule into the app,
which is the one thing this catalogue exists to keep out. Started beside started, and Blocks
beside a per-screen blocks, are that rule not yet applied.
A counted surface holds both numbers, so count is a {one, many} pair. A foot counts rows
and a row may be one of them, so the entry spells the singular as well: count: {one: 'schedule',
many: 'schedules'}, and counted picks. A word that also stands alone in a sentence is written
again under term, because that is a surface of its own -- the two hold the same string in
English and need not in a language that declines a counted noun, which is exactly the difference
a shared entry would hide.
A verb is one entry in action, and the states of its own control sit under it. The word on
the button, the word while the request is in flight and the word once it has landed are one
concept, so they are verb, busy and done beneath one code -- never a Creating minted
again on each screen that writes something. A pair of verbs that toggles one thing, Pause and
Resume or Enable and Disable, is two entries side by side there rather than a pair per screen.
A counted sentence is a {one, many} pair, never a ternary and never two sibling codes.
`${count} field${count === 1 ? '' : 's'}` is English's plural rule compiled into the
catalogue, and a language with three plural forms, or with none, cannot write itself into that
string; two codes a stem apart, stores_one beside stores_many, are the same pair with the
concept pulled apart. So both forms are spelled whole under one code and the component picks
between them -- the shape five entries already had while five more were still branching inside
the string.
A read in flight says Reading, and says nothing else. Naming what is being read -- the
artifacts, the report, the corpus -- adds no fact the pane it is drawn in has not already given,
and "from the server" adds none at all, because every read in this app is from the server. One
code on every card, button and pane whose answer has not landed.
A string with a value in it is a function taking named parameters, so a language that puts
the value somewhere else can. Nothing here reads a locale and nothing chooses a table; a second
language is const nb: typeof LABELS = {...}, which the compiler checks for a missing code, a
stray code, and a sentence that takes different parameters. That is the whole reason the
catalogue exists, and the reason it is not worth arguing with.
What is not a label: a wire value (completed_with_errors, cron), an element id, a
data-testid, a class name, a URL or a path, a media type, an icon name, the key cap in a
<Kbd>, and the keywords a palette action is filtered on but never draws. One word a person
reads is spelled outside the catalogue and it is named there: a state a plugin contributed, which
this bundle was built before and cannot hold a table of, is spelled from the wire by
statusLabel.
scripts/check_ui_labels.py holds the rule, in make static and make ui-lint: it refuses a
literal between JSX tags, at an attribute or property a person reads, in a toast, or anywhere at
all if it is sentence-shaped, and it refuses a label in the catalogue that no source reads.
It cannot see a bare lower-case word returned from a lib function, because nothing reading the
text can tell one from a wire value -- review and scripts/ui_copy.py cover that.
Documentation that quotes a label backticks it and marks it { .label }. A released note
once named a screen's heading in running prose, the heading was renamed, and the note went on
saying a word the product had stopped saying with nothing to catch it. Nothing could: docs/
holds 652 distinct backticked phrases and a handful of them are labels, so a check that guessed
would be mostly false alarm. The author says which instead:
scripts/check_docs_labels.py, in make static and make ui-lint, then refuses a marked phrase
the catalogue no longer holds, exactly as written, naming the page and the line. It is
attr_list, so the phrase renders as the inline code it already was; a marked page needs nothing
else, and a marker inside a fenced block is teaching the syntax rather than quoting the product.
A phrase quoted with no marker on it is not checked, which is the price of not guessing --
so a label named in prose is named this way or the sentence is written not to need it. What the
server says is not a label and takes no marker: a refusal's own wording is held to Python by
scripts/check_refusal_labels.py and by dirigent-cli/tests/test_shared_words.py.
Interface copy is plain, and most of it is absent¶
A label, a hint, a description in the interface is plain product English: "Timezone", "Follow logs", "Change your password." The house voice -- the wry sentence, the deliberate turn of phrase -- belongs in this documentation and in commit messages, never in the words a person reads while working. If a native speaker would not put it on a settings row, it does not go on a settings row.
The context says most of it; a sentence survives only by adding a fact. Concretely:
- A screen carries no subtitle. "Pipelines" needs no line saying it lists the pipelines.
- The top strip's note exists only when it is stateful -- the editor's shape and warnings, the run's settled tone, the workers' health. A static note restating the screen is noise.
- A section heading over a headed table carries no gloss; the headers name the columns.
- A dialog's description earns its lines by teaching semantics the controls cannot show (what sealing a secret means), never by describing the dialog.
A copy defect is a pattern, not an instance. Fixing one gibberish line means sweeping every screen for the same pattern in the same change -- the ui-review skill says the same.
The door greets, and its eyebrow is the one line of text in the app set in the accent. It is
the body size, uppercase and tracked out, in --primary-ink: the door is the one screen with
nothing on it to act on but the button beneath it, so the colour that means action is free to
name the product there. The login screen alone carries that eyebrow, a heading, a one-line subtitle,
placeholders and leading field icons, because it is the one screen a person meets before the
product's own facts are on it: there is no data to read, no title to take a heading from and no
shell around it, so the screen has to say where somebody has arrived and what to do next. No
other screen may copy any of it -- an eyebrow over a page title, a subtitle under a heading, a
placeholder repeating a label or the heading it sits under, or an icon inside a field is a defect
everywhere behind this door -- with one exception, the search glyph that leads every search box,
which says what kind of box it is rather than repeating a label. A listing's search box says
Search and nothing more, from components/ds/SearchField: the heading over it already names what is being searched, and the noun
and what a match is made over stay on its accessible name, which is read without that heading
beside it. A box on a surface whose subject no heading states says what it matches over instead
-- a picker's Search by name or code, the terminal's Search lines, the palette's own row.
The brand pane never carries a tagline. What is on it is the mark, the word dirigent, and
the instance and the version the door answers for. A line saying what the product is for is
marketing on the one screen whose only question is who is asking, and the graph behind the lockup
already says what is through the door.
Design system¶
Every recurring shape on a screen is one component in src/components/ds, and a page does not
hand-build what one of them covers. A lead cell, a chip, a dot, an icon-only button, a search
box, a heading inside a screen, a list of facts, a field, a notice, a dialog, a page heading and
a link are each drawn one way, by the component below, and a screen that typed the classes out
again is a second answer to a question the system has already answered. /design draws every
component in every variant and state -- enabled, shut with a reason, hovered, focused, empty,
loading, long text truncating -- in both modes; it is offered by the palette ("Design system")
and not by the rail. A component that needs a new variant gets it in ds/ and on that screen
first, then on the page that asked. The gallery is named for what somebody is looking for, not
for how the code files it: Text (Titles, Headings, Links), Marks (Labels, Status dots, Icons),
Controls (Buttons, Search, Form fields, Switches), Containers (Notices, Dialogs, Lists of facts),
with an index beside them that follows the scroll and a plain sentence under a heading only where it
adds a fact. A component keeps its own name in the code. A specimen stands on the section's card with
no frame of its own.
| Component | When |
|---|---|
EntityHeading |
Any addressable thing headed by its name and code. row-stacked for a listing's lead cell, row-inline where a row has one line, panel for a panel's head, dialog inside a dialog's title. The name, else the code; the code always on screen in mono and never twice (headingOf); chips beside the title; an optional one-line description. One title weight in all three |
Chip |
A word on a chip: neutral, mono for a key, dashed for an absence, good, critical, edit. onSelect makes it a button, onRemove adds an X with its own focus ring. It replaces the hand-typed chip and the pill Badge for a word. KindChip and StatusChip stay, on the same 20px and the same radius |
ToneDot |
Every state drawn as a dot, from one map: a tone (good, critical, warning, info, quiet, unknown, the last hollow) or a run status. A dot alone is the answer -- a green dot says connected -- and its word is its name and its tooltip. The word is drawn beside it only when it is news: failed, unknown. tip adds a sentence to the tooltip, and focusable puts the dot in the tab order where nothing around it is a control. StatusChip takes a label where a screen names the state in its own terms |
IconButton |
Any button that is an icon and nothing else, which is why every icon button has a tooltip. Its type requires label; the tooltip is the label, a longer tooltip, or why when it is shut. Sizes xs, sm and md. Shut with why, never with disabled, so it stays reachable |
SearchField |
Every box that narrows a screen. A leading search glyph, a clear button once there is text, Escape to clear, and the / key cap: / moves the caret into the first box marked for it, never while another field or the editor has focus |
SectionHeading |
A heading inside a screen. section is sentence case at text-sm font-semibold; group is the one faint uppercase label over a run of rows. An optional aside and link |
FactList, Fact |
Facts about one thing. grid is a term column beside a value column, a panel's and a dialog's, whose term is a key in mono where termMono says so; row is small facts wrapping on one line, a heading's strip |
FormField, SwitchField |
Every field: a prose label or a mono key, the required word in body ink, a hint, the default, help in one ink, an error, one 6px gap. SwitchField is a boolean setting as a row, the whole row its label |
Notice, Well, Inset, CodeFrame |
Notice is a sentence that must not be missed, critical, warning or neutral, with the tone's mark. Well is a group that belongs together, on the panel ground at 8px. Inset is reference material -- JSON, a code block, a log -- on the page's ground. CodeFrame is a code editor drawn as the field it is, and WindowedPane puts its pane in one. A refusal off the wire is still Refusal |
DialogShell |
Every dialog: a width from sm, md, lg, xl, a title and description, a body that scrolls under a fixed head and foot, and a footer of Cancel and one Refusable confirm, or of verbs on a dialog that reads one thing |
ConfirmDialog |
Before every verb that takes something away -- revoke, deactivate, delete, rotate. The title is the question and names the thing, its code in mono and nowhere else: Delete ops-hook-2?. There is no body, because the verb says what is lost. It holds one line only when that line changes the answer: what stops the verb, which shuts the confirm with the reason on its title, or what the verb takes beyond the thing named. A refusal off the wire is one short line, with no box and no second naming of the thing. A verb that cannot go without one value holds that one field: a password reset's new password. No such verb is immediate |
SecretReveal |
A secret the server answers with once: the value in mono, a copy button that says it copied and resets, and the line saying it is shown once |
CopyButton |
The icon button beside a string somebody pastes elsewhere -- a secret, a URL, a command line: it puts the string on the clipboard, says it copied, and resets |
PageHeader, SplitButton |
A screen's heading, at text-page. It owns the primary action through actions, last of them, which is a SplitButton when it carries more; a count beside the title; a code under it in mono for a detail screen; a trail for a detail screen. No subtitle |
TextLink |
Every link that is text. inline in prose, in ink and underlined, data for a number or a reference in a table, aside in the muted ink beside a section heading, external with its mark and a new tab |
Code |
Every code, key, block and kind in mono. One line, truncated with an ellipsis, the whole value on title; tone is the muted ink beside a title or the body ink where the code is the value |
Panel |
Every section of a page: a title bar with the label left and a count or fact right, a hairline, a body on the panel ground, a hairline edge and no shadow. graph puts a dot grid on the body, critical is the failure with its top rule and warning mark. inset is off where the body's rows lay out their own padding. A listing is ListTable, which draws the same panel |
MiniGraph |
A pipeline's shape at the scale of a row: the miniature rung, sources dashed, fan-outs stacked, a failed step filled, the scale shared and capped |
A mark comes from lib/icons and nowhere else. ICONS maps every concept the interface
draws a mark for -- create, edit, delete, run, rerun, pause, resume, cancel, refresh, recheck,
clear a filter, search, copy, external, settings, fit the view, open in a window, upload, a
template, the sidebar, and the nouns: pipeline, run history, step, block, connection, schema, trigger, schedule, source, user,
token, worker -- to one lucide icon, named for what it means rather than where it was first drawn,
and no two concepts to the same one: fitting the canvas is
Scan and opening a pane in a window is Maximize2. A page or a component that needs a mark
with no concept adds the concept there. A kind's mark is still lib/glyphs.
Nothing is fully round but a status dot and an avatar. A chip, a control, a card, a dialog and a switch take the radius ladder's rungs, and no class names a radius of its own.
scripts/check_ui_system.py holds the system, in make static and make ui-lint. Under
src/pages and src/components, the generated primitives and components/ds excepted, it
refuses a hand-typed word chip (one class string holding rounded-sm, border, px-1.5 and
text-xs), a Button sized icon* rather than an IconButton, an import from lucide-react
(which it refuses in components/ds as well), and an arbitrary rounded-[...]. What predates
the check is recorded in scripts/ui_system_offenders.txt, one line per rule and file with its
count. It is a ratchet: a file may hold exactly what is recorded, one more is refused as new and
one fewer as stale, so the change that moves a screen onto the system shrinks the record with
--write in the same commit.
An ellipsis means the action asks for more before it acts¶
A control's word ends in … when pressing it does not yet do what the word says, because it needs
something more from the person first: a file, a choice from a picker, a form. From a file…,
From a template…, Add step after…, Run…, Send a test…, Reset password…, Change…,
Set up slack…. The same action is written the same way on a button, a menu item and a palette row.
What it opens is titled without the mark, because by then the question is on screen.
These do not wear it, and each is a decision:
- A create following the
Newrule below.New,New tokenand a palette'sNew alert ruleorCreate an accountsay what they open, and every create is one. - A verb raising
ConfirmDialog, and anApplyopening its plan. Those ask for a yes and nothing else, soDelete,Deactivate,Revoke, a source'sNew tokenandApplystay as they are. A confirm's own verb is the act.Reset password…wears it because that question also needs a new password. - A word whose outcome is the opening.
Editis true the moment it is pressed: you are editing.Add a windowadds the section.Change…wears it because nothing has changed yet. - A place.
SettingsandKeyboard shortcutsare somewhere to go, not an action. - A control with no word. The canvas's
+is an icon, and its tooltip names it. - An action that acts.
Use as templatecopies and goes there, andValidateanswers at once.
A create button says New¶
On a screen whose heading names the noun, the primary create button says New alone: the screen
supplies the object. The full name stays on the control's aria-label, on the palette action,
which is offered from every screen and so needs the noun, and on whatever the button opens. A
screen offering more than one create -- Triggers -- keeps the noun on each button, because New
alone would not say which. The create is the strip's primary either way, and wears the fill. A create that belongs to one row is on that row and names its noun:
New token, on the account it is for.