From f7ddf5e27e8f38c754a5433a62b4a1e821b37e77 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 25 Aug 2026 10:52:19 -0400 Subject: [PATCH 1/2] feat(web): installable, with a worker that structurally cannot cache a balance (#538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ───────────────────────── `` 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=`. `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) Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2 --- .../specs/2026-08-23-web-ui-rewrite-design.md | 7 +- keel/web/static/icons/keel-192.png | Bin 0 -> 1300 bytes keel/web/static/icons/keel-512.png | Bin 0 -> 4315 bytes keel/web/static/icons/keel-maskable-512.png | Bin 0 -> 3649 bytes keel/web/static/icons/keel.svg | 1 + keel/web/static/index.html | 16 + keel/web/static/js/main.js | 39 +- keel/web/static/manifest.webmanifest | 38 ++ keel/web/static/sw.js | 171 ++++++++ scripts/build_icons.py | 261 +++++++++++ tests/web/test_client_assets.py | 10 +- tests/web/test_pwa.py | 409 ++++++++++++++++++ 12 files changed, 948 insertions(+), 4 deletions(-) create mode 100644 keel/web/static/icons/keel-192.png create mode 100644 keel/web/static/icons/keel-512.png create mode 100644 keel/web/static/icons/keel-maskable-512.png create mode 100644 keel/web/static/icons/keel.svg create mode 100644 keel/web/static/manifest.webmanifest create mode 100644 keel/web/static/sw.js create mode 100644 scripts/build_icons.py create mode 100644 tests/web/test_pwa.py 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/static/icons/keel-192.png b/keel/web/static/icons/keel-192.png new file mode 100644 index 0000000000000000000000000000000000000000..df63420dfd46fe5f6dbdb58a17a18443b3586203 GIT binary patch literal 1300 zcmb`HdrVVz6vxl+_OTAEX`x70OSyqXK?#(t*?=KjHV{#)AOyQAC z-+OL;wUHoQzx4Tac8(%lydha;^rXwjnF1_$)*U*~Ww5WvpWhkXDh`yRjg(BHpkemb&F#Q?gYsN}B?Ph8DP z#`DS9qAi$-?)n^n&QB#8+nP`yFBf!DHVkODJg6Mu=D>$KH;^^jI8% zJqCv{c+4*SgV5u9WFAx+Y!D1p9Z3Zdq4?UFk15j&^xJ}=9}|y&4DISQJ7#hOG#lip zTC2oBqzLWDYjc0Vyq<_iyyhyz5^*pZ(lLY$1TobOX2)-s4}cx3I~+TbSN~(xDy|`< z;q*_nxz$^@!wOc)QD;WUuSmTc2K5g{ZqNGo-i0O(HE4bVSA6MT>MSlat^*PhDe?%V@JF6#@yq^twebzztq{7WkqYR4jr|6&Y9&uomDXxEsNL zThOuHt~}kYycS(4KvD!cBk;h)n7a?YjazJ5h+zHR$sp$?b{UZKD+-etmNy{&A@Qw0 z*rVF41k6R#8la7wlHNX7D~|m2XG6Kv+|JZw2Ami6$iRMruQZ`M!{=@U17H>Ei!>lZ z`++wm039wkM=_{vCPW@X(38EVsH^cZ?&M{kU07qI{En4-%3zw1Ue6XOET9g9&j!@u z$hRzRqF7YdvvL6NFvsd@ElUT;M+BE;EAQin7CxQl0e2C02TzkCtcI7s*)9*?yKpAc zgrt#luDgzBm51L;4Tg^)-9|01kCT*xPHAbHY8TRGShcaT`GXo1v{XH|0-LAC%pNND7|$1GT1#KYswq&hR7?dLw^uSR?R6_ zRKnERi-;`QRM}l01DC%a6ZmEB_oT=3%5My-8kkgmro}noWYFSC1@qZInvX^hH7>l= T_V{KF-iDBrAdmm@b#=wxNOiE; literal 0 HcmV?d00001 diff --git a/keel/web/static/icons/keel-512.png b/keel/web/static/icons/keel-512.png new file mode 100644 index 0000000000000000000000000000000000000000..fae018d52aec7898aa961c291201b30f68d03e8a GIT binary patch literal 4315 zcmbVPeNa^Q6}|86vMl1_Qw$JF!V;5eThz6#QPL_af*&oSZJd#08bE0V7>&~4Oo+nn z6PbuvQj@fkQo{&ht23QZuy)$oL3kmKgLMko(iuOhvWgR!7zK5W$gtRRe{YwM0X6oo zecX5Nx#ygF-|s#4tj$Q<#LT~~bZ=H+=? zUcDTpdMbAn*en+(OY=ffKl=FnX?x~aBDEQ{wRN?BI9QibNlT}0`|#*=>&lnUe|hEZ zk&O%1^lq)ZS+ePrCJ#=_5_P75Yf} z2&ai+3RQQ0o;P=EQ>nj2$~yUO_eq19j4BH-Ly;A~GBjBsQpGR8B$dU|oVK z^>GDO8eAKV5{4lo!;q}G8T$W*d?W{1=g8oMYwK`BlK7fr$CLR!5`4TP>=H?%x;?!+ zPwu9kSX_MP(hc3L@83$^KG0SA%9Z-{=YCWlCgw0}@W{ii!|ll@^H$4ZZrZ7=ud_NI z?0;^l_uf)5yvSbFHITx1{Povj?7QIk*#6cz6GUto=#%@~yHK?T-ISR`*2t8-F(ymSpcs zjrK=e^*D@7+UBXNSz+L~w;yyQJQDf_*3QT`vt zRv_6-bvgtHkw=FB4b`hdfQH)s0W)uERp_@PRb_7Hl2E8o=PnrmP(N}7#DQqv1&zgJ zzilSBv)H~?SQq8*%dZs%Cl2e$^xeI{Yg%&Vs=t>_Xrd6(JMkuuNemd%Fzl9h@hwpj z$0a3MEGRo4h=Ih!0iFQcn(6yI;C0`U(}GyFPE3K(3X1zs!Zc7a|3&t^v7u-83PT7X z>OJ{BUp1}cE+YznEhZwc#l#wV@}AYFbY-u7$lp1wmbB=j)b}^1+nLZ+^G;*0z{~U< z?}^^-Yj3_EPDboOUG~>Sx!33N@U+&>x|+0P!MV77gJ%jX&XsaJQ`}CmGawbXi>`I? zqHE%S$V2&+^_jk9vTbUCzk4ztkwK6Em@)d15+nKA_G`yBY3vg#Y3wjaTv-x8mh1R zO@?yTmdhG@59w;=Xf8phl+jRwzt0q`#y}0TiW+7$2DJF{>;a}aCOAsKOdeWQ@L)$CXdi6XVL8&J-BS!F-rP;()}5QCc$d1|L4JqWmP`&FW}j z62<@t0oiDtP{H_ee)xtJeA}LZA|_BWhbe#$6JS114d_~M=l+n1H@WAl?cIwb_@Q)} zV4|5i#EUgQ-Ec_)x~#qA;AVx-D10ua8<0pjw+hrvJK6L8Qi){P6^naw%Sf{eh%fh> z7FmsaP#3kZo8D?0&#TG=t&(a;86PWvzY2*WBejJYI4=;JIhkFLFpEj(c%q#ibCAHp z1~CPR4oM7D`0oPsrKPNGa|hd0Y&_P^n1?tgf-UA*sltjff`Kn^Q_ zWysU^td58Tffgwz7I<850q?{Ps^CFbwp7dX3JkX?AW52 ziK2+*l*|c$>8_ds%DHKT`Ks{73LKk-p9&+yygJ*w4^O#K#luk%vxZMcTxP`7ECi4i z{c!;;e;ACNbp;dON|t*{=WtgnNN(kj_%_Lj&GGHUDEWk`91@?bSdT!tmUQ)IY-C*T z8^VAp)2aOr?(ctw6=V;+GKCq(by}ha$>#nc_sVr!0!>HPM`jPo^BXCq<8cMr7UQXze;ClX~8+ajsHrit&dN4Sg0h#r$Bk z3wLImzsP2wY;&x4p+Af$!*0pE!LlUM_8hsb_w%OjoqY67*gv{aVW48@cu_pl#(%Is z*k`Klf~U=Rxsvg)wEUs0g-Z?B3bgM(0g2>!L8FL!I|ce56#5PcvAGQ4tPI v_=xH7zrh?+aRq8t{5<-W19u;vzR2CXGQ#O!v$LxdxwI-RBlT!XPTBte%n?AW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c84478d3f401b790bf4ebddb0d4d91ba9581d3e GIT binary patch literal 3649 zcmds4ZBSHY6h3$Nx_dIl z+e=?p>edpwv8Ku^Z+XF12O=^Pa=SV7Xj|@=pM^eC*1DyArZqU;{(?#>W$Gt_L8S?Zj20xcso>Rs zvr_OG!z&I>V3el;1m#wM$N>@u5Sxg91R#2uS`eFh-}IWE+tH&Q0d-M7Y5>}5B$EfyZg$aMP=AuftMan zZVO&p8dDn4VU0yG38#+)>-IEG2%K%0Lr@ zvr#?Q&QBG{WcI$c<#UEv($I{mb*L5=~4W&ziq4-BMLdd8mcDf?obUQKH^bUqVGXzd;2VWz0=QA2+`H zylrquw4gwopC^D7vwd}BPh?QtR!*LA=niVw{B&p)J@d)(RRwb`Ntr|L^~Xsj+{oVd z_NLcvx&pJsG)j%7*KWP)&x6p7y|mNJ_k2{(OGCT2d=OSUPN#Im5YN1`Q9W_~)i_TH zHk3s5s2YiVLkTrBxLrHb?K)y*KRjdZF|Sb_&Fy{C>x#i)sBviQS>KZwyVQ@RBU*wn zqCts*sGhH4h%>*oBOWV^C?-@v{59x(foCAnDub|86H-rI?bjJ-uUjTksc(Li)khnG^4v|W*q_6QMnb?npO^h&+2o?L*80)S3F zslKHNU>>NNP7JBmuYXm4bNeo`thBFMem==Wd%FO)qY4*>_1iZ{qWcDeKpWVGPUniE zns4RR;!Sxh?Lf`{zi(E)?`Q=8BTFV zNIc@Dhp4l|ih+qxg$b#^iF)g_wZfAH?2!~QSs7`fakV-Hijx#d5G@;M&FthJwY6*` zzY$_dcVX@jstc`6oKHlBszhT#KAPkSBvK&3LJ=d83V2Yz$vShe{IjKp6u>jra&CNJ zQ%i74@u(dpMjI`>C76+8%$;E2L^)j;pM&f1gpxGlf444`n>rVza-i8bz{K!i>K3aZE(`rWa zo}H#d#fj4`oT2yab*J@x(TafyvIQqHw6Cvb&;$FEE!86yY9#}(B!?Sv3Es+Tt>ipr zj-g@diWT?7c9VkAoMf%lNndV<1cwzj1id-QT4iJgy*bUe=c@P@C|b*HveDGgT9ptX z#cI|j&e!o?wzU8y623BWZiCJJw!%*dk(n?j8DWnEMli%lIQcl!8vd!(xgZ$Hk|;FV z==ELK?Hu+@63*<%$_PYu98LUo;{08uyu=yvkj;|d&qU31o`P57S2sUNX9=?(^-Wt0 z?-U_*zwDr$h^`#Ifgv3m11TJ3Ow9<=xD1X+voqURV()D4(PW)~b3S_YD)7V!_OlRs z&R5Pw=9`@x1N{5Y9kV4os!GTR@|MbYT?*6`Fps8O8YcgiQ_7z^8VlT~&b`o_DFJJ; zIc`jt^v4E({Vg`fB8?Hula+mIHK_Dzth@JW4fs3Utf$GEvTw`oF8EI>Y)DFL%0rKzjR zE`s`s)w?5r|BAj7-#_pxJ6;i$NLnk7=d?OEp?BS&VzbQNCh99?_PhG+Ap!=EyBlyM r7^wa$g5jszKf*8V|L4z}h?cOd(UbD_ diff --git a/keel/web/static/index.html b/keel/web/static/index.html index 8df55e6..52b1923 100644 --- a/keel/web/static/index.html +++ b/keel/web/static/index.html @@ -38,6 +38,22 @@ to a declared base. --> + + + + + diff --git a/keel/web/static/js/main.js b/keel/web/static/js/main.js index 4d52cfa..be84587 100644 --- a/keel/web/static/js/main.js +++ b/keel/web/static/js/main.js @@ -584,6 +584,40 @@ if (window.location.pathname !== pathFor(booted)) { window.history.replaceState(null, "", pathFor(booted)); } +/** + * 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 footer's build line, read once. * @@ -592,6 +626,9 @@ if (window.location.pathname !== pathFor(booted)) { * 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. */ -void read("config").then((reading) => buildLine(buildNode, reading.data)); +void read("config").then((reading) => { + buildLine(buildNode, reading.data); + registerWorker(reading.data); +}); show(booted, false); 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..82507f5 --- /dev/null +++ b/keel/web/static/sw.js @@ -0,0 +1,171 @@ +/** + * 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/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"