Build a Page
Grow a screen by appending section objects to a list — then watch one contract flex from full CRUD down to read-only without changing the renderer.
Building a page additively
To build a page additively means you never write a new screen; you append one more section object to a list and the screen grows to include it. A section is one typed, self-contained region of the page (a header, an image list, an upload control), and the page is just an ordered array of them. Because the frontend is a single generic renderer that draws each section by looking its type up in a registry, the backend controls the whole layout by choosing what to put in the list and in what order.
Two design principles make this hold together, and both are worth naming because they explain why it stays cheap:
- DRY — Don't Repeat Yourself. There is exactly one render path, not a duplicated screen file per page. Fix the rendering once and every page is fixed at once.
- SRP — Single Responsibility Principle. Each section owns exactly one job. An upload section only uploads; a table section only tables. Nothing does two things, so nothing is entangled with anything else.
The rest of this day walks the additive build from an empty page, then shows three real sections proving the same contract stretches to fit very different jobs.
Start empty, then grow the list
The smallest possible page is a name and an empty list of sections. It renders to a blank projects page — and that emptiness is the point: nothing bespoke exists, and the JSON is about to grow while the screen grows with it, with no screen code written.
{ "page": "projects", "sections": [] }
Append a header and an image list, and a titled list of the project's slides appears:
{
"page": "projects",
"sections": [
{ "type": "header", "title": "Projects" },
{ "type": "image_list", "title": "My projects", "data": "project_slides" }
]
}
Append an upload control, and the page is complete — a header, a list, and a way to add slides:
{
"page": "projects",
"sections": [
{ "type": "header", "title": "Projects" },
{ "type": "image_list", "title": "My projects", "data": "project_slides" },
{ "type": "upload", "title": "Upload slide", "accept": "wsi" }
]
}
At no step did anyone open a screen file. Each new region arrived because a section object joined the array — the frontend recognized the type and mounted the matching region beside the ones already there.
One render path (DRY), one job per section (SRP)
This is where the payoff is concrete. Because every page — projects, a single project, datasets, the canvas — is drawn by the same generic renderer, there is one place to fix a rendering bug and one place to add a capability that every page inherits. That is DRY made physical: no per-page screen to keep in sync.
SRP is the other half. Each section is responsible for exactly one region and one behavior, so an engineer adding a feature only ever touches their new section — a JSON spec on the backend plus one registered component on the frontend. Reviewing that change is reading a JSON diff, not auditing a new screen. The three sections below are each a single responsibility, and each proves the same actions-map contract (from the previous day) flexes to a different amount of interaction.
A section with full CRUD: the AI models table
The AI models section is a sortable, paginated table — and it needs the full set of operations: read the rows, add a model, edit one. So its actions map carries the complete href set (load, a read, a create, an update). The options bag carries the layout hints the renderer uses to draw it as a table.
{
"id": "ai_models_section",
"options": { "layout": "table", "pageSize": 3, "sortable": true },
"actions": {
"load": { "url": "/projects/7/sections/models", "method": "GET" },
"model_read_multiple": { "url": "/projects/7/models", "method": "GET" },
"model_create_one": { "url": "/projects/7/models", "method": "POST" },
"model_update_one": { "url": "/projects/7/models/<int:id>", "method": "PATCH" }
}
}
The rows, the paging, and the status pills are all driven by model_read_multiple — the client owns none of that data. The kebab menu's edit follows model_update_one with the row's id resolved into the URL's placeholder. Add a column or a new status on the backend and the table grows; there is no new client wiring to do.
┌─ SX Models Hub — ViewSectionTable ───────────────┐ │ name status accuracy ⋮ │ │ resnet-50 ready 0.94 ⋮ │ │ vit-tissue training — ⋮ │ │ unet-nuclei ready 0.91 ⋮ │ │ ‹ page 1 / 4 › sortable · paginated │ └───────────────────────────────────────────────────┘
A section as a deck: images and datasets
The images-and-datasets section shows the same gigapixel scans, but as a fanned deck of tissue cards instead of a table. Nothing about the mechanism changes — only the options.layout hint ("deck") and the specific hrefs. It reads a list, creates on upload, and deletes a card, so it carries read, create, and delete hrefs.
{
"id": "images_and_datasets_section",
"options": { "layout": "deck", "cardLabels": true },
"actions": {
"load": { "url": "/datasets/12/sections/deck", "method": "GET" },
"image_read_multiple": { "url": "/datasets/12/images", "method": "GET" },
"image_create_one": { "url": "/datasets/12/images", "method": "POST" },
"image_delete_one": { "url": "/datasets/12/images/<int:id>", "method": "DELETE" }
}
}
The cards come from image_read_multiple; an upload follows image_create_one, then mutate() re-reads so the new card appears; deleting a slide resolves the id placeholder in image_delete_one. The same scans that render as cards here render as the deep-zoom canvas elsewhere — one section contract, two very different surfaces.
A read-only section: per-class statistics
The per-class statistics section is a breakdown of annotation counts — bars and percentages. It never writes anything, so its actions map shrinks to fit: just load and one read href. There are no create, update, or delete intents, because there is nothing to create, update, or delete. The contract does not force them; it flexes down.
{
"id": "classes_statistics_section",
"options": { "layout": "table", "showBars": true },
"actions": {
"load": { "url": "/tissues/5/sections/classes", "method": "GET" },
"class_read_multiple": { "url": "/tissues/5/classes", "method": "GET" }
}
}
Each row — class, count, percent, bar — is one item from class_read_multiple. This is the same shape as the models table and the datasets deck; it simply carries fewer hrefs. That is the whole point of the contract: one id + options + actions shape stretches from full CRUD at one end down to read-only at the other, and the renderer treats them all identically.
Rule of thumb: a section declares only the hrefs it actually needs. A read-only panel is not a special case that required new plumbing — it is the same contract with a smaller actions map.
Key takeaways
- Building a page additively means appending a typed section object to an ordered list; the screen grows, and no screen code is written.
- One generic renderer draws every page (DRY) — a rendering fix applies everywhere at once — and each section owns a single job (SRP).
- The empty → image_list → upload progression shows the screen revealing itself section by section as the JSON grows.
- The AI models table carries a full CRUD
actionsmap (load, read, create, update) and atablelayout hint. - The images-and-datasets deck is the same contract with a
decklayout and read/create/delete hrefs — same scans, different surface. - The per-class statistics section is read-only: its
actionsmap shrinks to justload+ one read href, with no writes to declare. - One
id+options+actionsshape flexes from full CRUD down to read-only, and the renderer treats all sections identically.
Checklist
- [ ] I can grow a page from
{ "sections": [] }to a working screen by appending section objects. - [ ] I can explain DRY (one render path) and SRP (one job per section) in this context.
- [ ] I can read the AI models section JSON and name why it carries a full CRUD
actionsmap. - [ ] I understand how the
options.layouthint switches a section between table and deck without changing the contract. - [ ] I can explain how the read-only statistics section shrinks its
actionsmap to fit. - [ ] I can articulate why adding a section is a JSON diff plus at most one registered component.