Add API and web application design documents
This commit is contained in:
@@ -0,0 +1,227 @@
|
|||||||
|
# API Design Guidelines
|
||||||
|
|
||||||
|
**Document Version:** v2
|
||||||
|
|
||||||
|
> **Note on Versioning:**
|
||||||
|
> - This document version is independent — reused across projects
|
||||||
|
> - **Project version** source of truth: the project manifest (`pyproject.toml`, `Cargo.toml`)
|
||||||
|
> - The **URL version** (`/v0`, `/v1`) is a separate axis and moves independently — see section 1
|
||||||
|
> - `CHANGELOG.md` uses the project version, never the URL version
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- **README.md** — Project overview, tool descriptions, build instructions
|
||||||
|
- **AGENTS.md** — Rules for AI assistants
|
||||||
|
- **PROJECT.md** — Project goals and current state
|
||||||
|
- **CHANGELOG.md** — Version history
|
||||||
|
- **DESIGN_DOCUMENT_LANGUAGE.md** — everything language-specific (`DESIGN_DOCUMENT_PYTHON.md`, …)
|
||||||
|
|
||||||
|
This document applies to any project that exposes an HTTP API, in addition to the design document
|
||||||
|
for the language it is written in. Where the two disagree, the language document wins on language
|
||||||
|
matters and this one wins on API surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Two Version Axes
|
||||||
|
|
||||||
|
An API carries two versions that must never be conflated.
|
||||||
|
|
||||||
|
| Axis | Where it lives | What it means | How often it moves |
|
||||||
|
|------|----------------|---------------|--------------------|
|
||||||
|
| **URL version** | the path — `/v0/...` | the contract with the consumer | almost never |
|
||||||
|
| **Product version** | project manifest, job manifest | the release of the software | every release |
|
||||||
|
|
||||||
|
The product version follows semantic versioning and the rules in `AGENTS.md`. It is what the
|
||||||
|
OpenAPI/Swagger `version` field reports, and what `CHANGELOG.md` is organised by.
|
||||||
|
|
||||||
|
The URL version is **not** semantic. It is a single integer identifying a frozen contract. A
|
||||||
|
consumer pinned to `/v0` keeps working across every product release until `/v0` is formally retired.
|
||||||
|
|
||||||
|
### 1.1 When the URL version is bumped
|
||||||
|
|
||||||
|
Bump `/v0` to `/v1` **only** on a breaking change to an existing endpoint:
|
||||||
|
|
||||||
|
- a field is removed or renamed
|
||||||
|
- a field changes type or meaning
|
||||||
|
- a default changes in a way that alters the response
|
||||||
|
- an endpoint is removed or its path changes
|
||||||
|
- a previously optional parameter becomes required
|
||||||
|
|
||||||
|
The following are **not** breaking and never justify a bump:
|
||||||
|
|
||||||
|
- adding a new endpoint
|
||||||
|
- adding a new optional query parameter
|
||||||
|
- adding a new field to a response
|
||||||
|
- performance, caching or refresh-strategy changes that leave the payload identical
|
||||||
|
|
||||||
|
### 1.2 Coexistence and retirement
|
||||||
|
|
||||||
|
When the version is bumped, the old version keeps serving until every consumer has moved:
|
||||||
|
|
||||||
|
- both versions are routed at the same time; the old one is not silently redirected
|
||||||
|
- the old version is marked `deprecated` in the OpenAPI schema
|
||||||
|
- retirement is announced in `CHANGELOG.md` at least one release before removal
|
||||||
|
- removal is a MAJOR bump of the product version
|
||||||
|
|
||||||
|
A new URL version is a cost paid by every consumer. Prefer an additive change, a new endpoint, or
|
||||||
|
an opt-in query parameter over a bump.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Path Naming
|
||||||
|
|
||||||
|
```
|
||||||
|
/v<n>/<CONSUMER>/<resource>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Segment | Rule | Example |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `v<n>` | lowercase `v` + integer, no dots | `/v0`, `/v1` |
|
||||||
|
| `<CONSUMER>` | the consuming system, as its own abbreviation | `WEXT`, `PDH`, `SALESOFFER` |
|
||||||
|
| `<resource>` | lowercase `kebab-case` | `insert-relation`, `attribute-defs` |
|
||||||
|
|
||||||
|
- **Collections are plural**, single lookups append the identifier:
|
||||||
|
`/v0/WEXT/products` and `/v0/WEXT/products/{productnr}`
|
||||||
|
- **Sub-resources nest**, they are not hyphenated into the parent:
|
||||||
|
`/v0/WEXT/assets/general`, not `/v0/WEXT/assets-general`
|
||||||
|
- **Never PascalCase or SCREAMING_CASE a resource.** The consumer segment is the only place an
|
||||||
|
uppercase abbreviation is allowed, because it is a proper name rather than a word.
|
||||||
|
- **Spell resources out.** `number-of-inserts` over `noi` — the URL is read by people who do not
|
||||||
|
know the internal acronym.
|
||||||
|
|
||||||
|
### 2.1 Endpoints outside the versioned space
|
||||||
|
|
||||||
|
Two different kinds of endpoint carry no version prefix, and the same rule does not cover both.
|
||||||
|
|
||||||
|
**Operational endpoints** — `/health`, `/cache/stats`, `/cache/reset` — are called by operators and
|
||||||
|
monitoring, never by a consumer. They change with the product version, no ceremony.
|
||||||
|
|
||||||
|
**Unversioned consumer endpoints** — the token endpoints (`/subscribe`) and any feed that predates
|
||||||
|
the versioning scheme — carry no prefix but are still contracts, and the token endpoints are the
|
||||||
|
widest of them: every delta consumer holds a token one of them issued. A breaking change to any of
|
||||||
|
these follows section 1.1 exactly. Having no version to bump is not permission to break. The
|
||||||
|
endpoint is either extended additively, or it enters the versioned space as a new path while the
|
||||||
|
old one keeps serving and is marked deprecated.
|
||||||
|
|
||||||
|
Unversioned is not unprotected. What decides is who calls the endpoint, not whether the path has a
|
||||||
|
number in it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Parameter Naming
|
||||||
|
|
||||||
|
| Kind | Convention | Example |
|
||||||
|
|------|------------|---------|
|
||||||
|
| Path parameter | `snake_case` | `/products/{productnr}` |
|
||||||
|
| Query parameter | `snake_case` | `?web_only=true`, `?dict_id=42` |
|
||||||
|
| Header | `X-` prefix, `Kebab-Case` | `X-Token` |
|
||||||
|
|
||||||
|
Reserved query parameter names, to be used for nothing else:
|
||||||
|
|
||||||
|
| Name | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `limit` | rows per page |
|
||||||
|
| `offset` | row offset |
|
||||||
|
| `since` | ISO-8601 low-water mark for a delta pull |
|
||||||
|
| `format` | response format override, where an endpoint serves more than one |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Code Naming
|
||||||
|
|
||||||
|
The path is the contract; the code that serves it mirrors the path mechanically, so the two cannot
|
||||||
|
drift apart.
|
||||||
|
|
||||||
|
| Element | Convention | Example |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| Router variable | `<consumer>_router` | `wext_router` |
|
||||||
|
| Handler function | `<consumer>_<resource>`, `snake_case` | `wext_attribute_defs` |
|
||||||
|
| Single-item handler | `<consumer>_<resource>`, singular | `wext_product` |
|
||||||
|
| OpenAPI tag | `<Consumer> feed` | `"WEXT feed"` |
|
||||||
|
|
||||||
|
One router per consumer, declared with its own prefix and tag and included on the application in a
|
||||||
|
single place. A handler never hardcodes its own prefix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Responses
|
||||||
|
|
||||||
|
### 5.1 Envelope
|
||||||
|
|
||||||
|
A list endpoint returns its rows inside an envelope carrying the paging state, never a bare array:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"table": "wext-products", "since": null, "count": 3,
|
||||||
|
"limit": 5000, "offset": 0, "has_more": false, "rows": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
For XML, the same metadata goes on the root element as attributes (`limit`, `offset`, `returned`,
|
||||||
|
`hasMore`).
|
||||||
|
|
||||||
|
Where the response body is a message defined by the **consumer** rather than by us, the paging
|
||||||
|
metadata stays outside that message — the body must remain exactly what the consumer schema expects.
|
||||||
|
|
||||||
|
### 5.2 Paging
|
||||||
|
|
||||||
|
**Every list endpoint is paged.** An endpoint with no cap is a denial of service waiting for the
|
||||||
|
first client that omits a filter.
|
||||||
|
|
||||||
|
- `limit` has a documented default and a hard maximum, both configurable by environment variable
|
||||||
|
- the maximum is chosen by measurement — response size, and the time a request holds any shared
|
||||||
|
lock — not by guess
|
||||||
|
- `has_more` is reported so the client knows to continue
|
||||||
|
- deep offsets over a very large table are not the intended access path; document the alternative
|
||||||
|
(walking `since` windows) instead
|
||||||
|
|
||||||
|
### 5.3 Delta feeds
|
||||||
|
|
||||||
|
An endpoint whose source has a reliable change column may support incremental pulls:
|
||||||
|
|
||||||
|
- `?since=<ISO-8601>` returns only rows changed strictly after that instant
|
||||||
|
- a subscription token in `X-Token` supplies, and then advances, a server-tracked watermark
|
||||||
|
- an explicit `?since` always wins over the token value
|
||||||
|
- a malformed `?since` is rejected with **422** before it reaches the query
|
||||||
|
|
||||||
|
**A snapshot message is never a delta.** If a response states a complete set as of a timestamp,
|
||||||
|
answering with only the changed rows tells the consumer that the set shrank to those rows. Such an
|
||||||
|
endpoint is a full snapshot and says so in its description.
|
||||||
|
|
||||||
|
### 5.4 Status codes
|
||||||
|
|
||||||
|
| Code | When |
|
||||||
|
|------|------|
|
||||||
|
| `200` | success, including a legitimately empty collection |
|
||||||
|
| `302` | redirect to an external asset |
|
||||||
|
| `400` | the request is missing something it needs |
|
||||||
|
| `403` | unknown or revoked token |
|
||||||
|
| `404` | a named single entity does not exist |
|
||||||
|
| `422` | a parameter is present but malformed |
|
||||||
|
| `500` | internal failure |
|
||||||
|
|
||||||
|
An empty collection is `200` with an empty list, not `404`. `404` means *this identifier does not
|
||||||
|
exist*, which only a single-entity lookup can determine.
|
||||||
|
|
||||||
|
Error bodies carry a short, generic `detail`. **Internal detail — driver strings, connection
|
||||||
|
parameters, SQL, stack traces — goes to the log only, never to the client.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Documentation
|
||||||
|
|
||||||
|
- Every endpoint declares a `summary` and a `description`. The description states where the data
|
||||||
|
comes from, whether the endpoint is a delta or a snapshot, and any non-obvious default.
|
||||||
|
- The OpenAPI `version` field is read from the project manifest, never hand-maintained.
|
||||||
|
- The interactive schema (`/docs`, `/openapi.json`) is the reference for consumers. Prose that only
|
||||||
|
restates it belongs nowhere.
|
||||||
|
- A new consumer feed is announced in `CHANGELOG.md` under the product version that ships it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
In addition to the testing rules in the language design document:
|
||||||
|
|
||||||
|
- every endpoint has at least one test asserting the **shape** of its response
|
||||||
|
- the query an endpoint builds is asserted, so a changed filter cannot pass unnoticed
|
||||||
|
- tests stub the data-access layer; they never require a live database
|
||||||
|
- the empty-result path is tested explicitly — it is the one most often wrong
|
||||||
@@ -7,6 +7,43 @@ ships documents, so it uses the same `v#` scheme as the documents themselves.
|
|||||||
Individual guideline documents carry their own `Document Version` header, and the skills carry
|
Individual guideline documents carry their own `Document Version` header, and the skills carry
|
||||||
`metadata.version`; both are independent of the revisions below.
|
`metadata.version`; both are independent of the revisions below.
|
||||||
|
|
||||||
|
## v7 — 2026-09-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`API/DESIGN_DOCUMENT_API.md` v2** — guidelines for any project that exposes an HTTP API,
|
||||||
|
carried in addition to the language design document. It settles what was previously left to
|
||||||
|
precedent: the URL version (`/v0`) and the product version are separate axes, the first naming a
|
||||||
|
frozen consumer contract that moves only on a breaking change, the second following semantic
|
||||||
|
versioning per release. Also fixes path naming
|
||||||
|
(`/v<n>/<CONSUMER>/<resource-in-kebab-case>`, resources spelled out and never PascalCase),
|
||||||
|
parameter and handler naming, the response envelope and its reserved paging keys, when an
|
||||||
|
endpoint may be a delta and when it must stay a full snapshot, status-code meanings (an empty
|
||||||
|
collection is `200`, not `404`), and the endpoint-level testing floor.
|
||||||
|
- `API/DESIGN_DOCUMENT_API.md` registered in `check_versions.py` and in the project-type table, so
|
||||||
|
`--sync` propagates it like every other guideline document.
|
||||||
|
- Section 2.1 was rewritten before release (v1 → v2). The v1 draft filed `/subscribe` under
|
||||||
|
endpoints that are "not part of any consumer contract" and declared the whole group free to
|
||||||
|
change with the product version. It is the opposite: the token endpoints are the widest
|
||||||
|
contract in the API, every delta consumer holds a token they issued, and the unversioned
|
||||||
|
legacy feeds are contracts too. The section now splits operational endpoints from unversioned
|
||||||
|
consumer endpoints and subjects the second group to section 1.1 — having no version to bump
|
||||||
|
is not permission to break.
|
||||||
|
- **`WEB/DESIGN_DOCUMENT_WEBAPP.md` v1** — guidelines for a server-rendered web interface driven
|
||||||
|
over a websocket (NiceGUI on FastAPI), carried in addition to `DESIGN_DOCUMENT_PYTHON.md`. Covers
|
||||||
|
layers, one connection and one lock per service, page and dialog structure, where the language
|
||||||
|
belongs, tables as the main idiom (including the slot trap), hand-written canvases, theming,
|
||||||
|
performance, errors and notifications, authentication, testing, and a checklist for a new screen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`AGENTS.md` v8 → v9** — the table mapping project types to design documents is gone. Every
|
||||||
|
`DESIGN_DOCUMENT*.md` in the project root now applies, with the suffix saying what it covers (a
|
||||||
|
language, application vs. library, or a part of the system such as `_WEBAPP`), so the list can no
|
||||||
|
longer go stale. Where two design documents disagree, the more specific one wins for what it covers.
|
||||||
|
- The `MASTERS` comment in `check_versions.py` no longer claims a design document is always named
|
||||||
|
after a language — it is named after what it scopes, which may be a cross-cutting concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v6 — 2026-08-18
|
## v6 — 2026-08-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+11
-14
@@ -1,24 +1,21 @@
|
|||||||
# AI Agents - Project Rules
|
# AI Agents - Project Rules
|
||||||
|
|
||||||
**Document Version:** v8 (independent, incremented on structural changes)
|
**Document Version:** v9 (independent, incremented on structural changes)
|
||||||
|
|
||||||
Language-agnostic rules for AI assistants (Claude Code, Cursor, Copilot, etc.).
|
Language-agnostic rules for AI assistants (Claude Code, Cursor, Copilot, etc.).
|
||||||
|
|
||||||
Everything language-specific — package manager, formatter, linter, test framework,
|
Everything specific — package manager, formatter, linter, test framework, logging library,
|
||||||
logging library, line length, project layout — lives in the matching design document:
|
line length, project layout, interface conventions — lives in the design documents.
|
||||||
|
|
||||||
| Project type | Design document |
|
**Find them, do not look them up in a list:** every file matching `DESIGN_DOCUMENT*.md` in the
|
||||||
|--------------------|------------------------------------|
|
project root applies to this project. The suffix says what it covers — the language
|
||||||
| Python application | `DESIGN_DOCUMENT_PYTHON.md` |
|
(`DESIGN_DOCUMENT_PYTHON.md`), whether it is an application or a library
|
||||||
| Python library | `DESIGN_DOCUMENT_PYTHON_MODULE.md` |
|
(`..._PYTHON_MODULE.md`), or a part of the system that has conventions of its own
|
||||||
| Rust application | `DESIGN_DOCUMENT_RUST.md` |
|
(`..._WEBAPP.md`). A project carries as many as it needs and nothing collides, so no list here
|
||||||
| Rust library | `DESIGN_DOCUMENT_RUST_LIB.md` |
|
needs maintaining and none can go stale.
|
||||||
| Godot | `DESIGN_DOCUMENT_GODOT.md` |
|
|
||||||
|
|
||||||
The language is part of the file name, so a project that mixes languages carries one design document
|
Where this file and a design document disagree, **the design document wins**. Where two design
|
||||||
per language and nothing collides.
|
documents disagree, the more specific one wins for what it covers.
|
||||||
|
|
||||||
Where this file and a design document disagree, **the design document wins**.
|
|
||||||
|
|
||||||
## First-time setup
|
## First-time setup
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ Dokumentace/
|
|||||||
│ ├── DESIGN_DOCUMENT_GODOT.md # Guidelines for Godot projects
|
│ ├── DESIGN_DOCUMENT_GODOT.md # Guidelines for Godot projects
|
||||||
│ └── .gitignore # Godot-specific ignore rules
|
│ └── .gitignore # Godot-specific ignore rules
|
||||||
│
|
│
|
||||||
|
├── API/ # API design guidelines
|
||||||
|
│ └── DESIGN_DOCUMENT_API.md # Guidelines for projects exposing an HTTP API
|
||||||
|
│
|
||||||
├── Project template/ # Reusable files for new projects
|
├── Project template/ # Reusable files for new projects
|
||||||
│ ├── CHANGELOG.md # Changelog template
|
│ ├── CHANGELOG.md # Changelog template
|
||||||
│ ├── PROJECT.md # Project documentation template
|
│ ├── PROJECT.md # Project documentation template
|
||||||
@@ -67,6 +70,7 @@ Where the two disagree, **the design document wins**.
|
|||||||
| Rust (application) | `Rust/DESIGN_DOCUMENT_RUST.md` |
|
| Rust (application) | `Rust/DESIGN_DOCUMENT_RUST.md` |
|
||||||
| Rust (library) | `Rust/DESIGN_DOCUMENT_RUST_LIB.md` |
|
| Rust (library) | `Rust/DESIGN_DOCUMENT_RUST_LIB.md` |
|
||||||
| Godot | `Godot/DESIGN_DOCUMENT_GODOT.md` |
|
| Godot | `Godot/DESIGN_DOCUMENT_GODOT.md` |
|
||||||
|
| Any project exposing an HTTP API | `API/DESIGN_DOCUMENT_API.md` (in addition to the language document) |
|
||||||
|
|
||||||
### Starting a new project
|
### Starting a new project
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+4
-2
@@ -34,8 +34,9 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Guideline documents owned by this repository, mapped to their master copy. The language is part of
|
# Guideline documents owned by this repository, mapped to their master copy. Design documents are named
|
||||||
# every design document name, so a project may carry several of them without any collision.
|
# after what they scope - a language, or a cross-cutting concern such as the API surface - so a project
|
||||||
|
# may carry several of them without any collision.
|
||||||
MASTERS: dict[str, str] = {
|
MASTERS: dict[str, str] = {
|
||||||
"CLAUDE.md": "Claude/CLAUDE.md",
|
"CLAUDE.md": "Claude/CLAUDE.md",
|
||||||
"AGENTS.md": "Claude/AGENTS.md",
|
"AGENTS.md": "Claude/AGENTS.md",
|
||||||
@@ -44,6 +45,7 @@ MASTERS: dict[str, str] = {
|
|||||||
"DESIGN_DOCUMENT_RUST.md": "Rust/DESIGN_DOCUMENT_RUST.md",
|
"DESIGN_DOCUMENT_RUST.md": "Rust/DESIGN_DOCUMENT_RUST.md",
|
||||||
"DESIGN_DOCUMENT_RUST_LIB.md": "Rust/DESIGN_DOCUMENT_RUST_LIB.md",
|
"DESIGN_DOCUMENT_RUST_LIB.md": "Rust/DESIGN_DOCUMENT_RUST_LIB.md",
|
||||||
"DESIGN_DOCUMENT_GODOT.md": "Godot/DESIGN_DOCUMENT_GODOT.md",
|
"DESIGN_DOCUMENT_GODOT.md": "Godot/DESIGN_DOCUMENT_GODOT.md",
|
||||||
|
"DESIGN_DOCUMENT_API.md": "API/DESIGN_DOCUMENT_API.md",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Names used before the language became part of the file name. A project still carrying one of these
|
# Names used before the language became part of the file name. A project still carrying one of these
|
||||||
|
|||||||
Reference in New Issue
Block a user