Skip to content

feat(web): installable, with a worker that structurally cannot cache a balance (#538) - #551

Merged
eaitbrahim merged 2 commits into
mainfrom
feat-538-pwa
Aug 25, 2026
Merged

feat(web): installable, with a worker that structurally cannot cache a balance (#538)#551
eaitbrahim merged 2 commits into
mainfrom
feat-538-pwa

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #538. Stacked on #550 (feat-537-remaining-views) — that PR was still open when this
branched, so review it after #550 lands; I'll rebase onto main then.

Manifest, icons and a service worker, so keel serve installs as a desktop app and opens to the
shell — and its own honest banner — when the engine is not running.

The API rule is enforced by scope, not by care

sw.js ships at the static root, so its registration 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. Two further layers back it up: the only
cache write in the module is addAll(PRECACHE) (no runtime put, pinned by a test), and an
explicit /api/ guard in fetch — dead code today, pinned by a test that says so, because #540
widens 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 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 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 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 not installable — with
nothing in the page or the console pointing at the cause.

Verification

ruff, mypy and the full suite are green (4854 passed, 3 skipped), and 28 new tests cover the
shipped bytes and the wire. Two of them were mutation-checked: dropping chart.js from PRECACHE
and 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:

check result
worker installs, activates, controls activated, scope http://127.0.0.1:8793/static/
cache name carries the build keel-shell-0.11.2+88fb17bcab15 — the + survived, as %2B on the wire
precache contents exactly the 13 declared files; no /api/ entry
manifest without credentials 403 (worker unregistered, so the server answered)
manifest with credentials 200
engine stopped, deep link to /static/insights shell paints, banner reads "keel isn't running — the local server at this address did not answer", nav intact, zero currency figures on screen
build bump 0.11.2+…0.12.0+… old cache deleted, new cache created with 13 entries, no /api/ entry

Chrome's installability checklist is satisfied item by item — localhost is a secure context, name
and short_name present, start_url inside scope and on a real client route, display: standalone, a 192px and a 512px PNG plus a separate maskable, and a worker with a fetch handler.
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_wheel walks the static tree against a real uv build, and test_freeze.py pins keel.web.static into the PyInstaller bundle — so the icons ship
with the desktop build for the same reason the stylesheet does.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2

Base automatically changed from feat-537-remaining-views to main August 25, 2026 18:31
eaitbrahim and others added 2 commits August 25, 2026 14:57
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PWA: manifest, icons, and a service worker that never caches /api/*

1 participant