diff --git a/README.md b/README.md
index c1ef00d..5330a80 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,63 @@ API-first management plane and horizontally scalable, isolated scanner engines.
> [`ARCHITECTURE.md`](./ARCHITECTURE.md) and [`docs/`](./docs/) for the spec, and the GitHub
> milestones/issues for what is left.
+## The console
+
+
+
+Server-rendered HTML driven by HTMX and Alpine, under a strict Content-Security-Policy. It is a
+client of the same API routes documented in [`docs/api.md`](./docs/api.md) — not a second
+implementation of them. Dark mode follows the operating system.
+
+
+More screens — findings and triage, live scan status, sources, the engine fleet, dark mode
+
+
+
+**Findings.** Every filter is part of the URL and is sent to the API as written, so the rows always
+reconcile with the query above them.
+
+
+
+**A finding, and why it is in the state it is in.** The snippet was masked inside the engine before
+it ever crossed the wire; the database has never held the secret. The history below the triage panel
+is the `FindingEvent` trail.
+
+
+
+**A scan in flight.** The status region polls itself every three seconds and stops when the scan
+reaches a terminal state — the browser never has to know which statuses those are.
+
+
+
+**A source.** The credential is write-only at the API, so the form has nothing to echo back — it
+reports that one is stored and offers to replace it.
+
+
+
+**The fleet.** Engines hold no database credentials. When a rolling deploy leaves two rule-pack
+versions in force, the page says so rather than picking one.
+
+
+
+**Dark mode** is the same token set with dark surfaces — no toggle, no cookie, and no inline
+bootstrap script (which the CSP would forbid anyway).
+
+
+
+
+
+> Screenshots are of fabricated demo data on a local instance. Every snippet shown is already
+> masked, because that is the only form the database can hold.
+
## What it does
- **Discover & scan** content in Confluence (MVP), with Jira and SMB/NFS file shares to follow.
diff --git a/apps/api/src/iceberg_api/web/static/css/iceberg.css b/apps/api/src/iceberg_api/web/static/css/iceberg.css
index a77c71b..db43504 100644
--- a/apps/api/src/iceberg_api/web/static/css/iceberg.css
+++ b/apps/api/src/iceberg_api/web/static/css/iceberg.css
@@ -438,7 +438,16 @@ select.field {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px;
}
-.field-hint { display: block; margin-top: 5px; font-size: 12px; color: var(--muted); line-height: 1.4; }
+/* Hints live *inside* `.stack > label`, which is mono/uppercase/tracked because it
+ is a field label. A hint is prose, so it resets all three rather than inheriting
+ them — otherwise a sentence of guidance renders as shouted monospace. */
+.field-hint {
+ display: block; margin-top: 5px;
+ font-family: var(--fs-sans); font-size: 12px; font-weight: 400;
+ letter-spacing: normal; text-transform: none;
+ color: var(--muted); line-height: 1.45;
+}
+.field-hint code { font-size: 0.95em; }
/* Stacked labelled form */
.stack { display: flex; flex-direction: column; gap: 0.95rem; }
diff --git a/apps/api/src/iceberg_api/web/templates/partials/triage.html b/apps/api/src/iceberg_api/web/templates/partials/triage.html
index 7b6e8a3..4bb92ca 100644
--- a/apps/api/src/iceberg_api/web/templates/partials/triage.html
+++ b/apps/api/src/iceberg_api/web/templates/partials/triage.html
@@ -106,9 +106,16 @@
{{ event.created_at | dt }}
- {% if event.from_value or event.to_value %}
+ {% if event.kind.value == 'assign' %}
+ {#- An assign event records the assignee's *id*, which is what stays
+ correct when somebody is renamed. Show the name to the reader. -#}
+
+ {{ event.from_value | person(user_names) }} → {{ event.to_value | person(user_names) }}
+
+ {% elif event.from_value or event.to_value %}
- {{ event.from_value or '—' }} → {{ event.to_value or '—' }}
+ {{ event.from_value | humanize if event.from_value else '—' }}
+ → {{ event.to_value | humanize if event.to_value else '—' }}
{% endif %}
{% if event.comment %}
diff --git a/apps/api/src/iceberg_api/web/templating.py b/apps/api/src/iceberg_api/web/templating.py
index 9896568..f130d92 100644
--- a/apps/api/src/iceberg_api/web/templating.py
+++ b/apps/api/src/iceberg_api/web/templating.py
@@ -80,6 +80,24 @@ def _humanize(value: object) -> str:
return str(value).replace("_", " ").capitalize()
+def _person(value: object, names: dict[uuid.UUID, str]) -> str:
+ """A user id recorded on an audit event, rendered as the person's name.
+
+ An assignment event stores the assignee's id in ``to_value``, because that is
+ what stays correct when somebody is renamed. A trail that reads
+ "assign — → 966d7342-…" is technically complete and practically useless, so
+ the name is substituted wherever the reader is allowed to know it, and the id
+ is shortened rather than dropped when they are not.
+ """
+ if value in (None, ""):
+ return "unassigned"
+ try:
+ key = uuid.UUID(str(value))
+ except ValueError:
+ return str(value)
+ return names.get(key, f"{str(value)[:12]}…")
+
+
def build_environment() -> Environment:
"""The template environment.
@@ -99,6 +117,7 @@ def build_environment() -> Environment:
env.filters["ago"] = _format_ago
env.filters["short"] = _shorten
env.filters["humanize"] = _humanize
+ env.filters["person"] = _person
env.globals["app_name"] = APP_NAME
return env
diff --git a/docs/img/engines-light.png b/docs/img/engines-light.png
new file mode 100644
index 0000000..068b5a1
Binary files /dev/null and b/docs/img/engines-light.png differ
diff --git a/docs/img/finding-detail-light.png b/docs/img/finding-detail-light.png
new file mode 100644
index 0000000..76a2a12
Binary files /dev/null and b/docs/img/finding-detail-light.png differ
diff --git a/docs/img/findings-light.png b/docs/img/findings-light.png
new file mode 100644
index 0000000..71e905e
Binary files /dev/null and b/docs/img/findings-light.png differ
diff --git a/docs/img/overview-dark.png b/docs/img/overview-dark.png
new file mode 100644
index 0000000..616202f
Binary files /dev/null and b/docs/img/overview-dark.png differ
diff --git a/docs/img/overview-light.png b/docs/img/overview-light.png
new file mode 100644
index 0000000..3d22c78
Binary files /dev/null and b/docs/img/overview-light.png differ
diff --git a/docs/img/scan-live-dark.png b/docs/img/scan-live-dark.png
new file mode 100644
index 0000000..f688a29
Binary files /dev/null and b/docs/img/scan-live-dark.png differ
diff --git a/docs/img/source-detail-light.png b/docs/img/source-detail-light.png
new file mode 100644
index 0000000..f46219d
Binary files /dev/null and b/docs/img/source-detail-light.png differ
diff --git a/docs/web.md b/docs/web.md
index 22006f2..5df436b 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -227,6 +227,21 @@ breadcrumb + `.canvas`/`.canvas-inner`). Unauthenticated pages use
rail-scoped styling from the `--rail*` tokens — a `.btn` there paints a white box
on dark chrome.
+## Screenshots
+
+`docs/img/*.png` are captured by hand and embedded in the README. They are not
+generated by CI and will drift as screens change — treat a stale one as a doc
+bug, not a broken build.
+
+Reproducing them needs two things this repository deliberately does not contain:
+a database of fabricated findings, and a way to sign in. Authentication is OIDC
+only (ADR 0005), so a local instance cannot be signed into without a provider,
+and a development bypass — a login route that trusts a query string — is exactly
+the kind of thing that survives into a production image. Both therefore live
+outside the tree: a seeding script and a wrapper that imports the real
+`create_app()` and adds a single `/dev-login` route. Nothing under `apps/` knows
+either exists.
+
## Security properties this surface adds
- **Strict CSP** on every response except FastAPI's interactive docs, which load