Page objects
from pages.py. The shared dashboard renders them. It also resolves
navigation, runs actions, and refreshes followed regions.
Most apps write no JavaScript. An app that needs full control of its interface
still ships an ESM frontend. See
frontends for that path.
This page is the V1 contract. It gives the exact Python fields and the exact
JSON for every public model. The backend, the renderer, and
the gallery implement this
page. Nothing else names these shapes.
Terms
The contract uses eight terms. Each one has one meaning.Import surface
Every public name comes fromdruks.ui:
druks.ui imports no app. It is a platform namespace like druks.workflows.
It exports exactly these names:
Declare pages
Pages live inpages.py. Druks discovers that module the way it discovers
routes.py.
peers and peer are two top-level pages. peer_history is a child of
peer. A page path can hold as many segments as the app needs. The one-level
rule counts child declarations, not path segments.
A page function needs no return annotation. The route names Page as its
response model, so writing it again on every function says nothing.
Each page has a name. The name is the function name. Link and
App.navigation reference a page by that name.
Each page has a label. Druks derives the label from the name: underscores
become spaces. peer_history becomes “peer history”. Pass label= to
override it:
Rules
Druks checks these at boot. A break raises with the app name and the exact cause.- Exactly one page declares
/. That page is the landing page. @pagedeclares a top-level page.@parent.childdeclares a child page.- One child level is allowed. A child of a child is a boot error.
- A child declaration can live in another module.
- A child inherits every parameter of its parent route.
- An extra child parameter must come from the relative child path.
- A page function takes one parameter for each parameter of its route, and no others. Each one must be callable by name, so a positional-only or variadic parameter is a boot error.
- A catch-all is the last segment of its route. A catch-all anywhere else would swallow every route under it, so it is a boot error.
- A static child is a tab. The parent is the first tab.
- A parameterized child is not a tab. A
Linkreaches it. - A parameterized detail page shows a link back to its parent.
- Tab order is the parent, then the static children in declaration order.
- Declaration order does not control route matching.
- Two pages in one app with the same name are a boot error.
LinkandApp.navigationboth address a page by name.
GET /api/apps carries the page table. The shell resolves a
Link, a tab strip, and a parent link against it:
order is
the page’s place in the app’s declarations, which is the order its tabs show
in. Route matching sorts the table, so that order survives only here.
path is the shell path, not the API path. The shell fills each {name}
placeholder from the Link arguments and percent-encodes the value.
arguments values are strings; FastAPI coerces each one to the type the page
declares. A Link missing an argument reads as broken.
The parent of a page is its parent entry when it has one. Otherwise it is the
declared page whose path is the longest proper prefix of this page’s path,
and the landing page when no other page is a prefix. A parameterized detail
page links back to that parent.
Navigation
App.navigation is a flat, ordered list of page names:
- A name that no page declares.
- A parameterized page.
- A child page.
GET /api/apps carries the resolved pairs, so the shell
needs no second read:
App.navigation names declared pages and nothing else.
Routes
Druks builds the complete page route table before it registers any route with FastAPI. It sorts the table so that matching is global and not declaration ordered. A path segment has one of three kinds. The sort key of a segment is its kind:
Druks compares two paths segment by segment. A literal segment wins over a
parameter. A parameter wins over a catch-all. The rule holds at every depth,
for top-level pages and for child pages.
So
/peers/new always matches before /peers/{peer_id}, whichever one the
app declares first.
Two page paths have equivalent parameter shapes when they are equal after
Druks replaces every parameter name with a placeholder. /{id} and /{slug}
are equivalent. That is a boot error. No request could tell them apart.
The page API
Druks mounts one route for each page under the app’s namespace:/api/<app> route.
The shell reads a page at /<app><page path>. It calls the matching page API
route.
pages is a reserved segment under /api/<app>, like transcripts. A subject
type named pages is a boot error. An app router whose prefix is /pages is a
boot error. Without the check, FastAPI would hide one of the two by
registration order.
Page purity
A page function is a pure read-side projection. A page function can:- read Druks state,
- read the app’s own data,
- read a projection,
- read a read-only external source.
- write data,
- start or enqueue work,
- publish an event,
- answer a gate,
- cause an external effect,
- depend on mutable process state.
Liveness
APage or a named region declares what it watches:
Section with a name. The name must be unique in the
page.
follows= takes the subject the page watches. Druks reads
subject.identity and fills subject_type and subject_id:
Section that follows a subject must have a name. The shell replaces the
region by name, so an unnamed one could never be replaced.
Druks reuses the per-subject event stream that every app already gets:
follows= also takes the subject class. The page or the region then watches
every subject of that type, subject_id is empty, and the shell reads the board
stream:
snapshot event the shell reads the page again. It takes the named
region from the new page and replaces that region in full. It sends no block
diffs.
The shell keeps scroll position, focus, and unsubmitted form values outside
the region.
The shell owns the EventSource, the reconnect, the retry, and the
stale-response protection. A response from an older read never replaces a
newer one.
A follows= on the Page itself replaces the whole page body.
Gates
GateControls declares only the run:
- The shell reads
GET /api/gates/{run}. - The shell submits
POST /api/gates/{run}/answer. - The answer echoes
parkedAtunchanged. A staleparkedAtis rejected. - Both routes use the signed-in dashboard session through
current_account.
GateControls is not an Action. It never calls /api/runs/{run}/resume.
A GateControls block must sit inside a Page, or inside a named Section,
that follows a subject. Druks rejects a GateControls block with no such
ancestor when it builds the page. Without the follow, an answered gate would
stay on screen.
When the run resumes, the followed region refreshes and the controls go away.
Actions and links
AnAction names an app-local operation:
operation is the operation_id of one of the app’s own routes:
operation_id at boot. Two
routes in one app with the same operation_id are a boot error, and so is one
route answering two methods under it.
An Action exists only once a page function has run, so the reference is
checked when Druks builds the page — the earliest moment it exists. Two
failures answer
with the page-read error, and each one names the operation:
- No route carries that
operation_id. - The route is a GET. A GET route is a read. It can never be an action.
Body(embed=True) or a model,
no aliases, no query parameters. A route shaped otherwise answers 422 when the
action runs.
Two routes of one app cannot share an operation_id, and one route cannot
answer two methods under it. Both are boot errors: an action names one
operation and calls one method.
refresh: "region" needs a region. An action that asks for one and sits in no
named Section is an error when Druks builds the page.
So an action target declares path parameters and a flat JSON body only:
GET /api/apps carries the table the renderer resolves
against. It lists the app’s non-GET operations only:
Request shape
The shell builds one JSON object. It takes the actionarguments first, then
the submitted field values. A field name that repeats an argument name is an
error when Druks builds the page.
The shell fills the operation’s path parameters from that object. It sends
every remaining key as the JSON request body. Authentication, authorization,
and request identity stay on the platform route.
Results
refresh: "region" needs an owner. The owner is the nearest named Section
that encloses the action. An action with no such ancestor and
refresh: "region" is a page-build error.
While the request runs, the shell shows a pending state and blocks a second
submission of the same action.
A 422 answer carries the platform validation envelope. The shell maps each
error whose loc ends with a field name to that field. It shows the others as
form errors.
A Link navigates and never calls an operation:
Link sets page or url, never both and never neither. Druks rejects a
Link that sets neither or both when it builds the page.
A page names a declared page of the same app, and arguments fills that
page’s route parameters. The shell resolves both against the page table. It
shows a Link it cannot resolve as broken and names the page it wanted, and
the rest of the page still renders.
Link and Action are different public types. Both are blocks, so a page can
hold one directly. Card.actions and EmptyState.actions hold either one.
How to read the model listings
Each listing below gives the exact Python fields and the exact JSON. Three rules hold for every one of them. A discriminator carries its own literal as its default.Text.block is
Literal["text"] = "text". The author writes Text("…") and never passes
the discriminator.
Wire names are camelCase. alternative_text serializes as
alternativeText. Schema does that for every model here.
Druks coerces author input to the wire type. Three fields take a friendlier
input than they store:
The three unions
Blocks
Every block carries ablock discriminator.
A block whose one required value is the thing it shows — its words, its
content, its identity — takes that value positionally, and every other value
by keyword:
Metrics, Facts, List, Timeline, Files, ImageGallery, Stack,
Columns, and TableRow all read that way. A block that holds more than one
thing names every argument — Card, Section, Table, and Chart. So does
Form: its required value is the action that sends it, not something it
shows, so action= is spelled out.
Text
Markdown
Section
Card
Callout
Divider
EmptyState
Link
page for another page of this app,
url for outside, or subject for the subject’s own platform page — the
full story of what druks did about it, which no app page recomposes:
Action
arguments keys are the operation’s own parameter names. Druks serializes
them unchanged. A route parameter keeps its Python spelling on the wire.
Form
Timeline
at must name an offset, so items from different sources order against each
other. Druks orders the items oldest first, where the stamps keep their full
precision, and items that share a moment keep their declared order. A snapshot
arrives in the order it is shown.
Progress
completed is a meaningful optional value. It carries three shapes:
Giving both
completed and steps is a validation error, and so is a
completed above total or a value that is not a number.
Image
alternative_text is required, and text that is only whitespace is a
validation error. When the image does not load, the shell shows the
alternative text in its place.
Files
files accepts druks.files.File objects. Druks reads the name, media type,
and size from the file record.
/api/files/{id}, which keeps the platform’s own authentication.
GateControls
GateControls. druks.ui has no type named Gate. Gate is the
workflow-side declaration in druks.workflows.
Chart
ImageGallery
Metrics
Facts
Facts is the label-and-value list. The contract has no type named
KeyValue.
Table
empty_text, and nothing of its own. A wide table scrolls inside its own
container, on a narrow screen as well: a stacked row would lose the header each
cell belongs to.
List
Stack
Columns
Stack and Columns hold every V1 block, including each other. They have no
special cases.
Values
Every value carries avalue discriminator. A value renders the same way in
Facts, Metrics, List, and Table.
TextValue
link is how a table cell, a fact, or a list item reaches another page.
NumberValue
StatusValue
Status.
TimeValue
when must name an offset. The shell shows a relative time, and the exact
time in the title attribute.
Fields
Every field carries afield discriminator. Every field has name, label,
help_text, is_required, and value. name is the key the shell sends.
TextField
TextAreaField
NumberField
SelectField
MultiSelectField
RadioField
CheckboxField
Page and Follows
Errors
The shell keeps a failure inside the app surface. It never breaks the dashboard.Demand-pulled
These are agreed, named, and not built for V1. Druks adds each one when an app needs it:MoneyValuePercentValueDurationValue- date and time input fields
Not in V1
V1 has noTabs block, no accordion, no expandable table row, no modal, no
inline reveal form, and no general client-state API.
Static child pages already give tabs, and the URL holds the current one. An
app that needs a control the contract does not have ships an ESM frontend.