+
diff --git a/src/content/docs/docs/adding-services.md b/src/content/docs/docs/adding-services.md
deleted file mode 100644
index d090f26..0000000
--- a/src/content/docs/docs/adding-services.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Adding services
-description: Add app tiles, group them into folders, put them in the dock, and reorder the grid.
----
-
-**In the admin:** Dashboard. See the [Settings reference](/docs/settings-reference/).
-
-Apps are added under **Dashboard** in the admin UI. Nothing here edits a file by hand.
-
-## Add an app
-
-Press Add, then fill in the app.
-
-
-
- Name, URL, icon and tile colour. Icon takes a name, a URL, or an upload.
-
-
-## Icons
-
-Type a name and Stackyard searches four community catalogues together: [dashboard-icons](https://github.com/homarr-labs/dashboard-icons), [selfh.st](https://github.com/selfhst/icons), [simple-icons](https://github.com/simple-icons/simple-icons) and [lobehub](https://github.com/lobehub/lobe-icons). Typing `sonarr` finds the Sonarr icon. A service is matched by its name, by other spellings of it, and by the aliases a catalogue lists. Each suggestion names the catalogue it came from.
-
-Where a catalogue holds an icon as a light and a dark file, the picker offers a Variant choice. The choice is fixed, not theme-aware.
-
-You can also paste a full URL or upload a file. Uploads are stored in `./icons` and served from your own server.
-
-Icons load through the server, which fetches each one once, sanitizes SVGs, and caches it for 24 hours. The CDN does not learn which services your dashboard shows.
-
-If no icon resolves, the tile shows the first letter of the name.
-
-Color takes a swatch or a hex value. `Dark` and `Light` are fixed tile colours, not theme-aware: the dashboard is always dark, so a tile keeps the colour you set.
-
-## The dock
-
-The dock is the row of icons pinned at the bottom of the dashboard. Turn on Show in Dock on an app to put it there.
-
-It holds four apps. Once full, the toggle refuses more until you remove one. Widgets cannot go in the dock.
-
-The dock is hidden while no app is in it, and the grid uses the space instead.
-
-## Folders
-
-A folder groups apps behind one tile, whose artwork is a grid of the icons inside it.
-
-Create one from Add, name it, then use Add Apps to choose the contents. Folders cannot be nested, and a widget cannot go in a folder.
-
-## Reordering
-
-The Dashboard list holds everything on the grid in one flat list. List order is grid order. Drag a row's handle, or use the arrows on the row, and the new order is saved straight away. There is nothing to confirm.
-
-
-
- Each row carries a drag handle, the move arrows, and Edit.
-
-
-Rows are tagged with what is configured on them.
-
-
-
- Filtered to apps. The move arrows are hidden while a filter is on, so reordering is disabled until you clear it.
-
-
-## Next
-
-Add context to a tile with [Badges](/docs/badges/), or add a [Widget](/docs/widgets/).
diff --git a/src/content/docs/docs/advanced-configuration.md b/src/content/docs/docs/advanced-configuration.md
deleted file mode 100644
index 0ada4f7..0000000
--- a/src/content/docs/docs/advanced-configuration.md
+++ /dev/null
@@ -1,125 +0,0 @@
----
-title: Advanced configuration
-description: Deployment-level settings. Environment variables, reverse proxies, host access, and Docker health checks.
----
-
-Everyday setup happens in the web UI. Nothing on this page is required for a normal install. These are deployment concerns: how the container is reached, what it may reach, and how it identifies clients.
-
-## Environment variables
-
-Every variable is optional. The defaults are what the container ships with.
-
-| Variable | Default | What it does |
-| --- | --- | --- |
-| `ALLOW_PRIVATE_IPS` | unset | Turns the SSRF guard off, so badges and widgets may reach private, LAN and loopback addresses. Most homelab installs need it. |
-| `SOCKET_PROXY_URL` | unset | A Docker socket proxy, for container health monitoring. |
-| `TRUST_PROXY` | unset | Believe `X-Forwarded-Proto`, so a request through a TLS-terminating proxy gets a `Secure` cookie. |
-| `TRUSTED_PROXY` | unset | Where a front proxy sits, so nginx can resolve the real client for rate limiting. |
-| `SESSION_MAX_AGE_DAYS` | `0.5` | Idle session lifetime in days before re-login, so 12 hours by default. Accepts a fraction. A session in use is extended. |
-| `PASSWORD_HASH_MEMORY` | `16mib` | Memory per password hash. One of `8mib`, `16mib`, `32mib`, `64mib`, `128mib`. |
-| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. `warn` and `error` behave the same. The General settings page also sets this, and that wins once the config has loaded. |
-| `DEMO_MODE` | unset | Run as a read-only public showcase. |
-| `CONFIG_PATH` | `/data/apps.json` | Where the config file lives. |
-| `ICONS_PATH` | `/icons` | Where uploaded icons are written. |
-| `WIDGETS_PATH` | `/usr/share/nginx/html/widgets` | Where widget folders are read from. A wrong path loads an empty registry and every widget reports as unknown. |
-| `NODE_OPTIONS` | `--max-old-space-size=192` | The heap ceiling for the API. Node sizes its heap from the host's memory, not from the container's limit, so without this the API is killed instead of collecting. Keep it near half of the container's memory limit, and raise both together. |
-| `PORT` | `80` | Only for a hosting platform that reads `PORT` to decide where to route. It must be `80`, the port the container serves on. It does not move anything inside the container. To reach the dashboard on another port, change the published port instead. |
-
-If `CONFIG_PATH` or `ICONS_PATH` points at a folder that does not exist, the container logs a warning at startup. Writes to that path fail until the folder exists.
-
-The repo's [`docker-compose.yml`](https://github.com/SandObserver/stackyard/blob/main/docker-compose.yml) carries each of these as a commented line.
-
-## Reaching services on private IPs
-
-The SSRF guard blocks requests to private, loopback and link-local addresses. Most homelab services live on private IPs, so most installs need the guard off:
-
-```yaml
-environment:
- - ALLOW_PRIVATE_IPS=true
-```
-
-Read [Security](/docs/security/) before setting it. Two things work without it: dotless hostnames such as Docker container names, and the host IP you set in General settings.
-
-## Reaching services on the Docker host
-
-On Linux a container cannot reach the host's LAN IP by default. Add:
-
-```yaml
-extra_hosts:
- - "host.docker.internal:host-gateway"
-```
-
-`host-gateway` is a Docker built-in that resolves to the host machine's IP.
-
-## Behind a reverse proxy
-
-Two variables apply, and they do different things.
-
-`TRUST_PROXY=true` makes Stackyard believe `X-Forwarded-Proto: https`, so the session cookie gets its `Secure` flag. Set it only when a proxy you control is actually in front of the app.
-
-:::caution
-If `TRUST_PROXY=true` is set while Stackyard is also reachable directly, a client can claim `X-Forwarded-Proto: https` and be issued a `Secure` cookie over plain HTTP.
-:::
-
-`TRUSTED_PROXY` tells nginx where the front proxy sits, so it can resolve the real client address for rate limiting:
-
-```
-TRUSTED_PROXY=172.18.0.0/16
-TRUSTED_PROXY="172.18.0.0/16 10.0.0.5"
-```
-
-Without it, every request through the proxy counts as the same client and rate limiting becomes one shared bucket.
-
-Rate-limit counters are held in memory and are not shared across replicas. Run a single instance behind any proxy.
-
-## Docker container health checks
-
-The health-check badge can read a container's state from the Docker daemon. This needs a Docker socket proxy, a separate container that exposes a narrowed read-only view of the socket.
-
-[tecnativa/docker-socket-proxy](https://github.com/tecnativa/docker-socket-proxy) is the usual choice:
-
-```yaml
-services:
- socket-proxy:
- image: tecnativa/docker-socket-proxy
- environment:
- - CONTAINERS=1
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- networks:
- - socket_proxy
- restart: unless-stopped
-
- stackyard:
- environment:
- - SOCKET_PROXY_URL=http://socket-proxy:2375
- networks:
- - socket_proxy
-
-networks:
- socket_proxy:
-```
-
-Then turn on Docker Container Health Checks in General.
-
-:::caution
-Never mount the Docker socket into Stackyard itself. A name such as `http://socket-proxy:2375` resolves only when both containers share a network. An IP address works only when the proxy publishes its port beyond the host's own loopback.
-:::
-
-Ping-based health checks need none of this. See [Badges](/docs/badges/).
-
-## Optional host mounts
-
-Two mounts extend what the System Summary widget can read when its source is This Machine:
-
-```yaml
-volumes:
- # CPU temperature sensors
- - /sys/class/thermal:/sys/class/thermal:ro
- # Disk usage for a mount path
- - /mnt/your-drive:/mnt/your-drive:ro
-```
-
-## TLS
-
-Stackyard does not terminate TLS and serves plain HTTP only. Put it behind a reverse proxy that terminates TLS. See [Security](/docs/security/).
diff --git a/src/content/docs/docs/apps-and-folders.md b/src/content/docs/docs/apps-and-folders.md
new file mode 100644
index 0000000..be154d6
--- /dev/null
+++ b/src/content/docs/docs/apps-and-folders.md
@@ -0,0 +1,22 @@
+---
+title: Add apps and folders to your dashboard
+description: Add app tiles with icons by name, group them in folders, and pin them to the dock in Stackyard.
+---
+
+**In the admin:** Dashboard.
+
+## Apps
+
+Press Add, choose App, and enter a name, URL, icon and colour.
+
+For the icon, type the service name, such as `sonarr`. Stackyard searches [dashboard-icons](https://github.com/homarr-labs/dashboard-icons), [selfh.st](https://github.com/selfhst/icons), [simple-icons](https://github.com/simple-icons/simple-icons) and [lobehub](https://github.com/lobehub/lobe-icons). A URL or an uploaded file also works. Icons load through your server, so the icon CDN never sees your list of services.
+
+Drag rows in the Dashboard list to reorder the grid.
+
+## Folders
+
+A folder holds apps behind one tile. Press Add, choose Folder, then Add Apps. Folders do not nest, and widgets cannot go in one.
+
+## Dock
+
+Turn on Show in Dock on an app to pin it to the bar at the bottom. The dock holds four apps.
diff --git a/src/content/docs/docs/badges.md b/src/content/docs/docs/badges.md
index cf1271a..b642e3e 100644
--- a/src/content/docs/docs/badges.md
+++ b/src/content/docs/docs/badges.md
@@ -1,183 +1,65 @@
---
-title: Badges
-description: Health checks, fixed labels, and live activity counts on an app tile.
+title: Status badges and health checks
+description: Add health checks, fixed labels and live values from any API to Stackyard app tiles.
---
-**In the admin:** Dashboard, then any app. See the [Settings reference](/docs/settings-reference/).
-
-A badge is the small pill on the corner of an app tile. It carries context without adding a widget.
-
-
-
-
- !
-
- Health check
-
-
-
- 4K
-
- Fixed label
-
-
-
- 7
-
- Live activity
-
-
-
-Each kind is turned on per app, in that app's edit form under Badge.
+**In the admin:** Dashboard, open an app, then Badge.
+
+A badge is the small pill on an app tile. Each app can have three kinds.
-
- The three kinds, each with its own toggle. Live Activity opens the API fields when it is on.
+
## Health check
-Reports whether a service is up.
-
-| Type | What it reads |
-| --- | --- |
-| Container | The container's state from the Docker daemon. |
-| Ping | The HTTP status from a URL you give it. |
-
-Healthy shows green. A problem shows ! and hovering gives the reason: container not found, container not running, daemon reported it unhealthy, ping failed, or ping returned an error status.
+Shows whether the service is up.
-Container checks need Docker Container Health Checks turned on in General, plus socket access. See [Advanced configuration](/docs/advanced-configuration/). Ping checks need neither.
+- **Ping** reads the HTTP status of a URL.
+- **Container** reads the container state from Docker. It needs a socket proxy such as [docker-socket-proxy](https://github.com/tecnativa/docker-socket-proxy), its address in `SOCKET_PROXY_URL`, and Docker Container Health Checks on in General. Never mount the Docker socket into Stackyard.
-Turn on Hide Healthy Badge in General to show a badge only when something is wrong.
+A problem shows a red `!`. Hover it for the reason. Turn on Hide Healthy Badge in General to hide the green state.
## Fixed label
-Text you type once, capped at 10 characters. Pick a colour from the swatches or give a hex value.
+Up to 10 characters of text, in a colour you pick.
## Live activity
-Numbers read from a service's own API.
+A number from any API. Enter the API URL, add a header if the API needs a key, and press Fetch. Stackyard lists every number in the response. Pick one.
-Enter the API URL and press Fetch. Stackyard reads the response and lists every number it found, including array lengths and counts of matching items, so you pick from a menu rather than writing a path. No custom widget, no code.
-
-
-
-| Field | What it does | Example |
-| --- | --- | --- |
-| API URL | The endpoint to poll. | `https://requests.example.com/api/v1/request/count` |
-| Authentication | A header or parameter to send. Tick Secret to keep the value out of the browser. | Header `X-Api-Key`, Secret ticked |
-| Poll | How often to re-read the endpoint. | `300` seconds |
+Tick **Secret** on a header to keep its value on the server. Poll sets how often it reads, in seconds.
-
+### More than one value
-### Labels
-
-One poll can feed several labels. Each names one number from the response and carries its own text, colour, unit and threshold.
+One API can feed up to five values. Press Add Label for each.
-
- Press Add Label for another. Drag a card by its handle to reorder it, or use the arrows.
+
-
-
-| Field | What it does | Example |
-| --- | --- | --- |
-| Value | Which number from the response this label reads. | `pending` |
-| Label Text | The name shown in the list. Optional. | `pending` |
-| Color | The badge fill. Optional. | `#ffcc00` |
-| Unit | A short suffix for the number, shown in the list and read out by a screen reader. Optional. | `pending` |
-| Show From | The count below which this label stays quiet. Optional. | `5` |
-
-
-
-A label is quiet until its number reaches Show From. Leave Show From blank to report any count above zero. A count above 99 shows as `99+`, with the full number in the list.
+- **Show From** keeps a value hidden below that count.
+- The tile shows the first value that has something to report. A second pill behind it means more.
+- Hover, tap or focus the badge to list all values.
+- **Show as a Single Badge** adds them into one number.
-The pill carries the number alone, so a unit can never make it wider than the icon it marks. The unit appears in the list beside the number.
+Counts above 99 show as `99+`.
-An app can have five labels. Only the first one that has something to report is drawn on the tile, so a queue that is never quite empty stays out of the way until it matters.
+## When an app has more than one
-### Reading more than one
+A tile draws one badge. If an app has a health check, a fixed label and live activity together, the tile shows the first that applies:
-When a second badge is reporting, a matching pill appears behind the first in that badge's colour. It means there is more to see.
-
-
-
- Jellyfin and Seerr each have more behind the badge. SABnzbd has one label, so nothing is stacked.
-
+| Order | Badge | When it shows |
+| --- | --- | --- |
+| 1 | Health problem | The service is down. It hides everything else, so a fault is never masked by a number. |
+| 2 | Live activity | A value has reached its Show From. |
+| 3 | Fixed label | Nothing above applies. |
+| 4 | Healthy dot | The service is up and nothing else shows. Hidden by Hide Healthy Badge. |
-Hover the badge, tap it on a phone, or move focus to the tile with a keyboard. Everything the tile is reporting opens in a list, with the full numbers and no truncation. Press Escape or tap elsewhere to close it. Tapping the badge opens the list without following the link.
+The others stay available in the list that opens on hover or tap.
-
-
- The list carries whatever the badge has no room for, in the same order.
-
-
-The list only appears when there is a second badge. One badge is already fully shown on the tile.
-
-### One total instead
-
-Turn on Show as a Single Badge to add every label's value together and show the sum as one number, in the first label's colour. This is how Live Activity behaved before labels, and dashboards that used it keep it.
-
-### Folders
-
-A folder shows the badge of the app inside it that is reporting, rather than a total. Opening its list names each row by the app it came from.
-
-## Which badge wins
-
-An app can have several configured. The first of these that applies is the one drawn.
-
-
-
- 1
- !
- Health problemAlways outranks a count, so a fault is never hidden behind a number.
-
-
- 2
- 7
- Live activityThe first label at or above its Show From.
-
-
- 3
- 4K
- Fixed label
-
-
- 4
-
- HealthyHidden when Hide Healthy Badge is on.
-
-
- 5
-
- Nothing
-
-
-
-Whatever is not drawn is still reachable: two or more badges on one tile open the list described above.
+A folder shows the badge of the app inside it that is reporting.
## Stale values
-If a poll fails, the last known badge stays on the tile and is marked stale. A failed poll is never read as zero.
-
-
-
-
- 7
-
- Current
-
-
-
- 7
-
- Stale
-
-
-
-## Accessibility
-
-Every badge carries a text description for screen readers, so meaning is never carried by colour alone, and so does the list behind it. Badge text is white wherever white is readable against the fill, and dark only where white would fail the contrast requirement.
-
-The list opens on keyboard focus as well as on hover and tap, and label order can be changed with the arrow buttons as well as by dragging.
+When a poll fails, the last value stays on the tile, dimmed with a dashed outline. A failed poll never reads as zero.
diff --git a/src/content/docs/docs/contributing/api-errors.md b/src/content/docs/docs/contributing/api-errors.md
new file mode 100644
index 0000000..d8b9630
--- /dev/null
+++ b/src/content/docs/docs/contributing/api-errors.md
@@ -0,0 +1,41 @@
+---
+title: Stackyard API error reference
+description: The error shape the Stackyard API returns and what each error kind means.
+---
+
+Every API error has a readable `error`, a `kind` to branch on, and sometimes a `detail`.
+
+```json
+{ "error": "Could not reach the service.", "kind": "network", "detail": { "code": "ECONNREFUSED" } }
+```
+
+Never match words in `error`. Use `kind`.
+
+The `error` text is written from the `kind`, never copied from the underlying error, so no internal host or path reaches the browser. The original is logged.
+
+## Kinds
+
+| Kind | Meaning |
+| --- | --- |
+| `network` | The target could not be reached. |
+| `timeout` | The target was too slow. |
+| `blocked` | Stackyard refused the request, by its guard or rate limit. |
+| `auth` | The Stackyard session or password. Never an upstream key. |
+| `upstream` | The target answered with an error. `detail.status` holds it. |
+| `invalid` | A malformed request, or a missing item. |
+| `internal` | Anything else. |
+
+An upstream 401 or 403 is `upstream`, not `auth`. Treat an unknown `kind` as `internal`.
+
+## detail
+
+Only these keys, only server-derived values, and omitted when empty.
+
+| Kind | Key |
+| --- | --- |
+| `network`, `timeout` | `code`, a Node error code |
+| `upstream` | `status`, the HTTP status |
+| `invalid` | `code`, such as `ERR_INVALID_URL` |
+| `blocked` | `reason`, `private-address` |
+
+A new kind goes in `KIND` in both `api/src/api-error.js` and `ui/js/admin-error.js`. A test fails until they match.
diff --git a/src/content/docs/docs/contributing/architecture.md b/src/content/docs/docs/contributing/architecture.md
new file mode 100644
index 0000000..1e8fb1e
--- /dev/null
+++ b/src/content/docs/docs/contributing/architecture.md
@@ -0,0 +1,22 @@
+---
+title: How Stackyard works
+description: Stackyard architecture. Nginx and a dependency-free Node API in one container, a single JSON config, and widgets in frames.
+---
+
+Stackyard is one container. Nginx serves the interface from `ui/` and passes `/api/` to a Node server in `api/`. The API has no npm dependencies.
+
+## Config
+
+All state is one JSON file, `apps.json`. The admin writes it and the dashboard reads it. Secrets stay on the server and never reach the browser.
+
+## Widgets
+
+Each widget is a frame on the dashboard. The frame asks the API for its data. The API runs that widget's `data.js`, which calls the service. See [Build your first widget](/docs/create-a-widget/).
+
+## Outbound requests
+
+Every request to another host goes through `api/src/proxy.js`. A URL that arrives in a request is checked against private addresses. A URL from saved config is not. `ALLOW_PRIVATE_IPS` turns the check off.
+
+## Frontend
+
+Plain HTML, CSS and ES modules, with no build step. The dashboard and the admin are separate pages.
diff --git a/src/content/docs/docs/contributing/design-system.mdx b/src/content/docs/docs/contributing/design-system.mdx
new file mode 100644
index 0000000..c1dcaa1
--- /dev/null
+++ b/src/content/docs/docs/contributing/design-system.mdx
@@ -0,0 +1,60 @@
+---
+title: Stackyard design system
+description: The Stackyard colours, logo and the few design rules that go beyond Apple's Human Interface Guidelines.
+---
+
+Stackyard follows Apple's [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines) for type, spacing, controls and motion. This page covers only what is specific to Stackyard. Tokens live in `ui/css/tokens.css`.
+
+## Colour
+
+Teal is the brand accent. Rules name a role below, never a raw colour.
+
+
Dark theme
+
+
Accent#00D2E0
+
Success#30D158
+
Warning#FF9230
+
Danger#FF4245
+
Info#0091FF
+
+
+
Light theme
+
+
Accent#0071A4
+
Success#238539
+
Warning#C93400
+
Danger#D70015
+
Info#0040DD
+
+
+The other hues and greys are Apple's system colours.
+
+## What differs from the guidelines
+
+- **The dashboard is always dark.** Only the Settings page has a light theme.
+- **Text on cards uses `--sy-a11y-dim`.** Apple's secondary label colours fail WCAG contrast on Stackyard's cards.
+- **Text on a user-picked tile colour is black or white,** whichever contrasts more. It is measured, never fixed.
+- **Widgets cannot read tokens.** A widget copies the values it needs from the palette.
+
+## Logo
+
+Use the files as they are. Do not recolour, stretch or redraw them.
+
+
diff --git a/src/content/docs/docs/contributing/index.md b/src/content/docs/docs/contributing/index.md
new file mode 100644
index 0000000..26d728b
--- /dev/null
+++ b/src/content/docs/docs/contributing/index.md
@@ -0,0 +1,59 @@
+---
+title: Contribute to Stackyard
+description: The rules a Stackyard change must keep, how to run it locally, and the checks a pull request must pass.
+---
+
+## Rules
+
+- One container. No extra services, no database.
+- No runtime dependencies. The frontend has no framework and no build step.
+- Server code is CommonJS. Frontend code is ES modules.
+
+Open an issue first if a change needs a dependency or a build step.
+
+## Run locally
+
+```sh
+docker build -t stackyard:local .
+docker run -d -p 8700:80 -v ./data:/data -v ./icons:/icons stackyard:local
+```
+
+Without Docker, start the API with `npm start` in `api/`, setting `CONFIG_PATH`, `ICONS_PATH` and `WIDGETS_PATH=../ui/widgets`. Serve `ui/` with any server that proxies `/api/` and `/health` to `127.0.0.1:3000`, as `nginx/dashboard.conf` does.
+
+## Tests
+
+A behaviour change ships with its tests. A bug fix ships with a test that fails without it.
+
+```sh
+cd api && npm test
+cd ui/test && node --test
+```
+
+Playwright specs in `e2e/` run against `BASE_URL`, default `http://127.0.0.1:8730`.
+
+## Pull request checks
+
+CI runs `.github/actions/checks/action.yml`:
+
+```sh
+npm ci
+node scripts/changelog-check.js
+node scripts/changelog-fragments.js --check
+node scripts/bump-cache-busting.js --check
+npm run paths:check
+cd api && npm test
+cd api && npx c8 check-coverage --lines 92
+cd ui/test && node --test
+npm run lint
+npm run format:check
+npm run typecheck
+npm run typecheck:ui
+docker build -t stackyard:ci .
+```
+
+CodeQL and Trivy also run. Both block on a finding.
+
+- Do not edit `CHANGELOG.md`. Add a fragment in `changelog.d/` named `-.md`.
+- Write `?v=1` on new `/css/` and `/js/` imports. The release sets the real hash.
+- A new `ui/js` module needs two entries in `tsconfig.frontend.json`: the path and its `?v=*` form.
+- Run Biome through `npm run lint`. A bare `npx biome` runs an unrelated package.
diff --git a/src/content/docs/docs/contributing/releasing.md b/src/content/docs/docs/contributing/releasing.md
new file mode 100644
index 0000000..33affb2
--- /dev/null
+++ b/src/content/docs/docs/contributing/releasing.md
@@ -0,0 +1,28 @@
+---
+title: How Stackyard releases are made
+description: How a Stackyard version is cut, signed and published, and how the public demo runs.
+---
+
+## Cut a release
+
+1. Run **Release prep** in Actions with the version, such as `1.8.0`.
+2. It folds `changelog.d/` into the changelog, bumps the version, pins the demo image, and opens a pull request.
+3. Merge it. The tag builds, scans, signs and publishes the image and the release page.
+4. Merge the Community Applications pull request that follows.
+
+A tag like `v1.8.0-beta.1` publishes a pre-release and leaves `latest` and the demo alone.
+
+If a release build fails, fix `main`, delete the tag, and push it again.
+
+## Secrets
+
+- `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY`: a GitHub App with read and write on Contents and Pull requests, installed on this repository only. The built-in token cannot trigger the release workflows.
+- `DOCS_DEPLOY_HOOK_URL`: a Cloudflare Pages deploy hook. It rebuilds this site after a stable release. The release still succeeds without it.
+
+## Demo
+
+`DEMO_MODE=true` serves `api/demo/demo-config.json`, blocks every write, and makes no outbound requests. Widgets read their `demo.js` instead.
+
+`api/test/demo.test.js` fails on a private address, a secret or an unknown host in the demo config.
+
+On Render, `render.yaml` runs a pinned release image. Keep `PORT=80`.
diff --git a/src/content/docs/docs/contributing/translations.md b/src/content/docs/docs/contributing/translations.md
new file mode 100644
index 0000000..a06a6f9
--- /dev/null
+++ b/src/content/docs/docs/contributing/translations.md
@@ -0,0 +1,168 @@
+---
+title: Translations
+description: How Stackyard translations work, how to add a key or a language, and the terms translators must keep.
+---
+
+Translations are plain JSON files in `ui/i18n/`, one per language. No build step and no dependency. `en.json` is the source, and the fallback for any missing key.
+
+A translator changes JSON files only. No application code is involved.
+
+## Languages
+
+`LANGUAGES` in `ui/js/i18n.js` is the one place a language is defined. Nothing else decides what is offered, which direction it reads, or how its name is spelled.
+
+| Code | Language | Direction | Status |
+| --- | --- | --- | --- |
+| `en` | English | ltr | source |
+| `fa` | Persian | rtl | machine |
+| `zh-Hans` | Chinese (Simplified) | ltr | machine |
+| `es` | Spanish | ltr | machine |
+| `de` | German | ltr | machine |
+| `fr` | French | ltr | machine |
+
+Codes are BCP 47 tags. A code is also the catalog filename.
+
+`source` marks the language the strings are written in. `machine` marks a catalog produced by machine translation. **No speaker of that language has checked a `machine` catalog.** It is complete and structurally valid, but its wording is unverified. A correction from a speaker overrides what is there.
+
+## Translator notes
+
+- Translate values, not keys. Keep the JSON structure identical to `en.json`.
+- Keep placeholders such as `{count}` and `{name}` exactly as they are. A renamed or dropped placeholder takes the value out of the sentence.
+- Keep the markup tags ``, ``, `` and ` ` intact. No other tag renders.
+- Translate a whole message. Word order, articles and punctuation move when the language changes.
+- Leave proper nouns and technical tokens as they are: Unsplash, Docker, URLs, environment variable names, HTTP header names.
+
+### Terms with a fixed meaning
+
+| Term | Meaning |
+| --- | --- |
+| tile | One item on the dashboard grid. |
+| badge | The small status readout drawn on a tile. |
+| folder | A tile that opens to hold other tiles. |
+| widget | A tile that runs its own page in a frame. |
+| Dock | The fixed row of tiles at the foot of the dashboard. |
+| upstream | The service a tile points at, not Stackyard. |
+| stale | The last reading is old. The service was not reached this time. |
+| unavailable | The service answered, and it is not working. |
+
+Some languages have words that must or must not be used. `ui/test/i18n-coverage.test.mjs` holds that list and fails on a forbidden word.
+
+## Fallback
+
+Fallback is per key, not per locale. A key missing from the selected language falls back to English on its own, and the rest of the page stays translated.
+
+A key missing from English too renders as the key itself. The reachability test fails the build before that reaches a reader.
+
+## Counted messages
+
+A message with a count is stored once per plural category. The language's own rules choose the category:
+
+```json
+"loaded_one": "Loaded {count} option",
+"loaded_other": "Loaded {count} options"
+```
+
+Call it with a numeric `count`:
+
+```js
+t('widgetCfg.loaded', { count: opts.length });
+```
+
+Never choose the form in code. `count === 1` is an English rule. French and Persian put zero in `one`, Chinese has one form for every count, and other languages have `few` and `many`.
+
+Read the categories a language uses from the runtime:
+
+```sh
+node -e "console.log(new Intl.PluralRules('fr').resolvedOptions().pluralCategories)"
+```
+
+Every category listed must exist in that catalog, and no others.
+
+## Add a key
+
+1. Add it to `ui/i18n/en.json`, under the section it belongs to.
+2. Add the same key to the other five catalogs.
+3. Reference it by its full dotted name, such as `t('general.logLevel')` or `data-i18n="general.logLevel"`, so the reachability test can see it.
+
+A key nothing references fails the test suite. So does a reference to a missing key, and English written straight into the source, which `ui/test/hardcoded-strings.test.mjs` catches.
+
+In static markup, name the key on the element:
+
+| Attribute | Sets |
+| --- | --- |
+| `data-i18n` | text content |
+| `data-i18n-html` | text content, with the allowed markup tags |
+| `data-i18n-ph` | `placeholder` |
+| `data-i18n-al` | `aria-label` |
+| `data-i18n-title` | `title` |
+
+## Add a language
+
+1. Add it to `LANGUAGES` in `ui/js/i18n.js`:
+
+ ```js
+ { code: 'it', name: 'Italiano', english: 'Italian', dir: 'ltr', status: 'machine' },
+ ```
+
+2. Copy `en.json` to `ui/i18n/it.json` and translate the values.
+3. Copy `en.json` to `ui/widgets//i18n/it.json` for every widget and translate those too.
+4. Split each counted message into the categories the language uses.
+5. Update the language table on this page.
+
+The language then appears under Settings, General, Language.
+
+## Development locales
+
+Two locales exist for testing and are never offered in the selector. Add `?lang=` to the dashboard or admin URL:
+
+```
+http://localhost:8080/?lang=en-XA
+http://localhost:8080/admin/?lang=cimode
+```
+
+- **`en-XA`** accents every letter, pads the text by about 40% and brackets each message. Clipped text, bad wrapping and joined fragments show up while still readable.
+- **`cimode`** loads no catalog, so every string renders as its key. Text that bypasses the translation system stands out.
+
+Neither is saved. `e2e/localisation.spec.js` loads the dashboard and Settings in `en-XA` and fails on any control whose text overflows without an ellipsis, at desktop and at 390px wide.
+
+## Validation
+
+```sh
+cd ui/test && node --test i18n.test.mjs i18n-reachability.test.mjs i18n-coverage.test.mjs widget-i18n.test.mjs i18n-markup.test.mjs
+```
+
+These run offline and contact no translation service.
+
+## Widget strings
+
+A widget frame never loads the dashboard i18n module. The selected code arrives on the frame URL as `lang`.
+
+Everything a widget shows, in its settings form and in the widget itself, comes from its own folder:
+
+```
+ui/widgets//
+ widget.json
+ i18n/
+ en.json the source
+ fa.json one file per language
+```
+
+In `widget.json`, write the key where the text would go:
+
+```json
+{ "key": "dnsUrl", "type": "text", "label": "dnsUrl.label", "hint": "dnsUrl.hint" }
+```
+
+`label`, `placeholder`, `hint`, `rowLabel` and `fetchLabel` are resolved this way. The API substitutes the selected language when it serves the manifest.
+
+In the widget page, ask the toolbox:
+
+```js
+import { loadStrings, wt } from '/js/widget-toolbox.js?v=1';
+await loadStrings();
+wt('ui.queriesBlocked', 'Queries Blocked');
+```
+
+Resolution is the selected language, then the widget `en.json`, then the text passed in. The toolbox has no counted-message form. A widget that needs one selects the category itself with `Intl.PluralRules`.
+
+See [Build your first widget](/docs/create-a-widget/).
diff --git a/src/content/docs/docs/create-a-widget/checklist.md b/src/content/docs/docs/create-a-widget/checklist.md
new file mode 100644
index 0000000..41144d3
--- /dev/null
+++ b/src/content/docs/docs/create-a-widget/checklist.md
@@ -0,0 +1,29 @@
+---
+title: Widget checklist
+description: What to check before opening a pull request for a new Stackyard widget.
+---
+
+CI validates every manifest. Run the same check locally with `cd api && node --test`.
+
+## Files
+
+- `widget.json` with `name` matching the folder, `label`, `sizes` and `fields`.
+- `data.js`, unless the widget runs entirely in the browser.
+- `index.html`.
+- `i18n/en.json`, plus one catalog per shipped language.
+- `demo.js`, optional.
+
+## Behaviour
+
+- Upstream calls go through `ctx.fetchJSON`.
+- Failures use `ctx.fail`, never a returned error.
+- Empty and failed look different.
+- The page calls no other host.
+- Spacing next to text uses logical properties.
+
+## Look
+
+- Transparent background, system font, palette colours.
+- Readable at every size it offers.
+
+A refused manifest is logged at startup and nowhere else. Check `docker logs `.
diff --git a/src/content/docs/docs/create-a-widget/data.md b/src/content/docs/docs/create-a-widget/data.md
new file mode 100644
index 0000000..9223aca
--- /dev/null
+++ b/src/content/docs/docs/create-a-widget/data.md
@@ -0,0 +1,104 @@
+---
+title: Widget data reference
+description: The data.js contract for a Stackyard widget. The ctx object, fetching upstream, option endpoints, reporting failures and demo mode.
+---
+
+`data.js` runs on the server, in Node, as CommonJS. It exports one async function that takes `ctx`. The saved config is on `ctx.config`.
+
+```js
+module.exports = async function (ctx) {
+ const { url, apiKey } = ctx.config;
+ const r = await ctx.fetchJSON(`${url}/api/items`, {
+ headers: { 'X-Api-Key': apiKey },
+ timeout: 8000,
+ });
+ return { items: r.data.slice(0, 10) };
+};
+```
+
+The return value is served as-is at `/api/widget-data/`.
+
+A widget that renders entirely in the browser, like [Clock](/docs/widgets/clock/) or [Dashboard switch](/docs/widgets/dashboard-switch/), ships no `data.js`.
+
+## ctx
+
+| Property | What it is |
+| --- | --- |
+| `ctx.config` | The saved widget config, secrets included. Server-side only. |
+| `ctx.settings` | A frozen copy of the dashboard settings shared with widgets. An allowlist, currently `stats` only. Everything else is withheld. To share another key, add it to `SHARED_KEYS` in `api/src/widget-settings.js`. |
+| `ctx.endpoint` | The endpoint name, set for an `optionsFrom` fetch or a multi-view widget. |
+| `ctx.row` | For an `optionsFrom` fetch from a field in a `group`, that row's values. Otherwise `null`. |
+| `ctx.params` | Extra query parameters, as `URLSearchParams`. |
+| `ctx.fetchJSON(url, opts)` | Fetches a URL and parses the body. Returns `{ status, data }` or throws. JSON is returned as-is. Prometheus text and XML are parsed. Pass `{ raw: true }` for the untouched text. |
+| `ctx.parsePrometheus(text)` | Parses a Prometheus metrics body. Non-string input gives an empty object. |
+| `ctx.normalizeBase(raw)` | Tidies a user-entered base URL: adds a scheme, drops a trailing slash. |
+| `ctx.metrics` | Host metrics: `cpuSample`, `ramPercent`, `cpuTemp`, `diskStats`, `procCount`, `uptimeSeconds`. Each is a function. `cpuSample()` is async and returns `{ cpu, iowait }`. They read `/proc` and `/sys`, so they report the host, not the container limits. |
+| `ctx.dispatchProvider(handlers, opts)` | Runs the handler for the provider the user picked. `opts.field` holds the key, `provider` by default. `opts.default` is the fallback key. |
+| `ctx.fail(message, opts)` | Reports a failure. Throws. `opts.kind` is one of `ctx.KIND`, `UPSTREAM` by default. |
+| `ctx.KIND` | `AUTH`, `INVALID`, `UPSTREAM`, `NETWORK`, `TIMEOUT`, `BLOCKED`, `INTERNAL`. See [API errors](/docs/contributing/api-errors/). |
+| `ctx.log` | The structured logger. |
+
+Keep every upstream call behind `ctx.fetchJSON`. It applies the SSRF guard, IP pinning, the size limit and the TLS setting.
+
+### Metrics and XML bodies
+
+Metrics are recognised from `application/openmetrics-text`, `text/plain; version=0.0.4`, or a bare `text/plain` containing a `# TYPE` comment. Other plain text comes back as a string.
+
+XML `data` is keyed by the root tag. Attributes and child elements become keys, a repeated tag becomes an array, and a text-only element becomes its text. Numbers convert only when they round-trip exactly, so `007` stays a string.
+
+Parsed XML and Prometheus objects have a null prototype. Use `Object.hasOwn(o, k)`, not `o.hasOwnProperty(k)`. A feed field called `__proto__` or `constructor` then stays an ordinary key.
+
+## Option endpoints
+
+A `select` with `optionsFrom` calls the same function with `ctx.endpoint` set:
+
+```js
+module.exports = async function (ctx) {
+ if (ctx.endpoint === 'lists') {
+ const r = await ctx.fetchJSON(`${ctx.config.url}/api/lists`, { /* ... */ });
+ return { options: r.data.map(l => ({ value: l.id, label: l.name })) };
+ }
+ return { items: [] };
+};
+```
+
+See [Options from the service](/docs/create-a-widget/manifest/#options-from-the-service).
+
+## Reporting a failure
+
+Throw. Never return an error as data.
+
+```js
+if (!config.apiKey) ctx.fail('API key not configured', { kind: ctx.KIND.INVALID });
+if (r.status === 401) ctx.fail('Auth failed, check the API key', { kind: ctx.KIND.AUTH });
+if (r.status >= 400) ctx.fail('Service HTTP ' + r.status);
+```
+
+A thrown failure becomes a 502. The frontend `poll()` counts it toward `staleAfter`, keeps the last good render, and reports how long ago the data was fresh. A returned `{ error: ... }` arrives as HTTP 200, and `poll()` records it as a success.
+
+Do not catch what `ctx.fetchJSON` throws. Letting it propagate classifies it: a refused connection as a network failure, a deadline as a timeout, the guard as blocked.
+
+Use `ctx.fail`, not `throw new Error`. A thrown message is replaced with a generic one before it reaches the browser, because it may carry a hostname, a path or an upstream body. Never build a `ctx.fail` message from an upstream response or a caught error. A status code is fine.
+
+An error field inside a successful result is a different thing, and correct. A widget reporting several services marks the one that failed and returns the rest:
+
+```js
+return { services: [{ name: 'VPN', error: 'Auth required' }, { name: 'Proxy', connected: true }] };
+```
+
+## Demo mode
+
+The public demo has no reachable services. A widget can ship `demo.js` beside `data.js`, returning an invented body. It runs only with `DEMO_MODE=true`.
+
+```js
+module.exports = function (ctx) {
+ const { wave, round } = ctx.demo;
+ return { items: [{ name: 'Example' }], total: Math.round(wave(600, 8, 20)) };
+};
+```
+
+It receives the same `ctx`, plus `ctx.demo` with `wave` and `round`. `wave(periodSec, min, max, phase)` is a clock-driven oscillation, so values drift between polls and every widget on the demo moves together. Build structural data, such as a calendar grid, once and cache it in a module-level variable.
+
+A widget with no `demo.js` runs its real `data.js` on the demo. [System summary](/docs/widgets/system-summary/) has none, because `ctx.metrics` already returns invented figures there.
+
+Only polling gets a demo body. An `optionsFrom` fetch always runs the real code.
diff --git a/src/content/docs/docs/create-a-widget/index.mdx b/src/content/docs/docs/create-a-widget/index.mdx
new file mode 100644
index 0000000..d055cb5
--- /dev/null
+++ b/src/content/docs/docs/create-a-widget/index.mdx
@@ -0,0 +1,83 @@
+---
+title: Build a custom Stackyard widget
+description: Build a Stackyard dashboard widget step by step, from the manifest to the data handler and the page that draws it.
+---
+
+import WidgetGroup from '../../../../components/WidgetGroup.astro';
+
+A widget is one folder in `ui/widgets/`. This page builds one from the template in the repository.
+
+
+
+A widget has three files. `widget.json` defines its settings. `data.js` runs on the server and calls your service. `index.html` runs on the dashboard and draws the result.
+
+## 1. Copy the template
+
+```sh
+cp -r docs/widget-template ui/widgets/mywidget
+```
+
+## 2. Settings
+
+```json title="widget.json"
+{
+ "name": "mywidget",
+ "label": "My Widget",
+ "sizes": ["small", "medium"],
+ "fields": [
+ { "key": "url", "type": "text", "label": "Service URL", "placeholder": "http://host:port" },
+ { "key": "apiKey", "type": "secret", "label": "API key", "optional": true },
+ { "key": "showTotal", "type": "toggle", "label": "Show total", "default": true }
+ ]
+}
+```
+
+`name` must match the folder. Each field becomes a row in the widget's settings. A `secret` never returns to the browser. See [Manifest](/docs/create-a-widget/manifest/).
+
+## 3. Data
+
+```js title="data.js"
+module.exports = async function (ctx) {
+ const { url, apiKey } = ctx.config;
+ if (!url) ctx.fail('Enter the service URL.', { kind: ctx.KIND.INVALID });
+
+ const r = await ctx.fetchJSON(`${ctx.normalizeBase(url)}/api/items`, {
+ headers: apiKey ? { 'X-Api-Key': apiKey } : {},
+ timeout: 8000,
+ });
+
+ return {
+ items: (r.data.items || []).slice(0, 10).map(i => ({ name: i.name })),
+ total: r.data.total ?? 0,
+ };
+};
+```
+
+The return value is served at `/api/widget-data/`. The template returns `{ error }` when the URL is missing. Use `ctx.fail` as shown, so the widget shows its failure state. See [Data](/docs/create-a-widget/data/).
+
+## 4. Page
+
+```html title="index.html"
+
+```
+
+`poll()` fetches the data and handles loading, empty, stale and failed states. The template adds the markup and styles. See [Widget page](/docs/create-a-widget/widget-page/).
+
+## 5. Try it
+
+Restart Stackyard, then in Settings, **Dashboard**, press Add and pick **My Widget**. If it is missing, the container log says why.
+
+Before a pull request, go through the [checklist](/docs/create-a-widget/checklist/).
diff --git a/src/content/docs/docs/create-a-widget/manifest.md b/src/content/docs/docs/create-a-widget/manifest.md
new file mode 100644
index 0000000..0d42697
--- /dev/null
+++ b/src/content/docs/docs/create-a-widget/manifest.md
@@ -0,0 +1,191 @@
+---
+title: Widget manifest reference
+description: Every widget.json option for a Stackyard widget. Views, sizes, list icons, card backgrounds, field types and option pickers.
+---
+
+`widget.json` describes a widget: its `label`, the card `sizes` it offers, the settings form, and for a multi-view widget, its views.
+
+```json
+{
+ "name": "mywidget",
+ "label": "My Widget",
+ "sizes": ["small", "medium"],
+ "fields": [
+ { "key": "url", "type": "text", "label": "Service URL", "placeholder": "http://host:port" }
+ ]
+}
+```
+
+- **`name`** must match the folder name.
+- **`label`** is the name in the admin list and the type picker, and the default widget name when the user saves without one.
+- **`sizes`** is the set of card sizes offered: `small`, `medium`, `large`, `xlarge`.
+
+An invalid manifest is skipped at startup with a logged reason. Only that widget is disabled.
+
+## Views
+
+A widget can ship more than one page and let the user pick, like the [GitHub](/docs/widgets/github/) and [Clock](/docs/widgets/clock/) widgets. Declare each view and its file, the field that holds the choice, and the default:
+
+```json
+{
+ "viewField": "clockStyle",
+ "defaultView": "digital",
+ "views": {
+ "digital": { "label": "Digital", "src": "digital.html" },
+ "analog": { "label": "Analog", "src": "analog.html" }
+ }
+}
+```
+
+`viewField` names a field the user sets, usually a `select`. Its value is matched against the `views` keys. With no `views` block, the page is `index.html`. A single-view widget whose file is not `index.html` declares it as one view, with no `viewField`.
+
+`viewField` must name a declared field. If that field lists `options`, its values and the `views` keys must be the same set. A manifest that breaks either rule is rejected, because both failures are otherwise silent. A field using `optionsFrom` is not checked, since its choices are fetched at runtime.
+
+### Sizes per view
+
+A view can narrow the widget sizes, for a layout that works at one size only. The [Connections](/docs/widgets/connections/) map is Medium only:
+
+```json
+"views": {
+ "map": { "label": "Map", "src": "connections-map.html", "sizes": ["medium"] },
+ "vpn": { "label": "VPN", "src": "connections-vpn.html" }
+}
+```
+
+Each list must be a subset of the top-level `sizes`. A view without `sizes` offers all of them.
+
+## List icon
+
+Settings lists every item with an icon. A widget that names a `glyph` shows it. One that names none shows its card size.
+
+| `glyph` | What it depicts |
+| --- | --- |
+| `clock` | A dial and hands |
+| `weather` | A sun behind a cloud |
+| `gauge` | A dial with a needle, for a measured figure |
+| `shield` | A shield with record lines, for a name server |
+| `drive` | A drive with a trace across it |
+| `archive` | A store with an arrow into it |
+| `shelf` | Book spines |
+| `play` | A play mark in a frame |
+| `network` | Three linked nodes |
+| `merge` | Two branches joining one |
+| `panels` | Two panels, one handing over to the other |
+
+Two widgets may not name the same glyph. A test refuses it. A name not on this list is rejected at startup.
+
+## Card background
+
+The card behind a widget is glass by default: dark, semi-transparent and blurred, so the wallpaper reads through. A widget can name another:
+
+| `card` | What it looks like |
+| --- | --- |
+| `dark` | Solid dark, `#1c1c1e`. |
+| `light` | Solid white. |
+| `translucent` | Darker than the default but more transparent, with a stronger blur. |
+
+A `card` inside a `views` entry overrides the widget-level one for that view.
+
+Keep the default glass when the widget paints its own interior. The [Weather](/docs/widgets/weather/) widget does this, white by day and dark by night.
+
+Under the increased-contrast setting, `translucent` becomes as dense as the default card. An unknown name is rejected.
+
+## Field types
+
+| Type | What the user sees |
+| --- | --- |
+| `text` | An inline-edit row. |
+| `number` | An inline-edit row that stores a number. |
+| `secret` | An inline-edit row for a masked value. Shows `Configured` once set. The value stays on the server. Leaving it blank keeps the stored value. |
+| `toggle` | An on and off switch, stored as a boolean. |
+| `color` | The swatch and colour control used elsewhere in the admin. Saves `#rrggbb`. |
+| `select` | A dropdown. `"variant": "pills"` renders a radio group. With `optionsFrom` it adds a Fetch button. |
+| `multiselect` | A checklist dropdown. The value is an array. |
+| `group` | A repeatable set of sub-fields in a nested `fields` array, each entry its own card with Add and Remove. Groups cannot nest. |
+| `picklist` | A fixed number of dropdowns filled from one fetch. Saves an array, `null` where unset. Needs `count` or `countBySize`, plus `options` or `optionsFrom`. |
+| `object` | One nested set of sub-fields in a `fields` array, saved one level deep. Objects cannot nest. |
+
+## Field options
+
+| Key | Meaning |
+| --- | --- |
+| `label` | Shown to the user. Required. |
+| `placeholder` | The hint in an empty `text`, `number` or `secret` row. |
+| `default` | The value used when none is saved. |
+| `hint` | Short help under the field. On a `group`, it shows at the bottom of the section. |
+| `optional` | When `true`, the field is not required to save. A required `secret` counts as missing only when nothing is stored. |
+| `transient` | When `true`, the field is sent to an `optionsFrom` fetch but not saved. Use it for a search box. Top-level fields only. |
+| `carries` | For a `select` with `optionsFrom`: extra config keys this picker writes, from the chosen option's `set` block. |
+| `showIf` | Shows the field only when a sibling matches: `{ "field": "provider", "equals": "adguard" }`, or `{ "field": "provider", "in": ["adguard", "pihole"] }`. Anything else is rejected. |
+| `optionsFrom` | For a `select`: the data endpoint that returns the options at config time. |
+| `variant` | For a `select`: `"pills"` renders a radio group. |
+| `min`, `max` | For a `group`: the fewest and most entries. |
+| `maxBySize` | For a `group`: a cap per size, such as `{ "small": 2, "medium": 5 }`. Extra entries are trimmed on a smaller size. |
+| `countBySize` | For a `group`: a fixed row count per size, such as `{ "small": 1, "medium": 3 }`, with no Add or Remove. |
+
+## One key, asked for differently
+
+Two sibling fields may share a `key`, so the same value is asked for differently depending on another field. Give each one a `showIf`:
+
+```json
+{ "key": "url", "type": "text", "label": "Metrics URL", "placeholder": "conduit:9090",
+ "showIf": { "field": "type", "equals": "conduit" } },
+{ "key": "url", "type": "text", "label": "Management API URL", "placeholder": "netbird:33073",
+ "showIf": { "field": "type", "equals": "netbird" } }
+```
+
+Hidden fields are skipped when values are read back, so only the visible one saves. A repeated key without a `showIf` on every declaration is rejected. The validator does not check that the conditions exclude each other.
+
+## A fixed list of picks
+
+A `picklist` stores a plain array of ids, one per physical slot, filled from one call. [Disk health](/docs/widgets/disk-health/) uses it for bays:
+
+```json
+{ "key": "bays", "type": "picklist", "label": "Bays", "rowLabel": "Bay",
+ "optionsFrom": "devices", "countBySize": { "small": 4, "medium": 10 } }
+```
+
+One Fetch button loads the options once for every row. The saved value is always `count` entries long, such as `["sda-abc", null, ...]`.
+
+A `group` whose `min` equals its `max` is fixed-length too.
+
+## Nested settings
+
+Use `object` for config stored one level deep:
+
+```json
+{ "key": "vpn", "type": "object", "label": "Connection", "fields": [
+ { "key": "url", "type": "text", "label": "Control server URL" },
+ { "key": "apiKey", "type": "secret", "label": "API key", "optional": true }
+] }
+```
+
+That saves `{ "vpn": { "url": "...", "apiKey": "..." } }`. A sub-field `showIf` names a sibling inside the same object. Its secrets are scrubbed and kept like top-level ones.
+
+## Options from the service
+
+When a `select` can only be filled after the user enters a URL and key, give it `"optionsFrom": ""`. The form shows a **Fetch** button, which calls `data.js` with `ctx.endpoint` set to that name. Return `{ options: [{ value, label }, ...] }`.
+
+The fetch receives the current form values, `transient` fields included.
+
+An option can write other keys too. List them in `carries` and return them in the option's `set`. [Weather](/docs/widgets/weather/) stores coordinates this way:
+
+```json
+{ "key": "city", "type": "select", "optionsFrom": "geocode", "carries": ["lat", "lon"] }
+```
+
+```js
+return { options: [{ value: 'Ottawa, Ontario, Canada', label: 'Ottawa, Ontario, Canada', set: { lat: 45.42, lon: -75.7 } }] };
+```
+
+Saved values under carried keys are kept when the widget is edited without touching the picker.
+
+A `select` inside a `group` can use `optionsFrom`. Each row fetches on its own, and `ctx.row` holds that row's values:
+
+```js
+if (ctx.endpoint === 'jobs') {
+ const slot = ctx.row || {};
+ const r = await ctx.fetchJSON(`${ctx.normalizeBase(slot.url)}/api/jobs`, { /* ... */ });
+ return { options: r.data.map(j => ({ value: j.id, label: j.name })) };
+}
+```
diff --git a/src/content/docs/docs/create-a-widget/widget-page.md b/src/content/docs/docs/create-a-widget/widget-page.md
new file mode 100644
index 0000000..5afbef3
--- /dev/null
+++ b/src/content/docs/docs/create-a-widget/widget-page.md
@@ -0,0 +1,145 @@
+---
+title: Widget page reference
+description: The index.html side of a Stackyard widget. Frame parameters, the content policy, canvas sizes, text direction and the widget toolbox.
+---
+
+A widget page runs in a frame, scaled uniformly from a fixed design size to its card. It reads its `id` from the query string, fetches its own data, and draws it.
+
+- Keep styles and scripts inline or same-origin. There is no shared widget stylesheet.
+- Never call an external host. Reach a service through `data.js`.
+- The saved config, without secrets, is at `/api/widget-config/`.
+
+## Frame parameters
+
+| Parameter | What it is |
+| --- | --- |
+| `id` | The dashboard item id. Pass it to `/api/widget-data/` and `/api/widget-config/`. |
+| `size` | The size the user placed this widget at, one of the manifest `sizes`. |
+| `mobile` | `1` on the mobile layout, absent otherwise. |
+| `lang` | The selected language code. See [Translations](/docs/contributing/translations/#widget-strings). |
+| `v` | The cache version, stamped at release. Nothing to read. |
+
+## Canvas sizes
+
+| Size | Canvas |
+| --- | --- |
+| small | 170 × 170 |
+| medium | 360 × 170 |
+| large | 360 × 360 |
+| xlarge | 360 × 540 |
+
+Match the existing look: a transparent background, the system font stack, the dark palette. See [Design system](/docs/contributing/design-system/).
+
+## What a page may load
+
+The widget frame has a stricter Content-Security-Policy than the dashboard. Scripts and styles must be inline or same-origin. `connect-src` is `'self'`, so the only host a widget can call is Stackyard. Images may come from the icon CDN or a `data:` URI.
+
+## Text direction
+
+The dashboard sets the frame direction and language when it mounts the widget. A widget must not set `dir` on its own ``. In Persian the frame becomes right to left, and text, flex rows and grid columns reverse with it.
+
+Write spacing next to text with logical properties, so it reverses too:
+
+```css
+.flag { margin-inline-end: 5px }
+.meta { padding-inline-start: 13px }
+.left { border-inline-end: 1px solid rgba(255, 255, 255, 0.1) }
+```
+
+`left: 0; right: 0` is symmetric and needs no change. `left: 50%` with a translate is centring. Artwork is artwork, and mirroring it is usually wrong. `ui/test/rtl-logical-properties.test.mjs` enforces the rule for properties that carry text.
+
+Content that reads the same in every language, such as an IP address, a log tail or a chart axis, pins its own direction on the element:
+
+```html
+
10.0.0.1
+```
+
+Never on the document. The test refuses `dir` on `` or ``, and `direction` on `html`, `body` or `:root`.
+
+## Mobile active state
+
+A widget with an interior state a tap turns on, such as a selected row, takes part in this protocol. Without it, two widgets end up active at once.
+
+```js
+parent.postMessage({ type: 'widget-active' }, window.location.origin);
+
+window.__clearActive = () => { /* drop the active state, hide any tooltip */ };
+```
+
+The dashboard resets every other widget when it receives the message, and calls `__clearActive` when a tap lands outside any widget. The frames are same-origin, so a widget needs no `message` listener. If you add one, check `e.origin` against `window.location.origin` first.
+
+## Off-screen pages
+
+Every dashboard page is mounted at once. The dashboard slows the polling of widgets on other pages through `window.__setPollRate`, which the toolbox defines. A widget that uses `poll()` needs nothing. A widget with its own timer must read the same hook.
+
+Returning to a page refreshes a widget at once when its data is older than one normal interval.
+
+## Cache busting
+
+Nothing to do by hand. The release build hashes each widget entry file and stamps the version into the manifest.
+
+## Toolbox
+
+Optional, and it bundles the pieces widgets keep needing. Import from `/js/widget-toolbox.js` and keep the `?v=1`:
+
+```js
+import { poll, fetchData, sparkline } from '/js/widget-toolbox.js?v=1';
+```
+
+### Data
+
+- `widgetId()` returns this widget id from the frame URL.
+- `fetchData(endpoint?)` fetches `/api/widget-data/` and returns the parsed JSON. Throws on a non-OK response.
+- `getConfig()` fetches this widget config, without secrets.
+
+### Polling
+
+`poll(opts)` runs the fetch and render loop and handles the loading, empty, stale and error states. A single failed poll never blanks a working widget.
+
+```js
+poll({
+ render: data => { root.textContent = `${data.items.length} items`; },
+ isEmpty: data => data.items.length === 0,
+ interval: 30000,
+});
+```
+
+A failure keeps the last good render. After `staleAfter` consecutive failures, 2 by default, it shows the failure and how long ago the last success was. `sinceLabel(ts)` gives that label on its own.
+
+The first fetch runs at once. Each repeat is spread by up to 15%, so widgets on one dashboard do not fetch on the same tick.
+
+### Failure and empty states
+
+Empty and failed are different claims and must look different. `isEmpty(data)` decides which applies. Without a custom handler, `poll()` draws both. With `onError`, add `onEmpty` too.
+
+`errorState(opts)` draws a failure state:
+
+```js
+const state = errorState({ root, content: chartEl, caption: metaLineEl });
+
+poll({
+ render: d => { state.ok(); draw(d); },
+ isEmpty: d => d.items.length === 0,
+ onEmpty: () => state.empty(wt('ui.noItems', 'Nothing here')),
+ onError: ({ error, everOk, stale, since }) => {
+ if (!everOk || stale) state.fail(error, { since, inert: everOk });
+ },
+});
+```
+
+- `content` is what goes inert. Without it, every child of `root` except the caption does.
+- `caption` is the widget metadata slot. Without one, a line is placed at the foot of `root`, or over its centre with `place: 'center'`.
+- `fail(err, { since, inert })` returns the line it drew. Pass `inert: false` when the widget never had data.
+- Set `--wt-cap-color` on a widget with a light card.
+
+The wording comes from the failure kind, not the upstream message. `errorLine(err)` returns the same wording for a widget with its own designed state.
+
+### Links, markup and visuals
+
+- `openUrl(href)` opens a link in a new tab. Use it instead of `window.open`, which the sandbox can block.
+- `esc(value)` HTML-escapes a value for `innerHTML`. Use it for anything from config or upstream.
+- `sparkline(values, opts?)` returns an `