Files
Dokumentace/WEB/DESIGN_DOCUMENT_WEBAPP.md

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