diff --git a/docs/superpowers/specs/2026-08-23-web-ui-rewrite-design.md b/docs/superpowers/specs/2026-08-23-web-ui-rewrite-design.md
index eb16f66..ceac12f 100644
--- a/docs/superpowers/specs/2026-08-23-web-ui-rewrite-design.md
+++ b/docs/superpowers/specs/2026-08-23-web-ui-rewrite-design.md
@@ -257,7 +257,12 @@ by outbound documentation links.
- **live** — the `EventSource` subscription and its reconnect behaviour.
- **format** — `Intl.DateTimeFormat` wrappers. Dates only; money is formatted server-side.
- **docs** — constructs outbound keeltrading.com links, with the anchor and the `?v=` version.
-- **sw** — the service worker.
+- **sw** — the service worker. **Amended at #538: this one file is NOT under `js/`.** A service
+ worker's registration scope is its own directory, so `static/js/sw.js` would be scoped to
+ `/static/js/` and could not answer a navigation to `/static/insights` — the deep links §"Static
+ assets" requires. It ships at `static/sw.js` instead. The alternative, a `Service-Worker-Allowed`
+ header widening the scope from `js/`, was rejected for failing silently: remove the header and
+ the worker still installs, still activates, and simply stops controlling the app.
In **js/external** there is nothing, and that is the intended end state rather than a stage.
diff --git a/keel/web/render.py b/keel/web/render.py
index fbd8419..36a6158 100644
--- a/keel/web/render.py
+++ b/keel/web/render.py
@@ -24,8 +24,20 @@
from collections.abc import Iterable, Sequence
from decimal import Decimal
from typing import Any
+from urllib.parse import quote
+
+#: The published documentation root. Spelled here and in `static/js/docs.js`, and
+#: `tests/web/test_doc_links.py` pins that the two agree.
+DOCS_URL = "https://keeltrading.com/en/docs/"
#: Nav order, and the labels. `/` first because the status page is the answer to "is it alive".
+#:
+#: **The eighth entry is an OUTBOUND link, and used to be a page (#539).** `/glossary` rendered
+#: `docs/glossary.md` read from the working directory -- which no installed deployment has, since
+#: `docs/` sits at the repository root, outside the `keel/` module `uv_build` packages. Every
+#: install therefore rendered an empty glossary, and `help_console.load_glossary`'s docstring said
+#: so. It is a link now, for the same reason the client's is: linking is the only form of this
+#: that reaches an installed deployment at all.
NAV: tuple[tuple[str, str], ...] = (
("/", "Status"),
("/setup", "Setup"),
@@ -34,7 +46,7 @@
("/rules", "Rules"),
("/venues", "Venues"),
("/gates", "Gates"),
- ("/glossary", "Glossary"),
+ (DOCS_URL, "Docs"),
)
_STYLE = """
@@ -304,6 +316,7 @@ def page(
path: str,
body: str,
build: str = "",
+ version: str = "",
refresh_sec: int | None = None,
) -> str:
"""The document shell. `refresh_sec` emits a `` -- a zero-JS
@@ -313,7 +326,29 @@ def page(
nav_items = []
for href, label in NAV:
on = ' class="on"' if href == path else ""
- nav_items.append(f'{esc(label)}')
+ # An outbound entry is navigation, not a subresource, so `default-src 'none'` does not
+ # reach it. `noopener` still does matter: a new tab opened without it holds a
+ # `window.opener` handle back to a trading console on a token-bearing origin.
+ #
+ # `?v=` carries the running build, exactly as `static/js/docs.js` does for the client:
+ # the site pins `main` while an operator runs a tagged release, so a linked page can
+ # describe behaviour their build does not have. That skew is made VISIBLE rather than
+ # solved -- the build ends up in the URL bar of the page they are reading. `quote`, not
+ # an f-string, because a full version is `0.11.2+c1634a3fa17f` and a raw `+` in a query
+ # string decodes to a space.
+ #
+ # `version`, NOT `build`, and the two are different strings: `build` is the footer's
+ # human-readable LINE -- `keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]` -- and the first
+ # spelling of this used it, putting that whole sentence percent-encoded into the query.
+ # Caught by looking at the rendered href, not by a test: both forms are non-empty
+ # strings and every assertion about "the link carries a version" passed.
+ away = ""
+ target = href
+ if href.startswith("https://"):
+ away = ' target="_blank" rel="noopener noreferrer"'
+ if version:
+ target = href + "?v=" + quote(version, safe="")
+ nav_items.append(f'{esc(label)}')
nav = "".join(nav_items)
meta_refresh = (
f'' if refresh_sec else ""
@@ -948,34 +983,5 @@ def render_gates(gates: Sequence[Any], capabilities: Sequence[Any]) -> str:
return "".join(parts)
-def render_glossary(terms: Sequence[Any]) -> str:
- parts = [
- '
Glossary
keel\'s vocabulary, and the fiqh terms it anchors to
',
- '
',
- ]
- for term in terms:
- marker = ' fiqh' if term.fiqh else ""
- if term.fiqh and not term.stated:
- marker = ' not stated in fiqh-basis'
- parts.append(f"
{esc(term.term)}{marker}
")
- parts.append(f"
{esc(term.definition)}
")
- source = term.citation or term.source
- if source:
- parts.append(f'
{esc(source)}
')
- parts.append("
")
- if not terms:
- # The normal state of an INSTALLED deployment, not a bug: `docs/glossary.md` is read from
- # the working directory, and a deployment folder is a config, a database and an .env --
- # there is no docs checkout beside them. The TUI's help screen shows the same empty state
- # for the same reason. Packaging the docs inside the artifact is D5's business (#438);
- # until then this says which file is missing rather than implying the glossary is empty.
- parts.append(
- '
No glossary here. keel reads docs/glossary.md from '
- "the folder it is run in, and an installed deployment has no docs checkout beside "
- "its config and database.
'
diff --git a/keel/web/server.py b/keel/web/server.py
index 65acdc8..4031740 100644
--- a/keel/web/server.py
+++ b/keel/web/server.py
@@ -280,17 +280,11 @@ def page_gates(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, st
return "Gates", render.render_gates(GATES, CAPABILITIES), None
-def page_glossary(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str, str, int | None]:
- from keel.commands.help_console import load_glossary
-
- return "Glossary", render.render_glossary(load_glossary()), None
-
-
ROUTES: dict[str, Callable[[ServeConfig, dict[str, list[str]]], tuple[str, str, int | None]]] = {
# First-run detection (#437): every page that reads the database serves the checklist when
# there is no database to read, rather than a 500 whose real cause is that the user has not
- # set anything up yet. `/venues`, `/gates` and `/glossary` are not wrapped -- none of them
- # touches the deployment, and all three are useful before one exists.
+ # set anything up yet. `/venues` and `/gates` are not wrapped -- neither touches the
+ # deployment, and both are useful before one exists.
"/": needs_database(page_status),
"/setup": page_setup,
"/activity": needs_database(page_activity),
@@ -298,7 +292,6 @@ def page_glossary(_cfg: ServeConfig, _query: dict[str, list[str]]) -> tuple[str,
"/rules": needs_database(page_rules),
"/venues": page_venues,
"/gates": page_gates,
- "/glossary": page_glossary,
}
@@ -466,6 +459,22 @@ def run_setup_action(cfg: ServeConfig, key: str, form: dict[str, str]) -> Any:
_JSON_CONTENT_TYPE = "application/json; charset=utf-8"
+def _docs_version(cfg: ServeConfig) -> str:
+ """The build the nav's documentation link should report, or `""`.
+
+ Read off `build_info` rather than off `cfg.build`, because those are different strings and
+ only one of them is a version: `cfg.build` is the footer's human-readable LINE
+ (`keel 0.11.2+c1634a3fa17f (DIRTY) [checkout]`), and putting it in a query string produced
+ `?v=keel%200.11.2%2B...%20%28DIRTY%29%20%5Bcheckout%5D`. This is the same field `/api/config`
+ hands the client for the same purpose (`payload.config_document`'s `"build"`), so both
+ front-ends report the identical string while both exist.
+
+ `""` when there is no build info at all -- an unversioned link is honest, and a link claiming
+ `?v=unknown` is not.
+ """
+ return str(getattr(cfg.build_info, "full_version", "") or "")
+
+
def _static_headers(content_type: str) -> tuple[tuple[str, str], ...]:
"""`_STATIC_BASE_HEADERS` plus CSP, but ONLY when `content_type` is one of
`_CSP_CONTENT_TYPES` -- see the comments on `_STATIC_BASE_HEADERS` and `_CSP_CONTENT_TYPES`
@@ -571,6 +580,7 @@ def _refuse(self, code: int, heading: str, detail: str) -> None:
path="",
body=render.render_message(heading, detail),
build=self.cfg.build,
+ version=_docs_version(self.cfg),
),
)
@@ -906,6 +916,7 @@ def do_GET(self) -> None: # noqa: N802 - stdlib's naming, not ours
path=parsed.path,
body=body,
build=self.cfg.build,
+ version=_docs_version(self.cfg),
refresh_sec=refresh,
),
)
diff --git a/keel/web/static/css/keel.css b/keel/web/static/css/keel.css
index db9962e..de68061 100644
--- a/keel/web/static/css/keel.css
+++ b/keel/web/static/css/keel.css
@@ -181,6 +181,22 @@ h2 { font-size: 1.05rem; margin: 2rem 0 0.6rem; }
}
.kv .v { font-size: 1.05rem; font-variant-numeric: tabular-nums; }
+/* #539: a label that names a documented term links out to its definition.
+ `--muted`, NOT `--accent`: the label is the quietest text on the card and a link colour there
+ would pull the eye off the figure beside it, which is the thing the operator came to read. The
+ dotted underline is what marks it as a link -- and it is an underline rather than colour alone,
+ because #532's whole finding was that this palette had been separating meanings by hue.
+ `text-decoration-thickness` is set because a dotted border-bottom would sit below the descenders
+ and read as a divider instead. */
+.kv .k .doclink {
+ color: inherit;
+ text-decoration: underline dotted;
+ text-underline-offset: 0.2em;
+ text-decoration-thickness: 1px;
+}
+.kv .k .doclink:hover,
+.kv .k .doclink:focus-visible { color: var(--fg); text-decoration-style: solid; }
+
.tablewrap {
overflow-x: auto;
/* A scrollable region must be reachable by keyboard. `tabindex="0"` on the wrapper (set in
diff --git a/keel/web/static/icons/keel-192.png b/keel/web/static/icons/keel-192.png
new file mode 100644
index 0000000..df63420
Binary files /dev/null and b/keel/web/static/icons/keel-192.png differ
diff --git a/keel/web/static/icons/keel-512.png b/keel/web/static/icons/keel-512.png
new file mode 100644
index 0000000..fae018d
Binary files /dev/null and b/keel/web/static/icons/keel-512.png differ
diff --git a/keel/web/static/icons/keel-maskable-512.png b/keel/web/static/icons/keel-maskable-512.png
new file mode 100644
index 0000000..0c84478
Binary files /dev/null and b/keel/web/static/icons/keel-maskable-512.png differ
diff --git a/keel/web/static/icons/keel.svg b/keel/web/static/icons/keel.svg
new file mode 100644
index 0000000..1672d71
--- /dev/null
+++ b/keel/web/static/icons/keel.svg
@@ -0,0 +1 @@
+
diff --git a/keel/web/static/index.html b/keel/web/static/index.html
index 8df55e6..f6ec75c 100644
--- a/keel/web/static/index.html
+++ b/keel/web/static/index.html
@@ -38,6 +38,22 @@
to a declared base.
-->
+
+
+
+
+
@@ -55,6 +71,23 @@
diff --git a/keel/web/static/js/docs.js b/keel/web/static/js/docs.js
new file mode 100644
index 0000000..adeb0e1
--- /dev/null
+++ b/keel/web/static/js/docs.js
@@ -0,0 +1,141 @@
+// @ts-check
+/**
+ * Outbound documentation links (#539). **The app fetches, bundles and caches nothing.**
+ *
+ * ── WHY LINKING IS THE ONLY OPTION, NOT THE CHEAP ONE ───────────────────────────────────────
+ * `docs/` lives at the REPOSITORY root, outside `keel/`, and `uv_build` packages the module root
+ * -- so no wheel has ever carried it. Every installed deployment, the signed desktop bundle
+ * included, renders an empty glossary today, and `keel/commands/help_console.py` says so in its
+ * own docstring: "an installed deployment has no docs/ checkout, and the help screen renders
+ * that notice as its empty state."
+ *
+ * That is not fixable by adding a packaging glob -- it is structural. Measured at #535: building
+ * with `artifacts` set and with `artifacts = []` produces byte-identical wheels, because
+ * `uv_build` ships the whole module root regardless of the key. Linking out is the only option
+ * that reaches an installed deployment at all.
+ *
+ * ── NO OFFLINE FALLBACK, DELIBERATELY ───────────────────────────────────────────────────────
+ * No inline definitions, no cached snapshot, no entry in the service worker's `PRECACHE`. An
+ * operator running a trading engine has network by definition, and #538's whole argument is that
+ * a cached copy of something authoritative is worse than no copy: a definition that has since
+ * changed, presented as current, with nothing on screen to say which it is.
+ *
+ * ── THE ANCHOR CONTRACT, AND WHY A TEST HOLDS IT ────────────────────────────────────────────
+ * `docs/glossary.md` states its own rule -- "Each entry is a `## term` heading, a definition,
+ * and a `Source:` line" -- and Astro emits kebab-cased IDs for those headings, so the anchor for
+ * a term is its heading kebab-cased. Nothing in either repository enforces that from the other
+ * side: a heading renamed upstream would break every deep link here **silently**, because a bad
+ * fragment is not an error, it is a page that opens at the top.
+ *
+ * `tests/web/test_doc_links.py` closes that by parsing this table and asserting every anchor
+ * exists as a heading in the named document, in this repository, where `docs/` is the source.
+ */
+
+/** The published documentation root. One string, spelled once. */
+const SITE = "https://keeltrading.com/en/docs/";
+
+/**
+ * The running build, for `?v=`.
+ *
+ * **Version skew is made VISIBLE here, not solved.** The site pins `main` while an operator runs
+ * a tagged release, so a linked page can describe behaviour their build does not have. Per-
+ * version documentation paths were rejected: `keeltrading.com/en/docs/v0.11.0/glossary` 404s
+ * today, and building versioned trees is work in the other repository plus a retention policy,
+ * across three languages and a sitemap. Carrying the version in the query string costs nothing
+ * and puts the operator's build in the URL bar of the page they are reading.
+ *
+ * Module state, written exactly once, at boot, by `main.js` -- the alternative is threading a
+ * version string through every render function to reach the four places that build a link.
+ */
+let version = "";
+
+/**
+ * Record the build every documentation link should carry. Called once from `main.js`, from the
+ * same `/api/config` read that fills the footer.
+ *
+ * @param {string} build
+ */
+export function rememberVersion(build) {
+ version = typeof build === "string" ? build : "";
+}
+
+/**
+ * The URL for one document, optionally at one anchor.
+ *
+ * The trailing slash on the slug is not cosmetic: the site serves `…/docs/glossary/index.html`,
+ * and the un-slashed form is a redirect that some browsers resolve by dropping the fragment --
+ * a deep link that lands at the top of the page, which is exactly the failure this module's
+ * anchor table exists to prevent.
+ *
+ * @param {string} slug a document slug, or `""` for the documentation index.
+ * @param {string} anchor a heading anchor, or `""` for the top of the page.
+ * @returns {string}
+ */
+export function documentUrl(slug, anchor) {
+ let url = SITE;
+ if (slug) url = url + slug + "/";
+ if (version) url = url + "?v=" + encodeURIComponent(version);
+ if (anchor) url = url + "#" + anchor;
+ return url;
+}
+
+/** The documentation index, for the header's outbound link. @returns {string} */
+export function indexUrl() {
+ return documentUrl("", "");
+}
+
+/**
+ * The labels this client puts on screen that name a term the documentation defines, mapped to
+ * where it is defined.
+ *
+ * **Keyed by the LABEL, not by the term.** The alternative -- tagging each call site with a term
+ * name -- spreads the decision across six views and makes "which words on this screen are
+ * defined somewhere" unanswerable without reading all of them. Keyed by label, the whole answer
+ * is this table, and `kv` consults it for every pair it builds, so a label that names a term is
+ * a link wherever it appears without a call site knowing.
+ *
+ * **Deliberately NOT exhaustive, and the omissions are the point.** `mode` reads `paper` or
+ * `live` and would need two different targets for one label; `evidence required` sits on the
+ * CAPABILITY gates (`keel.capabilities.GATES`), not the promotion gate, and linking it to
+ * `#promotion-gate` would be confidently wrong. A missing link costs a reader one search. A
+ * wrong one costs them their trust in every other link on the page.
+ *
+ * @type {Record}
+ */
+export const TERMS = {
+ autonomy: { slug: "glossary", anchor: "autonomy" },
+ "autonomy configured": { slug: "glossary", anchor: "autonomy" },
+ "autonomy lapses": { slug: "glossary", anchor: "autonomy" },
+ "kill switch": { slug: "glossary", anchor: "kill-switch" },
+ "rail 11": { slug: "glossary", anchor: "rail" },
+ "withdrawal attestation (rail 17)": { slug: "glossary", anchor: "attestation" },
+ "market session": { slug: "glossary", anchor: "market-clock" },
+ session: { slug: "glossary", anchor: "session-bound-venue" },
+ "paper stage": { slug: "glossary", anchor: "paper-mode" },
+};
+
+/**
+ * A label as an outbound link to its definition, or `null` if the label names no term.
+ *
+ * `rel="noopener"` with `target="_blank"`: a new tab opened without it gets a `window.opener`
+ * handle back to this page, and this page is a trading console on a token-bearing origin.
+ * `noreferrer` too -- the server already sends `Referrer-Policy: no-referrer`, and a link that
+ * states it as well is one that keeps holding if this markup is ever read somewhere the header
+ * is not sent.
+ *
+ * @param {string} label
+ * @returns {HTMLAnchorElement | null}
+ */
+export function termLink(label) {
+ const target = Object.prototype.hasOwnProperty.call(TERMS, label) ? TERMS[label] : null;
+ if (!target) return null;
+ const anchor = document.createElement("a");
+ anchor.className = "doclink";
+ anchor.textContent = label;
+ anchor.href = documentUrl(target.slug, target.anchor);
+ anchor.target = "_blank";
+ anchor.rel = "noopener noreferrer";
+ // Named for a reader who arrives on the link out of context, and hears only the link text.
+ anchor.title = "Definition on keeltrading.com — opens in a new tab";
+ return anchor;
+}
diff --git a/keel/web/static/js/main.js b/keel/web/static/js/main.js
index 4d52cfa..bbd9eb2 100644
--- a/keel/web/static/js/main.js
+++ b/keel/web/static/js/main.js
@@ -34,6 +34,7 @@
*/
import { read } from "./api.js";
+import { indexUrl, rememberVersion } from "./docs.js";
import { available, subscribe } from "./live.js";
import {
activityView,
@@ -127,6 +128,8 @@ const engineNode = must("engine");
const contentNode = must("content");
/** @type {HTMLElement} */
const buildNode = must("build");
+/** The header's outbound documentation link (#539); its href gains `?v=` once the build is known. */
+const docsNode = /** @type {HTMLAnchorElement} */ (must("docs-link"));
/**
* An element that `index.html` guarantees. Throwing beats rendering half a page: the two files
@@ -585,13 +588,60 @@ if (window.location.pathname !== pathFor(booted)) {
}
/**
- * The footer's build line, read once.
+ * Register the service worker (#538), keyed to the build that just answered.
+ *
+ * **After `/api/config`, never before, and that ordering is the whole design.** The worker's
+ * cache name comes from the build string, so registering before the build is known would install
+ * a worker under a name that has to be corrected on the next load -- two registrations, two
+ * caches, for one deployment. Waiting costs one round trip against a local socket.
+ *
+ * **A failed read registers nothing, deliberately.** With `keel serve` stopped this promise
+ * resolves with `data: null`, and the right response is to leave whatever worker is already
+ * installed exactly as it is: it is the one serving the shell that is letting the operator read
+ * this page at all. Re-registering it under `unknown` would swap a correct cache for an empty
+ * one at the precise moment the network cannot refill it.
+ *
+ * **`encodeURIComponent`, because the build string contains `+`.** `keel.version` produces
+ * `0.11.2+88fb17bcab15`, and a raw `+` in a query string decodes to a SPACE -- the worker would
+ * read a different build than the one that is running, and the cache key would silently stop
+ * tracking the binary it is supposed to track.
+ *
+ * @param {any} config `/api/config`'s `data`, or `null`.
+ */
+function registerWorker(config) {
+ if (!("serviceWorker" in navigator)) return;
+ const build = (config && (config.build || config.version)) || "";
+ if (!build) return;
+ // Errors are swallowed on purpose and the app carries on: every failure mode here -- an
+ // unsupported browser, a user profile with workers disabled, a private window -- costs the
+ // offline shell and nothing else. A dashboard that refused to render because it could not
+ // install an optional cache would be trading a working page for a nicety.
+ void navigator.serviceWorker
+ .register(`${BASE}sw.js?v=${encodeURIComponent(build)}`, { scope: BASE })
+ .catch(() => {});
+}
+
+/**
+ * The build, read once, and the three things that depend on it.
*
* Once, not per poll: `/api/config` describes the binary that is answering, and that cannot
* change without the process restarting -- at which point the session token is new, every fetch
* is a 403, and the banner says so. A version string re-read four times a minute would be four
* times a minute spent confirming a constant.
+ *
+ * **The first view is painted from INSIDE this callback (#539), and that ordering is deliberate.**
+ * Every documentation link carries `?v=` (`docs.rememberVersion`), and a link built before
+ * the build is known would carry no version until the next poll -- fifteen seconds of links that
+ * quietly do not say which build the reader is running, on exactly the first screen they see.
+ * `/api/config` is the one endpoint that opens no database, so this costs a single round trip on
+ * a loopback socket, and it cannot hang the app: `api.read` resolves with a stopped reading
+ * rather than rejecting, so `show` runs even with nothing listening on the port.
*/
-void read("config").then((reading) => buildLine(buildNode, reading.data));
-
-show(booted, false);
+void read("config").then((reading) => {
+ const config = reading.data;
+ rememberVersion((config && (config.build || config.version)) || "");
+ docsNode.href = indexUrl();
+ buildLine(buildNode, config);
+ registerWorker(config);
+ show(booted, false);
+});
diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js
index 64f7d11..dd7efe2 100644
--- a/keel/web/static/js/render.js
+++ b/keel/web/static/js/render.js
@@ -56,6 +56,7 @@
*/
import { equityChart } from "./chart.js";
+import { termLink } from "./docs.js";
import { instant } from "./format.js";
/**
@@ -158,7 +159,14 @@ function plain(value) {
*/
function kv(label, value) {
const wrap = el("div", "kv");
- wrap.append(el("span", "k", label));
+ // #539: a label that names a documented term becomes an outbound link to its definition,
+ // here rather than at each call site. `docs.TERMS` is the whole answer to "which words on
+ // this screen are defined somewhere", and a label it does not know stays plain text.
+ const linked = termLink(label);
+ const key = el("span", "k");
+ if (linked) key.append(linked);
+ else key.textContent = label;
+ wrap.append(key);
const holder = el("span", "v");
if (value instanceof Node) holder.append(value);
else if (typeof value === "string") holder.textContent = value;
diff --git a/keel/web/static/manifest.webmanifest b/keel/web/static/manifest.webmanifest
new file mode 100644
index 0000000..5c2f385
--- /dev/null
+++ b/keel/web/static/manifest.webmanifest
@@ -0,0 +1,38 @@
+{
+ "id": "/static/",
+ "name": "keel",
+ "short_name": "keel",
+ "description": "keel's read-only console, served from this machine only.",
+ "start_url": "/static/status",
+ "scope": "/static/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#fbfaf8",
+ "theme_color": "#1a5578",
+ "icons": [
+ {
+ "src": "/static/icons/keel.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/static/icons/keel-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/static/icons/keel-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/static/icons/keel-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/keel/web/static/sw.js b/keel/web/static/sw.js
new file mode 100644
index 0000000..a7c463c
--- /dev/null
+++ b/keel/web/static/sw.js
@@ -0,0 +1,172 @@
+/**
+ * keel's service worker (#538).
+ *
+ * ── THE ONE RULE, AND WHY IT IS STRUCTURAL RATHER THAN CAREFUL ──────────────────────────────
+ * A PWA that caches financial data is actively dangerous: opening the app to last week's equity
+ * styled as current is worse than an error, because an error is visible. So `/api/*` is
+ * `NetworkOnly`, "no exceptions" (the design spec's Service worker table).
+ *
+ * That rule is enforced three times over, and the first one is the reason to trust it:
+ *
+ * 1. **Scope.** This file is served from `/static/`, so its registration scope is `/static/`
+ * and `/api/*` is OUTSIDE it. A service worker's `fetch` handler is never invoked for a
+ * request outside its own scope -- not "is skipped", not "returns early": the browser does
+ * not consult it at all. No edit to this file can cache an API response, because no edit to
+ * this file can see one.
+ * 2. **`PRECACHE`, a closed list.** The only writes to the cache are `addAll(PRECACHE)` at
+ * install. There is no runtime `cache.put`, anywhere, so there is no code path by which a
+ * response fetched later becomes a stored one.
+ * 3. **An explicit guard in `fetch`.** Belt and braces for the day #540 moves the shell to `/`
+ * and the scope widens to the whole origin -- at which point rules 1 and 2 stop being the
+ * same protection and this becomes the one that holds. `tests/web/test_service_worker.py`
+ * pins that the guard exists, so it cannot be tidied away as dead code before then.
+ *
+ * ── THE CACHE NAME IS THE BUILD, AND THAT IS THE SECOND HAZARD ──────────────────────────────
+ * `CacheFirst` on the shell means an upgraded engine could otherwise be met by a stale client
+ * holding an older contract -- subtler than a stale balance, because everything renders and only
+ * the fields are wrong. So the cache name carries the build: `main.js` registers this file as
+ * `sw.js?v=`, a different byte sequence for the browser to compare, which is what makes
+ * an upgrade trigger an update at all. `activate` then deletes every cache that is not this
+ * build's, so an old shell is gone rather than merely unused.
+ *
+ * ── WHAT THIS BUYS, IN ONE SENTENCE ─────────────────────────────────────────────────────────
+ * With `keel serve` stopped, opening the installed app shows the shell and its own banner saying
+ * keel is not running -- rather than the browser's dinosaur, or the server's 403 page, which is
+ * what the same click gets today. It never shows a figure.
+ */
+
+/**
+ * This build's cache. Read from the registration URL's query string, which is the only channel a
+ * service worker has to its registrant that does not require the page to still be open.
+ *
+ * `"unknown"` is a real state, not a fallback nobody hits: a registration without `?v=` gets its
+ * own cache name and behaves correctly in every other respect. It is what a hand-typed
+ * registration in a console would produce, and it must not silently share a cache with a real
+ * build.
+ */
+const BUILD = new URL(self.location.href).searchParams.get("v") || "unknown";
+const CACHE = `keel-shell-${BUILD}`;
+
+/** The prefix this worker is allowed to touch, matching its own scope. */
+const BASE = "/static/";
+
+/** The path prefix that is never cached, never stored, never served from a cache. */
+const API_PREFIX = "/api/";
+
+/** The document every in-scope navigation resolves to -- `staticfiles.CLIENT_ENTRY`. */
+const SHELL = `${BASE}index.html`;
+
+/**
+ * Everything the app needs to paint with no network.
+ *
+ * A closed, hand-maintained list rather than a directory walk, because a service worker cannot
+ * walk a directory -- and `tests/web/test_service_worker.py` compares this list against the files
+ * actually present under `keel/web/static/`, so an asset added without a line here fails the
+ * build rather than producing an app that works until it is opened offline.
+ */
+const PRECACHE = [
+ SHELL,
+ `${BASE}manifest.webmanifest`,
+ `${BASE}css/keel.css`,
+ `${BASE}js/api.js`,
+ `${BASE}js/chart.js`,
+ `${BASE}js/docs.js`,
+ `${BASE}js/format.js`,
+ `${BASE}js/live.js`,
+ `${BASE}js/main.js`,
+ `${BASE}js/render.js`,
+ `${BASE}icons/keel.svg`,
+ `${BASE}icons/keel-192.png`,
+ `${BASE}icons/keel-512.png`,
+ `${BASE}icons/keel-maskable-512.png`,
+];
+
+/**
+ * Install: fill this build's cache, then take over immediately.
+ *
+ * `skipWaiting` rather than waiting for every tab to close, and the version key is what makes
+ * that safe: the new worker serves the new build's cache, the old one is deleted in `activate`,
+ * and a client that reloads gets a consistent set. Waiting would leave an upgraded engine being
+ * read by the previous shell for as long as one tab stayed open -- exactly the failure the
+ * version key exists to prevent.
+ *
+ * `cache: "reload"` on every request: the server sends `Cache-Control: no-store` on static
+ * assets (`server._STATIC_BASE_HEADERS`), but the HTTP cache is not the only thing between here
+ * and the file, and an install that populated itself from a stale intermediate would bake the
+ * staleness in for the life of the build.
+ */
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches
+ .open(CACHE)
+ .then((cache) => cache.addAll(PRECACHE.map((path) => new Request(path, { cache: "reload" }))))
+ .then(() => self.skipWaiting()),
+ );
+});
+
+/**
+ * Activate: delete every other keel cache, then claim open clients.
+ *
+ * Scoped to the `keel-shell-` prefix rather than deleting everything `caches.keys()` returns:
+ * this origin is keel's alone today, but a worker that deletes caches it did not create is a
+ * worker that will one day delete somebody else's.
+ */
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((names) =>
+ Promise.all(
+ names
+ .filter((name) => name.startsWith("keel-shell-") && name !== CACHE)
+ .map((name) => caches.delete(name)),
+ ),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+/**
+ * Fetch: the shell and its assets from this build's cache; everything else untouched.
+ *
+ * "Untouched" means `respondWith` is never called -- the browser then performs the request
+ * exactly as it would with no worker installed, which is the correct behaviour for `/api/*` and
+ * for anything this worker has no opinion about. Calling `respondWith(fetch(request))` instead
+ * would be a same-behaviour-looking rewrite that quietly drops streaming and changes how a
+ * failed request reports itself.
+ */
+self.addEventListener("fetch", (event) => {
+ const request = event.request;
+
+ // Only GET. A service worker sees no POST from this app -- `/setup/*` is a form on a rendered
+ // page outside this scope -- but responding from a cache to any non-GET is meaningless, and
+ // "meaningless" and "returns the wrong thing" are the same event here.
+ if (request.method !== "GET") return;
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+
+ // Rule 3 (see the module comment): never anything under `/api/`. Unreachable while the scope is
+ // `/static/`, load-bearing the moment it is not.
+ if (url.pathname.startsWith(API_PREFIX)) return;
+
+ if (!url.pathname.startsWith(BASE)) return;
+
+ // A navigation is a request for a VIEW, not for a file: `/static/insights` names no asset, and
+ // the server answers it with the shell (`staticfiles.resolve_client_route`). Matching that here
+ // is what makes a deep link work with the engine stopped.
+ if (request.mode === "navigate") {
+ event.respondWith(
+ caches.match(SHELL, { cacheName: CACHE }).then((hit) => hit || fetch(request)),
+ );
+ return;
+ }
+
+ // `ignoreSearch`, because `main.js` registers `sw.js?v=...` and a cache-busting query on an
+ // asset would otherwise miss a file that is present and identical.
+ event.respondWith(
+ caches
+ .match(url.pathname, { cacheName: CACHE, ignoreSearch: true })
+ .then((hit) => hit || fetch(request)),
+ );
+});
diff --git a/scripts/build_icons.py b/scripts/build_icons.py
new file mode 100644
index 0000000..9cbeb72
--- /dev/null
+++ b/scripts/build_icons.py
@@ -0,0 +1,261 @@
+"""Generate keel's app icons (#538) from one geometry, with no dependencies.
+
+The PWA manifest needs raster icons, and a repository that commits PNGs without saying where
+they came from has committed four files nobody can review or reproduce. So the mark is defined
+ONCE below as plain geometry, and both the SVG and every PNG are emitted from it:
+`scripts/build_icons.py --check` re-renders and compares bytes, which
+`tests/web/test_icons.py` runs on every build. A hand-edited PNG fails; a changed shape has to
+be changed here, where the change is readable in a diff.
+
+**Why hand-rolled rasterisation rather than Pillow.** keel's web surface ships zero JavaScript
+dependencies on purpose (the design spec's §2), and the same argument applies with more force to
+a build-time dependency that exists to draw three strokes: the whole rasteriser is a
+point-in-polygon test and a `zlib.compress`, both stdlib, and the output is deterministic across
+platforms in a way "whatever Pillow does with anti-aliasing this release" is not. Byte-identical
+output is what makes `--check` a test rather than a suggestion.
+
+Release tooling: deliberately NOT shipped in the wheel.
+"""
+
+from __future__ import annotations
+
+import argparse
+import struct
+import zlib
+from math import hypot
+from pathlib import Path
+
+#: Where the generated icons land, inside the served static tree.
+ICON_DIR = Path(__file__).resolve().parent.parent / "keel" / "web" / "static" / "icons"
+
+#: The two brand colours, taken from `keel/web/static/css/keel.css`'s light palette so the
+#: installed app's tile matches the page it opens. `--accent` (#1a5578) rather than `--fg`: the
+#: mark has to survive being shrunk to a 16px favicon and sitting on an unknown desktop
+#: background, and a near-black square is indistinguishable from every other near-black square.
+BACKGROUND = (0x1A, 0x55, 0x78, 0xFF)
+FOREGROUND = (0xFB, 0xFA, 0xF8, 0xFF)
+
+
+def _stroke(
+ start: tuple[float, float], end: tuple[float, float], width: float
+) -> tuple[tuple[float, float], ...]:
+ """A straight stroke of `width` as a four-point polygon, with butt caps.
+
+ The mark below is three strokes, so it is written as three strokes rather than as twelve
+ hand-computed corners: a letterform whose geometry is spelled out corner by corner is one
+ nobody can adjust later without re-deriving the perpendiculars by hand, and every adjustment
+ to a monogram is a nudge.
+ """
+ dx, dy = end[0] - start[0], end[1] - start[1]
+ length = hypot(dx, dy)
+ # A zero-length stroke has no perpendicular. It is a caller error rather than a shape, and
+ # returning an empty polygon here would silently drop a limb of the letter instead.
+ if length == 0:
+ raise ValueError("a stroke needs two distinct points")
+ nx, ny = -dy / length * width / 2, dx / length * width / 2
+ return (
+ (start[0] + nx, start[1] + ny),
+ (end[0] + nx, end[1] + ny),
+ (end[0] - nx, end[1] - ny),
+ (start[0] - nx, start[1] - ny),
+ )
+
+
+#: The mark: a lowercase `k`, in a 0..1 unit square with y increasing downward.
+#:
+#: **A monogram rather than a picture of a keel, and that was decided by looking.** Three
+#: nautical marks were drawn and rendered first -- a hull in section over a keel, a hull in
+#: profile with a fin, and a bulb keel -- and each one read as something else at icon size: a
+#: funnel, a letter T, and an exclamation mark on a saucer. A launcher tile is 32-48 CSS pixels
+#: on a background nobody chose, and at that size a silhouette gets one reading, which is not
+#: necessarily the one it was drawn with. The name is unambiguous at every size, and this
+#: application's whole argument is that a surface should say plainly what it is.
+#:
+#: Every coordinate is a fraction, so the SAME numbers render at 16px and at 512px. Sizes are
+#: not special-cased and there is no hinting: a mark that needs different geometry at small
+#: sizes is a mark with too much in it, and the fix is fewer shapes rather than more code.
+_STEM = _stroke((0.305, 0.115), (0.305, 0.885), 0.150)
+#: The arm and the leg meet at ONE point, and that point is INSIDE the stem rather than on its
+#: right edge: butt caps that meet on the edge leave a small wedge of background at the
+#: junction -- visible at 512px, and at 32px it reads as a broken letter rather than as a nick.
+#: Ending both strokes inside the stem lets the stem cover the joint.
+_JUNCTION = (0.335, 0.600)
+_ARM = _stroke((0.755, 0.300), _JUNCTION, 0.140)
+_LEG = _stroke(_JUNCTION, (0.775, 0.885), 0.140)
+
+SHAPES: tuple[tuple[tuple[float, float], ...], ...] = (_STEM, _ARM, _LEG)
+
+#: The safe-area inset a maskable icon is judged against. Android may crop a maskable icon to
+#: any shape inside the middle 80% -- a circle, a squircle, a rounded square -- so the mark is
+#: scaled to sit inside that circle rather than merely inside the square. Getting this wrong is
+#: invisible on the developer's own launcher and clips the icon on somebody else's.
+MASKABLE_SCALE = 0.72
+
+#: Samples per axis inside each pixel. 3 means nine coverage tests per pixel, which is enough to
+#: keep the letter's diagonals from stepping visibly at 192px and cheap enough that all four
+#: icons render in well under a second.
+_SUPERSAMPLE = 3
+
+#: What gets written, and what `--check` compares against. `any` and `maskable` are separate
+#: files rather than one file declared as both: a maskable icon has 20% padding by construction,
+#: so declaring it `any` too puts a small mark in a big box everywhere the safe area is not
+#: cropped -- the commonest way an install looks slightly wrong for no visible reason.
+TARGETS: tuple[tuple[str, int, bool], ...] = (
+ ("keel-192.png", 192, False),
+ ("keel-512.png", 512, False),
+ ("keel-maskable-512.png", 512, True),
+)
+
+SVG_NAME = "keel.svg"
+
+
+def _inside(polygon: tuple[tuple[float, float], ...], x: float, y: float) -> bool:
+ """Even-odd point-in-polygon. Ray-casts to the right and counts crossings.
+
+ The `!=` on the two comparisons is what makes a vertex on the ray count once rather than
+ twice or zero times -- the classic crossing-number test, kept verbatim rather than
+ "simplified", because every simplification of it drops a boundary case.
+ """
+ inside = False
+ count = len(polygon)
+ for index in range(count):
+ x0, y0 = polygon[index]
+ x1, y1 = polygon[(index - 1) % count]
+ if (y0 > y) != (y1 > y) and x < (x1 - x0) * (y - y0) / (y1 - y0) + x0:
+ inside = not inside
+ return inside
+
+
+def _blend(coverage: float) -> tuple[int, int, int, int]:
+ """Foreground over background at `coverage`, rounded half-up.
+
+ Composited here rather than left as a transparent foreground over a transparent background:
+ the icon is opaque by design (a manifest icon with alpha gets an arbitrary backdrop from
+ whatever is behind it), so every pixel is a straight mix of two known colours.
+ """
+ return tuple( # type: ignore[return-value]
+ int(back + (fore - back) * coverage + 0.5)
+ for back, fore in zip(BACKGROUND, FOREGROUND, strict=True)
+ )
+
+
+def render(size: int, *, maskable: bool) -> bytes:
+ """One icon as PNG bytes."""
+ scale = MASKABLE_SCALE if maskable else 1.0
+ offset = (1.0 - scale) / 2.0
+ shapes = tuple(
+ tuple((x * scale + offset, y * scale + offset) for x, y in shape) for shape in SHAPES
+ )
+
+ rows: list[bytes] = []
+ step = 1.0 / (size * _SUPERSAMPLE)
+ for row in range(size):
+ pixels = bytearray()
+ for column in range(size):
+ hits = 0
+ for sub_y in range(_SUPERSAMPLE):
+ y = (row * _SUPERSAMPLE + sub_y + 0.5) * step
+ for sub_x in range(_SUPERSAMPLE):
+ x = (column * _SUPERSAMPLE + sub_x + 0.5) * step
+ if any(_inside(shape, x, y) for shape in shapes):
+ hits += 1
+ pixels.extend(_blend(hits / (_SUPERSAMPLE * _SUPERSAMPLE)))
+ rows.append(bytes(pixels))
+ return _png(size, rows)
+
+
+def _png(size: int, rows: list[bytes]) -> bytes:
+ """RGBA8 PNG, filter 0 on every scanline.
+
+ No filtering (`0` = None) rather than the adaptive heuristic a full encoder uses: these are
+ flat-colour images where filtering buys a few hundred bytes, and a fixed filter is one fewer
+ thing for `--check` to have to reproduce identically.
+ """
+ raw = b"".join(b"\x00" + row for row in rows)
+ header = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
+ return b"".join(
+ (
+ b"\x89PNG\r\n\x1a\n",
+ _chunk(b"IHDR", header),
+ _chunk(b"IDAT", zlib.compress(raw, 9)),
+ _chunk(b"IEND", b""),
+ )
+ )
+
+
+def _chunk(kind: bytes, payload: bytes) -> bytes:
+ return b"".join(
+ (
+ struct.pack(">I", len(payload)),
+ kind,
+ payload,
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF),
+ )
+ )
+
+
+def render_svg() -> bytes:
+ """The same mark as SVG, for the manifest's `any` entry and anywhere a vector is better.
+
+ Emitted from `SHAPES` rather than hand-written beside it, so the vector and the rasters
+ cannot drift: a change to the hull that forgot the SVG would otherwise ship an icon that
+ disagrees with itself depending on which size the launcher picked.
+
+ `viewBox="0 0 1 1"` lets the unit coordinates go in verbatim. No `'
@@ -70,20 +69,13 @@ def test_a_failed_adapter_row_shows_the_error_and_not_the_placeholders() -> None
assert "WIRED" not in html
-def test_a_fiqh_term_that_fiqh_basis_does_not_state_says_so() -> None:
- """`stated=False` means fiqh-basis does not define the term and the glossary entry says that
- rather than substituting a help-authored summary. Losing that marker in a new front-end would
- turn a disclaimed gap into an apparent citation."""
- term = GlossaryTerm(
- term="something",
- definition="fiqh-basis does not state this.",
- source="",
- citation=None,
- fiqh=True,
- stated=False,
- )
- html = render.render_glossary([term])
- assert "not stated in fiqh-basis" in html
+# `test_a_fiqh_term_that_fiqh_basis_does_not_state_says_so` lived here and went with
+# `render_glossary` at #539. The property it protected did NOT go: the "not stated" disclaimer is
+# written into the definition text in `docs/glossary.md` itself -- which is why
+# `help_console.parse_glossary` can DERIVE `stated` from it (`stated = "not stated" not in
+# source.lower()`), and why `tests/commands/test_help_console.py` asserts it on the gharar entry.
+# A reader following the deep link lands on that prose. What was deleted is a renderer for a file
+# no installed deployment has ever had.
def test_utc_is_used_and_a_broken_timestamp_does_not_raise() -> None:
diff --git a/tests/web/test_server.py b/tests/web/test_server.py
index fa2051d..714f1a7 100644
--- a/tests/web/test_server.py
+++ b/tests/web/test_server.py
@@ -35,7 +35,6 @@
"/rules",
"/venues",
"/gates",
- "/glossary",
)
@@ -969,10 +968,19 @@ def test_the_printed_url_is_the_one_that_carries_the_token(
def test_the_nav_and_the_routing_table_agree() -> None:
"""A page with no nav entry is unreachable; a nav entry with no page is a 404 the user is
- invited to click. Neither is caught by testing either side alone."""
+ invited to click. Neither is caught by testing either side alone.
+
+ **The nav has one entry that is deliberately not a route (#539).** `Docs` links out to
+ keeltrading.com, because `docs/` has never shipped inside a wheel and the page that used to
+ render it was empty in every installed deployment. It is separated here by its scheme rather
+ than by its label, so a second outbound entry needs no edit and an internal entry that loses
+ its route still fails."""
from keel.web import render
- assert {href for href, _label in render.NAV} == set(web_server.ROUTES)
+ internal = {href for href, _label in render.NAV if not href.startswith("https://")}
+ outbound = {href for href, _label in render.NAV if href.startswith("https://")}
+ assert internal == set(web_server.ROUTES)
+ assert outbound == {render.DOCS_URL}, "an unexpected outbound nav entry"
assert set(ROUTES) == set(web_server.ROUTES), "this test module's list drifted from the server"