feat(web): installable, with a worker that structurally cannot cache a balance (#538) - #551
Merged
Conversation
…a balance (#538) Manifest, icons and a service worker, so `keel serve` installs as a desktop app and opens to the shell -- and its own banner -- when the engine is not running. ── THE API RULE IS ENFORCED BY SCOPE, NOT BY CARE ───────────────────────────── A PWA caching 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. The spec routes `/api/*` `NetworkOnly`, "no exceptions", and the implementation makes that structural rather than disciplined: `sw.js` ships at the STATIC ROOT, so its scope is `/static/` and `/api/*` is outside it. A worker's `fetch` handler is never invoked for a request outside its own scope -- not skipped, not returned early from: the browser does not consult it. No edit to that file can cache an API response, because no edit to that file can see one. The only cache write in the module is `addAll(PRECACHE)`; there is no runtime `put` anywhere, and a test pins that there never is. The explicit `/api/` guard in `fetch` is therefore dead code today, and is pinned by a test that says so -- #540 widens the scope to `/` and it stops being redundant on the same day nobody is thinking about it. This is where the implementation departs from the spec's file list, which puts `sw` among the modules in `js/`. `js/sw.js` is scoped to `/static/js/` and cannot answer a navigation to `/static/insights`. 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. The spec is amended in place rather than quietly diverged from. ── THE ICONS ARE GENERATED, BECAUSE FOUR BINARY BLOBS ARE NOT REVIEWABLE ────── `scripts/build_icons.py` defines the mark ONCE as three stroke coordinates and emits the SVG and all three PNGs from it, with a stdlib rasteriser -- point-in-polygon and `zlib.compress`, no Pillow. `--check` re-renders and compares bytes, and a test runs it, so a hand-edited PNG fails and a deliberate change has to be made where it is legible. The mark is a monogram, 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 at icon size each one read as something else: a funnel, a letter T, and an exclamation mark on a saucer. A tile is 32-48px on a background nobody chose, and at that size a silhouette gets one reading, not necessarily the drawn one. ── ONE ATTRIBUTE DECIDES WHETHER ANY OF THIS INSTALLS ───────────────────────── `<link rel="manifest">` is fetched with credentials mode "omit" by DEFAULT, and every response this server sends is gated on the session cookie. Without `crossorigin="use-credentials"` the manifest fetch is a 403, the browser reports no manifest, and the app is simply not installable -- with nothing in the page or the console pointing at the cause. Measured, not assumed: with the worker unregistered, a credential-less fetch of the manifest returns 403 and a credentialed one returns 200. The cache name carries the build from `/api/config`, registered as `sw.js?v=<build>`. `encodeURIComponent`, because `keel.version` produces `0.11.2+88fb17bcab15` and a raw `+` in a query string decodes to a SPACE -- the cache would silently stop tracking the binary it exists to track. A failed config read registers NOTHING: with the engine stopped the installed worker is the thing letting the operator read the page, and re-registering it under `unknown` would swap a correct cache for an empty one exactly when the network cannot refill it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
… are checked (#539) (#552) Every documentation reference is now an outbound keeltrading.com link. The app fetches, bundles and caches nothing, and `/glossary` -- a page that has been EMPTY in every installed deployment keel has ever shipped -- is deleted. ── THE SHIPPED BUG THIS CLOSES ──────────────────────────────────────────────── `docs/` lives at the REPOSITORY root, outside `keel/`, and `uv_build` packages the module root. No wheel has ever carried it. `help_console.load_glossary` says so in its own docstring: "an installed deployment has no docs/ checkout, and the help screen renders that notice as its empty state." It is not fixable with a packaging glob -- measured at #535, building with `artifacts` set and with `artifacts = []` produces byte-identical wheels. Linking out is not the cheaper option here; it is the only one that reaches an install. ── A BROKEN DEEP LINK IS NOT AN ERROR, SO A TEST HAS TO BE ──────────────────── `…/glossary/#rial` opens the glossary at the TOP of the page, looking exactly like a link to a term that happens not to scroll, and nothing anywhere reports it. keel's `docs/` is the source and the site is the mirror, so this repository is the only place a rename and the links depending on it can be compared -- and `tests/web/test_doc_links.py` is that comparison, in both directions: * every `(slug, anchor)` the client can emit resolves to a heading that exists in the named document in `docs/`; * every LABEL in the table is one a view actually puts on screen, because `kv` links by lookup -- a label renamed in `render.js` would silently stop being a link rather than break. ── THE TABLE IS KEYED BY LABEL, AND IS DELIBERATELY INCOMPLETE ──────────────── `kv` consults `docs.TERMS` for every pair it builds, so a label that names a term is a link wherever it appears and no call site knows. The alternative -- tagging each site with a term -- spreads the decision across six views and makes "which words on this screen are defined somewhere" unanswerable without reading all of them. Two labels are left out on purpose and pinned as such. `mode` reads `paper` or `live`: one label, two definitions, no way to pick. `evidence required` sits on the CAPABILITY gates, not the promotion gate, so `#promotion-gate` would be confidently and invisibly wrong. A missing link costs a reader one search; a wrong one costs them their trust in every other link on the page. ── THE FIRST PAINT WAITS FOR THE BUILD, AND `?v=` IS NOT `build` ────────────── `show()` moved inside the `/api/config` callback: a link built before the build is known carries no version until the next poll -- fifteen seconds of links that do not say which build the reader is running, on the first screen they see. `/api/config` opens no database, and `api.read` resolves rather than rejecting, so this cannot hang the app with nothing on the port. The rendered nav links out too, and its first spelling put `cfg.build` in the query -- which is the footer's human-readable LINE, not a version. It rendered `?v=keel%200.11.2%2Bc1634a3fa17f%20%28DIRTY%29%20%5Bcheckout%5D`. Caught by reading the href of a running server, not by a test: both are non-empty strings and every assertion about "the link carries a version" passed. `server._docs_version` now reads `full_version`, the same field `/api/config` hands the client, and a test passes both strings so the two can never be confused again. Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
force-pushed
the
feat-538-pwa
branch
from
August 25, 2026 19:02
1507463 to
6d3688a
Compare
This was referenced Aug 25, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #538. Stacked on #550 (
feat-537-remaining-views) — that PR was still open when thisbranched, so review it after #550 lands; I'll rebase onto
mainthen.Manifest, icons and a service worker, so
keel serveinstalls as a desktop app and opens to theshell — and its own honest banner — when the engine is not running.
The API rule is enforced by scope, not by care
sw.jsships at the static root, so its registration scope is/static/and/api/*isoutside it. A worker's
fetchhandler is never invoked for a request outside its own scope — notskipped, not returned early from: the browser does not consult it. No edit to that file can cache
an API response, because no edit to that file can see one. Two further layers back it up: the only
cache write in the module is
addAll(PRECACHE)(no runtimeput, pinned by a test), and anexplicit
/api/guard infetch— dead code today, pinned by a test that says so, because #540widens the scope to
/and it stops being redundant on the day nobody is thinking about it.This is where the implementation departs from the spec's file list, which puts
swamong themodules in
js/.js/sw.jsis scoped to/static/js/and cannot answer a navigation to/static/insights. The alternative — aService-Worker-Allowedheader widening the scope fromjs/— was rejected for failing silently: remove the header and the worker still installs, stillactivates, and simply stops controlling the app. The spec is amended in place rather than quietly
diverged from.
The icons are generated, because four binary blobs are not reviewable
scripts/build_icons.pydefines the mark once as three stroke coordinates and emits the SVG andall three PNGs from it, with a stdlib rasteriser — point-in-polygon and
zlib.compress, no Pillow.--checkre-renders and compares bytes, and a test runs it, so a hand-edited PNG fails and adeliberate change has to be made where it is legible in a diff.
The mark is a monogram, 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, a bulb keel — and at
icon size each read as something else: a funnel, a letter T, and an exclamation mark on a saucer.
One attribute decides whether any of this installs
<link rel="manifest">is fetched with credentials mode omit by default, and every responsethis server sends is gated on the session cookie. Without
crossorigin="use-credentials"themanifest fetch is a 403, the browser reports no manifest, and the app is not installable — with
nothing in the page or the console pointing at the cause.
Verification
ruff,mypyand the full suite are green (4854 passed, 3 skipped), and 28 new tests cover theshipped bytes and the wire. Two of them were mutation-checked: dropping
chart.jsfromPRECACHEand adding an unlisted asset both fail
test_every_shipped_asset_is_either_precached_or_exempt.The rest is what Python cannot prove, driven in a real Chromium against a snapshot of the live
155 MB deployment database:
activated, scopehttp://127.0.0.1:8793/static/keel-shell-0.11.2+88fb17bcab15— the+survived, as%2Bon the wire/api/entry/static/insights0.11.2+…→0.12.0+…/api/entryChrome's installability checklist is satisfied item by item — localhost is a secure context, name
and
short_namepresent,start_urlinsidescopeand on a real client route,display: standalone, a 192px and a 512px PNG plus a separate maskable, and a worker with afetchhandler.I did not trigger the install prompt itself, and Safari is not verified here — recorded as
unverified rather than assumed, per the issue's "Safari behaviour recorded either way".
Packaging needed no change and is already covered:
test_static_assets_survive_being_built_into_a_wheelwalks the static tree against a realuv build, andtest_freeze.pypinskeel.web.staticinto the PyInstaller bundle — so the icons shipwith the desktop build for the same reason the stylesheet does.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2