Add API and web application design documents
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
# Web Application Guidelines
|
||||
|
||||
**Document Version:** v1
|
||||
|
||||
> **Note on Versioning:**
|
||||
> - This document version is independent — reused across projects
|
||||
> - It describes a **server-rendered web interface driven over a websocket** (NiceGUI on FastAPI); the language rules stay in `DESIGN_DOCUMENT_PYTHON.md`
|
||||
> - Where this document and the language document disagree about the same thing, the language document wins for language matters, this one for interface matters
|
||||
|
||||
## Related Documents
|
||||
|
||||
- **README.md** — Project overview, tool descriptions, build instructions
|
||||
- **AGENTS.md** — Rules for AI assistants
|
||||
- **DESIGN_DOCUMENT_PYTHON.md** — Language rules: tooling, typing, logging, layout
|
||||
- **PROJECT.md** — Project goals and current state
|
||||
|
||||
---
|
||||
|
||||
## 1. What This Stack Is
|
||||
|
||||
One Python process serves the pages, holds the session state and talks to the database. The browser
|
||||
renders components and sends events back over a websocket; there is no separate front-end build, no
|
||||
REST layer between the screen and the data, and no client-side router.
|
||||
|
||||
That buys a great deal — a screen is a function, a test can call it, a value is one attribute access
|
||||
away — and it costs three things that shape every rule below:
|
||||
|
||||
| Property | Consequence |
|
||||
|---|---|
|
||||
| The server holds the UI state | A slow call blocks the screen, and often every screen |
|
||||
| Events cross a websocket | Anything the browser does must reach the server to have happened |
|
||||
| Components are a third-party library | Its escape hatches (slots, CSS layers) follow its rules, not yours |
|
||||
|
||||
---
|
||||
|
||||
## 2. Layers
|
||||
|
||||
```
|
||||
core/ decisions: models, rules, calculations — no UI, no database
|
||||
repository/ storage: one class per table or aggregate — no UI
|
||||
services/ one wired-up object: connection + repositories + shared services
|
||||
web/ screens: build components, read services, write back
|
||||
api/ HTTP routes for machines
|
||||
```
|
||||
|
||||
Dependencies point one way: `web → services → repository → core`. A core module never imports the
|
||||
web layer, a repository never renders, a screen never opens a connection.
|
||||
|
||||
**Every screen has a pure counterpart.** What a screen *decides* — which rows, in what order, which
|
||||
cells may be edited, what a value reads as — belongs in a core module that takes data and returns
|
||||
plain structures. The screen is then the part that cannot be unit tested but also barely has logic
|
||||
in it. This is the single most useful rule in this document: it is what makes a complicated screen
|
||||
testable without a browser.
|
||||
|
||||
---
|
||||
|
||||
## 3. Services: One Connection, One Lock
|
||||
|
||||
The whole point of a server is that the database connection exists once, not per user:
|
||||
|
||||
- One process-wide `Services` object, built lazily on first use, holding the connection and every
|
||||
repository built on it
|
||||
- Database drivers are usually **not** safe for concurrent use, so every call goes through one lock:
|
||||
`svc.run(fn, *args)`
|
||||
- A screen never touches the driver, only `svc.run(...)`
|
||||
|
||||
The lock has a consequence worth writing on the wall: **a slow query freezes the application for
|
||||
everybody**, not just for the person who triggered it. See §9.
|
||||
|
||||
Replace the lock with a connection pool before the user count makes this untrue — the call sites do
|
||||
not change.
|
||||
|
||||
---
|
||||
|
||||
## 4. Page and Screen Structure
|
||||
|
||||
- **Entry point** starts the server, installs logging before anything that can fail, and prints every
|
||||
address it can be reached at
|
||||
- **One page per URL**: a sign-in page and a main page are usually enough; everything else is a
|
||||
dialog opened from the main page
|
||||
- **A screen is a function**: `open_<thing>(svc, ...)` builds its components and returns. No global
|
||||
state, no singletons per screen, nothing left registered when it closes
|
||||
- **State lives in one dict per page**, not in module globals: which object is selected, which tab,
|
||||
which ticks. A dict, because it is shared with the nested functions that redraw parts of the page
|
||||
- **Redraw in units, not wholesale.** Mark the parts that change as refreshable and refresh those;
|
||||
rebuilding the page loses scroll position, focus and any transient state the browser holds
|
||||
|
||||
### Dialogs
|
||||
|
||||
Dialogs are for decisions and rare edits — a confirmation, a picker, managing relations. They are not
|
||||
where everyday data is edited: a value a person changes ten times a minute belongs in the table they
|
||||
are already looking at (§6).
|
||||
|
||||
---
|
||||
|
||||
## 5. Where the Language Belongs
|
||||
|
||||
Pick one language for the interface and one for the code, and keep them apart:
|
||||
|
||||
- Code, comments, identifiers, log messages, commit messages: **English**
|
||||
- Labels, notifications, validation messages the user reads: **the user's language**
|
||||
|
||||
Mixing them inside one screen is what happens by accident when a feature is written in a hurry;
|
||||
mixing them across screens is what the user notices.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tables Are the Main Idiom
|
||||
|
||||
For a data application, the table is the interface. Two rules:
|
||||
|
||||
**Build rows in core, render in web.** A core function turns records into rows and cells — text,
|
||||
whether it can be edited, where it came from, whether it is held by hand. The screen turns that into
|
||||
component properties. Sorting, grouping and "show only what differs" then have unit tests.
|
||||
|
||||
**Edit in the cell, not in a dialog.** An input in the cell, written when the field is left or Enter
|
||||
is pressed — never per keystroke, which turns one edit into a dozen writes.
|
||||
|
||||
### The slot trap
|
||||
|
||||
Component libraries let you replace a cell with your own markup (a "slot"). Framework event bindings
|
||||
written inside such a slot **may never fire** — the slot is compiled in a scope that is not the one
|
||||
the binding expects. Verified in a browser: the handler was never reached, with no error anywhere.
|
||||
|
||||
The reliable pattern is to bypass the framework's event system for that one case:
|
||||
|
||||
- A plain `<input>` with a **native** attribute handler (`onblur`, `onkeydown`) — compiled by the
|
||||
browser itself
|
||||
- The handler calls the framework's own "send an event to the server" function with a small payload:
|
||||
which panel, which record, what value
|
||||
|
||||
### Addressing the panel
|
||||
|
||||
An event that carries an id, and a server-side registry of live panels, is the general shape for
|
||||
anything the browser drives:
|
||||
|
||||
```
|
||||
mount(panel_id, handler) # once per page build, outside any refreshable
|
||||
emit → {panel: <id>, …} # from the browser
|
||||
dispatch → registry[id] # server routes it, ignores unknown ids
|
||||
release on disconnect # a browser that goes away stops answering
|
||||
```
|
||||
|
||||
Two rules that are easy to get wrong:
|
||||
|
||||
- **Subscribe once per page, never inside the refreshable** that redraws the table. A subscription per
|
||||
redraw saves a typed value once per redraw that has ever happened
|
||||
- **Put the client id in the panel id.** The registry is process-wide; without it, a second person's
|
||||
typing is handed to the first person's screen, written as them
|
||||
|
||||
---
|
||||
|
||||
## 7. Custom Canvases and Other Hand-Written Markup
|
||||
|
||||
When a component library has nothing for what you need — a node editor, a diagram — hand-write the
|
||||
markup and keep the browser side thin:
|
||||
|
||||
- **The browser reports, the server decides.** A gesture (a wire dropped, a box let go) is sent as an
|
||||
event; the state changes on the server, where a test can reach it. Only what is impossible to do
|
||||
server-side — the box following the pointer — stays in the browser
|
||||
- **One id per canvas**, as in §6, so two open canvases cannot answer for each other
|
||||
- **Attach with a retry**: markup and script arrive in separate messages, so the script's entry point
|
||||
polls a few times for its own element instead of assuming it is there
|
||||
- **Sanitisers strip `<style>` and `<script>`.** If the framework sanitises raw HTML by default, an
|
||||
opt-out is required for hand-written markup — and then **every value interpolated into that markup
|
||||
must be escaped where it is built**. Opting out without escaping is how a catalogue name becomes an
|
||||
injection
|
||||
- **Pan and zoom belong to the browser** and must survive a redraw; recomputing a fit on every edit
|
||||
throws away what the person set up
|
||||
|
||||
---
|
||||
|
||||
## 8. Theming
|
||||
|
||||
- **One module holds the palette** and applies it once per page: the framework's colour variables plus
|
||||
a stylesheet. Screens use the names, never the hex codes
|
||||
- **One selection style** for every pick-one list in the application. Five copies of "the selected row
|
||||
is light blue" drift apart within a month
|
||||
- **A brand colour is a fill, not small text.** A mid-tone brand colour on white fails contrast at
|
||||
11 px. Filled buttons get it with white on top; flat and outlined buttons keep near-black labels
|
||||
- **Component libraries fight back with `!important`.** When an override does not take, the cause is
|
||||
usually CSS layers: for important declarations, a layered rule beats an unlayered one, and the
|
||||
earlier layer wins. Put overrides in a layer the framework declares *before* its own important
|
||||
layer, rather than escalating specificity
|
||||
- **Status colours are not brand colours.** Error, warning and success carry meaning and stay
|
||||
recognisable even when the palette changes
|
||||
- **Static assets** (logo, images, icon) are served from one directory at a fixed URL and bundled by
|
||||
the packaging step. Check that the packaging spec includes them; a page that works from source and
|
||||
is bare in the executable is the default failure
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance
|
||||
|
||||
The lock in §3 turns any slow call into a global freeze, so the interface layer owns performance:
|
||||
|
||||
- **Never walk a tree node by node.** One recursive query instead of one query per node. A real case
|
||||
from this project: a depth lookup cost 8 491 round trips and 22.5 s, ran on every click, and made
|
||||
the whole application unusable while it did
|
||||
- **One round trip per screen refresh** is the target; a handful is acceptable; per-row queries are a
|
||||
defect
|
||||
- **Measure before and after with a query counter**, not with impressions. Wrap the database's execute
|
||||
method, count calls, print the time. It takes ten minutes and it is the only way to know
|
||||
- **Page long lists** and cap what a screen renders (rows, columns, hits) with an explicit, visible
|
||||
limit — "showing the first 200 of 7 890" — rather than a silent truncation
|
||||
- **Cache what is recomputed per redraw**, such as compiled patterns, at module level. Anything
|
||||
derived from code, not from data, can be computed once per process
|
||||
|
||||
---
|
||||
|
||||
## 10. Errors, Refusals and Notifications
|
||||
|
||||
- **Refuse rather than guess.** A value that cannot be parsed, a move that would create a cycle, a
|
||||
rename that would orphan something: say what is wrong, change nothing
|
||||
- **Every write says what it did**, as a short notification: what was saved, how many objects it
|
||||
reached, what it left alone
|
||||
- **One failure must not cost the whole screen.** A record that will not load is dropped with a
|
||||
logged warning; the other sixty still render
|
||||
- **Log for the operator, notify for the user.** They are different messages: the log has ids and
|
||||
counts, the notification has the sentence
|
||||
|
||||
---
|
||||
|
||||
## 11. Authentication and the Machine Interface
|
||||
|
||||
- Every page checks the session and redirects to sign-in; every role check happens **on the server**,
|
||||
never by hiding a menu item
|
||||
- HTTP routes for machines live beside the pages in the same process and authenticate per request
|
||||
(HTTP Basic is enough behind a firewall); they never rely on the browser session
|
||||
- **A health endpoint** is worth more than it costs: unauthenticated it answers "is the server up",
|
||||
authenticated it answers "is the data in the state it should be" — schema version, drift, coverage.
|
||||
It is where a broken deployment announces itself
|
||||
- Turn on the framework's API documentation and make the addresses visible in the interface, showing
|
||||
**the address the browser used**, not `localhost`, which on anybody else's machine is their own
|
||||
computer
|
||||
- Secrets come from the environment; nothing secret is ever logged
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing
|
||||
|
||||
Three levels, each for what only it can see:
|
||||
|
||||
| Level | Covers | Cost |
|
||||
|---|---|---|
|
||||
| Core functions | Rows, ordering, what is editable, every rule | Milliseconds |
|
||||
| Simulated client | The screen assembles, a click reaches its handler, a dialog opens | Fast, no browser |
|
||||
| Real browser | Anything that only exists in a browser: gestures, styles, native events | Slow, opt-in |
|
||||
|
||||
- **The simulated client cannot see into component properties.** A table's contents live in its props,
|
||||
not in the page text, so assert on the element's `columns` and `rows`, not on visible text
|
||||
- **A real-browser test is worth writing for the parts that burned you.** Drive it over the browser's
|
||||
debugging protocol: sign in, click, type real keystrokes, read the DOM back. Two bugs in this
|
||||
project — an unstyled canvas and a dead event binding — were invisible to every other level
|
||||
- **Skip, do not fail, when no browser is present** (`CDE_BROWSER=none`), so the suite still runs on a
|
||||
machine that has none
|
||||
- **Never verify against production data without restoring it.** If a check must write, write one
|
||||
field, read it back through the database, restore it, and say so in the report
|
||||
|
||||
---
|
||||
|
||||
## 13. Checklist for a New Screen
|
||||
|
||||
1. What does it decide? Put that in a core module with tests
|
||||
2. What does it show? Build it from the core structures; one refreshable per part that changes
|
||||
3. What does it write? One function per write, each reporting what it did
|
||||
4. Does the browser drive anything? Give it an id, subscribe once, release on disconnect
|
||||
5. How many queries does one refresh cost? Count them
|
||||
6. Does it use the palette, or does it invent a colour?
|
||||
7. Which of the three test levels does it need? At least the first
|
||||
Reference in New Issue
Block a user