271 lines
13 KiB
Markdown
271 lines
13 KiB
Markdown
# 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
|