# RFC 0003: A Home Page for the Instance
**Status:**
- proposed: 2026-09-11
- accepted: 2026-09-11 (D1 to D9, see Decisions)
- rejected: -
- implemented: 2026-09-11 (PR#54)
**Proposal:** `/` serves a home page for the instance instead of the served repository's Latest view.
It shows what is running, what failed and how the machine is doing.
Every tile links to what it shows.
## Context and Problem
`/` showed Latest Builds of the served repository — one of six on a multi-repository instance.
The wordmark led to `/repos`, the repository list; with one served repository `/repos` redirected to `/`.
Three things were missing:
**No view of the instance.**
The repository list answers "which repositories exist and what happened last", not "what is happening now" or "what needs attention".
**The machine state is a separate page.**
Whether the host is healthy stands on `/system` only.
**The wordmark led to a subpage.**
`brandUrl` was either `/repos` or the served repository.
## The Design
Direction "Werkbank", one of three drafts.
The four artboards under [`0003-homepage/drafts/`](0003-homepage/drafts/) are authoritative:
`werkbank-desktop-light`, `werkbank-phone-light`, `werkbank-desktop-dark`, `werkbank-phone-dark` (each `.dc.html`).
Two changes against the desktop drafts:
- The footer spans the full content width below both columns, outside `.wrap` (D5).
`.site-footer` itself is unchanged, so no other page changes.
- Light and dark are both targets.
The dark draft differs from the light one only in the `:root` block, so no colour may appear in the markup — everything goes through the custom properties in `werkator.css`.
Dark mode already exists for every page; new is that the reader can choose it.
The browser stays the default, a button in the header overrules it.
See [Switchable Dark Mode](#switchable-dark-mode).
### Layout
Two columns: a narrow system rail left, the repository mosaic right.
```
header (siteHeader, but: brandUrl -> "/", nav gets "Home" as .current,
plus the new light/dark button)
.wrap → 288px | minmax(0,1fr), gap 24px, width as main (1180px)
aside.rail
h2 "System" + last 24 h
.verdict dot + "Healthy" + time
.glist one row per CPU, RAM and each monitored disk
(four on mih09: CPU, RAM, SSD, HDD)
.tgrid 2x2: Load (load1), Swap (swapUsedGib),
CPU wait (cpuPressure), Mem wait (memoryPressure)
.mhint "Open the System page →"
main
.head no visible h1; explanation line + "1 running · 1 failed"
.grid 3 columns, one card per repository
.legend successful / failed / running
footer
```
### The System Rail
The rail has two size classes, drawn along the same line as the system page.
What has a **gauge** there — CPU, RAM and one row per monitored disk — gets a row with label, sparkline (about 150 × 26) and value.
What is only an **info tile** there — Load, Swap, CPU wait, Mem wait — gets a smaller cell in the 2×2 grid with a sparkline (about 62 × 14).
Every row and cell links to `/system`.
The graphs are not meant to be read, but to invite a click.
They still show real data: anything else would be decoration that lies.
Out of scope, noted here because it surfaced while drafting:
Load, Swap, CPU wait and Mem wait have no graph on the system page at all, although `SystemHealth` judges by the pressure values.
That is a separate PR.
### The Repository Mosaic
One card per served repository, three columns, newest activity first — the same order as the repository list (D7).
What needs attention stands out by colour, not by position.
Each card carries: status dot and word, the name, tags (number of builds, running, failed), the newest build with branch and commit, a bar strip of the last builds, plus duration and start time.
The bar strip is the card's picture: twelve bars, height is duration, colour is outcome — accent for green, clay for red, translucent accent for running, line colour for "no build here".
It replaces no number; it makes a tendency visible.
Three card states: normal, `run` (accent tint), `bad` (clay tint).
A fourth, `ghost`, for a private repository: greyed, italic, not clickable, as in the repository list since PR#43.
It keeps its bar strip, unlabelled (D4) — the card is anonymous, not empty.
### Phone
On a narrow viewport (reference: 390 px, content 354 px) the rail sits above the cards:
the four gauge metrics in two columns, a hairline, the four tiles in two columns, then the link to the system page.
The cards stack below.
## Data Sources
Everything exists; no new endpoint is needed.
| What | From |
|------------------------------------------------------------|---------------------------------------------------------------------|
| Repositories, status, running/failed, newest build | `GET /api/repos` (`RepoOverviews` → `RepoOverviewDto`) |
| System verdict and current values | `GET /api/system` (`SystemMetricsCollector`, `SystemHealth.assess`) |
| History for the sparklines | `GET /api/system/history?range=24h` |
| Build durations for the bar strip | `GET /api/repos`, new field in `RepoOverviewDto` (see D1) |
The sparklines come from the minute values of the 24-hour history, condensed to a few points (maximum per bucket), normalised to the box, emitted as a `polyline` plus a closed `path` — the same technique as `.spark` on the system page, smaller.
Available series: `cpuUsed`, `load1`, `ramUsedGib`, `swapUsedGib`, `cpuPressure`, `memoryPressure`, `disk..usedGib`.
The page is rendered server-side and refreshed by `werkator.js` from the same endpoints, like every other page.
## Constraints
- Work happens in the worktree `~/Projekte/werkator-home` on branch `home-page`, never in the primary checkout.
- No pull request, no merge, no push to `main`; the feature branch only.
- No deployment to mih09 or vm4006 unless asked.
- Version **1.9.0** (new behaviour) with the matching entry at the top of `releases.html`, or `ReleaseVersionConsistencyTest` fails.
- PR-doc in `docs/prs/` per the [pr-doc skill](../../.claude/skills/pr-doc/SKILL.md), as PR#54.
- The `AGENTS.md` invariants hold: no emoji, icons as CSS masks, colours only through custom properties, `UiFormats` and `werkator.js` produce identical formats, every fetch has a timeout and a visible error state, no frontend build.
- Tests per the [writing-tests skill](../../.claude/skills/writing-tests/SKILL.md); `./gradlew ktlintFormat` before `./gradlew build`.
### Additional Changes
Two sentences in [`docs/prs/2026-09-11-PR#53-testcontainers-smoke-test-on-the-webspace.md`](../prs/2026-09-11-PR%2353-testcontainers-smoke-test-on-the-webspace.md) are wrong.
The header says PR#53 was squashed to main without its PR-doc.
Correct: commit `bbb892f` reached main without any pull request, and the number 53 belongs to the documentation PR itself (`07785a6`).
Plus the two consequences of D8: the rationale on the `durations` endpoint in `BuildsApiController`, and the invariant in `AGENTS.md`.
### On Questions
- Routine decisions are made here; the assumption goes into the PR-doc and is named in one sentence at the end.
- Blocking questions only where a wrong assumption would make the work worthless or trigger something irreversible.
- Everything else is collected and asked at a natural point: one point per question, multiple choice, recommendation first.
- If a question affects only one part, everything else is finished and what stayed open is stated.
- Where draft and code disagree, the code wins on invariants, otherwise the draft — and the spot is named.
## Decisions
Nine points were open while drafting and were decided on 2026-09-11.
**D1 — the bars come from `RepoOverviews`.**
`RepoOverviewDto` gets a field with duration and outcome of the repository's last twelve builds.
One call of `GET /api/repos` then feeds the whole mosaic, and the home page polls like every other page.
`GET /api/builds/durations` stays untouched: it is repository-scoped and aggregates per build definition over thirty days — the History page's trend, not the last twelve runs.
Price: `/api/repos` grows for the repository list too, which does not need the field.
**D2 — `/` becomes the home page, Latest moves to `/latest`.**
The brief asked for the home page without a further path segment.
The `AGENTS.md` invariant that the unscoped form permanently means the served repository survives: `/latest`, `/branches` and `/history` still do, only `/` belongs to the instance.
To do: `brandUrl` points at `/`, the repository pages' tab strip links Latest to `/latest`, and `/` is not redirected to Latest — nothing ever linked there stably.
**D3 — the home page applies with exactly one repository too.**
Every instance gets the same entrance; the system rail is the actual gain and does not depend on the number of repositories.
The mosaic is then one card and needs a layout rule so it does not look thin beside the rail — the card takes the whole width.
`/repos` still redirects to `/` with one repository, which now shows the home page instead of Latest.
**D4 — a private repository carries the strip, unlabelled.**
The grid stays visually even, and a visitor sees the same card as for any other repository, only without name, branch, commit and artifacts.
This extends what `Redaction.redact` already lets through — status, start time and duration of the newest build are visible today — from one build to twelve.
D8 says why this is not a breach.
**D5 — the footer is content-width and sits below both columns.**
It keeps the width of header and content (`min(1180px, 100% - 64px)`) but moves out of the mosaic, under the whole page.
`.site-footer` itself is unchanged, so the footer looks the same on every page.
**D6 — the watcher banner yes, the repository line no.**
If the server cannot reach origin, every card below is as old as the last successful poll, and a calm mosaic would claim otherwise — so the banner belongs here.
The repository line says exactly what the mosaic shows large, and would sit as a duplicate directly above itself.
Consequence: the home page does not use `viewHead` as a whole — no tabs, no `h1`, no repository line, but the banner.
**D7 — sorted by newest activity, not by urgency.**
The same order as the repository list: a repository is found where it last was.
Running and failed stand out by accent and clay tint rather than by position — otherwise the grid reorders under the mouse on every poll and the click misses.
Price: a repository that failed days ago can sit far down; the line "1 running · 1 failed" above the mosaic names it anyway.
**D8 — a private repository gives out its metadata, never its content.**
The line is written out and applies to every endpoint from here on.
*Content is secret*, and that expressly includes the names:
the repository's name, its branch and build-definition names, commits, artifacts and everything in them.
*Metadata may be given out* where it says nothing about the content:
that a build ran, when, how long, with what outcome — likewise storage sizes and every other aggregate figure of the machine.
The disk breakdown on the system page is therefore unproblematic: its slices are categories, not repositories.
The occasion was an apparent contradiction between D4 and PR#49, and checking resolved it the other way round.
`GET /api/builds/history` and `GET /api/builds/latest` redact name, branch, commit and artifact key for a private repository, but hand out `status`, `startedAt`, `runningSince` and `durationSeconds` for **every** build.
Exactly what the comment on `durations` calls the secret — how often it runs, when it runs, how big it is — has long been available from its neighbours, and for the whole history rather than twelve runs.
It is not D4 that is out of line, but PR#49's rationale.
That also settles the observation that `history` gives out the whole time series: that is intended, not tolerated.
Two consequences, both as additional changes:
`GET /api/builds/durations` **keeps** the empty list but gets the correct reason — a trend card is grouped by build definition, and without that name it is meaningless, not dangerous.
The rule itself belongs in `AGENTS.md` as an invariant, or the next PR decides it differently again.
**D9 — a private repository answers a visitor like a non-existent one.**
Writing out D8 showed that the name was redacted but confirmable:
`/api/repos//builds/latest` answered a visitor without the control token with 200 and redacted data, an invented name with 404.
Whoever guesses the name gets the confirmation — and for a repository named after a customer or an unreleased product, the name is the secret.
From here on all routes `/api/repos//…` and `/repos//…` answer **404** for a private repository without a control token, exactly as for an unknown name.
Redaction keeps the job it was meant for: filling the entries of repository list, repository line and mosaic without naming anything.
The redacted detail pages become unreachable for visitors — they were only reachable through a guessed name anyway, so nothing visible is lost.
Scope: one condition where `repoOf` resolves the name, in `UiController` as in the API controllers, plus scenarios in the PR-doc.
The answer must not differ from that for an unknown name in **anything** — same status, same body, and no early failure that betrays the repository's existence through response time.
## Switchable Dark Mode
Dark mode exists for every page, but only as `@media (prefers-color-scheme: dark)` in `werkator.css`: the browser decides, not the reader.
New is the switch, not the mode.
The browser stays the default; a button at the top right of the header — beside the live indicator, key and reload, as an icon mask like those — switches to light or dark and remembers the choice in the browser.
Technically the token blocks then stand three times: `:root` for light, `@media (prefers-color-scheme: dark)` for the default, and an attribute on the root element for the choice, which wins in both directions.
The stored choice must be applied before the first paint, or the page flickers.
`color-scheme` travels with the choice, or form controls and scrollbars stay with what the browser thinks right while the page shows the opposite.
The switch applies to the whole application, not only to the home page; it is the one part of this PR that touches every other page.
A naming trap: the model attribute `homeUrl` in `fragments.html` means the repository's Latest page today, not the instance home page.
It becomes `/latest` under D2 and should be renamed to what it is.
## Draft Sources
- [`0003-homepage/drafts/`](0003-homepage/drafts/) — the four accepted artboards
- [RFC 0001](0001-web-ui-instrument-panel.md) — the visual language ("Kin") the home page takes its tokens, status dots and icons from