feat(web): the client shell — zero dependencies, no build step, served as authored - #549
Open
eaitbrahim wants to merge 1 commit into
Open
feat(web): the client shell — zero dependencies, no build step, served as authored#549eaitbrahim wants to merge 1 commit into
eaitbrahim wants to merge 1 commit into
Conversation
The first JavaScript keel ships. Seven more views inherit this shape (#537), so what matters here is the shape: plain ES modules served exactly as authored, no framework, no bundler, no transpile, no minification, no source maps, and nothing under `js/external/` — a directory this commit creates, because it did not exist before, along with the test that keeps it empty. WHERE IT MOUNTS, AND WHY NOT `/` Under `/static/`, because `/` and the seven paths beside it are still rendered in Python and are deleted at step 7 of the spec's build order (#540), not here. Serving this at `/` today would mean deleting a working page to install a shell with one view in it. The prefix is spelled in three files and a test pins that the three agree, so that move is a mechanical edit rather than a hunt. The router is the History API and nothing else. Hash routing was the alternative — it needs no server cooperation at all — and was rejected for putting a `#` in every URL an operator copies, when the cooperation it avoids is `staticfiles.resolve_client_route`: fifteen lines that serve the shell for a CLOSED list of seven names. Closed, not a wildcard, and the difference is not stylistic: with a wildcard a missing `.js` stops being a 404 and becomes a 200 of HTML that the browser then refuses to execute under `nosniff`, reported as a MIME-type error naming the module rather than as "that file is not there." PROVING `render` DERIVES NOTHING, RATHER THAN PROMISING IT The spec asks for `render` as its own file so a reviewer can confirm the absence of arithmetic by reading one file. Reading is the point; a gate is what stops the property decaying between readings. `_code_only` strips comments and string literals with a four-state lexer and the scan rejects `+ - * / %`, `++`, `--`, `Number`, `parseInt`, `Math`, `toFixed`, `NumberFormat` and friends. The lexer can be that small only because `render.js` contains no template literals and no regex literals — the two things that make lexing JavaScript genuinely hard. Both rules are written down in the file so nobody removes them as pointless style, and the second one turns out to buy more than it cost: with no template literals and no `+`, there is no way to build an HTML string, so the client has no `innerHTML` anywhere and therefore no escaping to get right and no injection sink to audit. `render.py` needs `esc()` and ninety disciplined call sites for the same property. THE ARITHMETIC SCAN ALONE WAS NOT ENOUGH, AND MUTATION IS WHAT SHOWED IT Appending `return v.value < 0 ? "bad" : "good"` to `render.js` passed the arithmetic test cleanly. No operator, no numeric identifier — and precisely the forbidden thing: a judgement re-derived in the client from a sign, which is what `payload.py`'s closed `state` vocabulary and #532's glyphs exist to make unnecessary. Two more rules close it, and the test that states them carries the mutation that motivated them: `render.js` never reads `Field.value`, and contains no relational comparison. Six other mutations (client-side money formatting, a template literal, `innerHTML`, a vendored module, a nudged palette value, a route added to one table only) were each confirmed to fail the suite. PARITY IS ON THE INFORMATION, NOT ON FIVE WRONG OUTPUTS Measured against the running code rather than read off the source: today's `render_status` reaches for five attributes that do not exist on the report dataclasses, through `getattr(..., default)` calls that swallow the mismatch. `/` currently prints autonomy "off" for a deployment placing orders unattended, prints rail 17 "fresh" in green for an EXPIRED withdrawal attestation, and renders every entry price, every live-rule name and both subscription columns blank. This view shows the values the API sends, which are the correct ones, and `statusView`'s docstring records each divergence. Reproducing them would mean writing code whose only purpose is to be wrong in the same way. ACCESSIBILITY, AND ONE PIECE OF RESTRAINT One `aria-live` region, on the engine banner, `aria-atomic` so it is heard as a sentence. Deliberately not on the data: this page re-reads itself every 15 seconds, and a live region around the tables would re-announce the whole dashboard twice a minute, which is not an accessibility feature. What changes MEANING underneath a reader is whether keel is running and when it was last read. Beside that: a skip link, `aria-current="page"` serving as both the assistive signal and the CSS hook so they cannot drift, focusable named scroll regions for tables, `scope="col"` headers, focus moved to `<main>` on a route change, one `:focus-visible` rule for everything, and a `<noscript>` that points at the server-rendered page rather than leaving a blank document. The palette is copied from `render.py` byte for byte and a test pins the copy, so the WCAG gate in `test_palette_contrast.py` transitively guards both. At #540 the copy becomes the original and that test is deleted. WHAT IS NOT HERE `tsc --noEmit` in CI. The annotations are written and verified clean under `tsc 5.9.3 --strict` locally, so the check can be switched on at zero code cost — but it would be the first Node dependency in a repository that is deliberately Python-only with no build step anywhere, and that is an architectural decision rather than a lint tweak. Costs are measured in the PR body; the call is the maintainer's. `keel/web/security.py` is a 0-line diff. Nothing here widens the write surface: every route added is a GET, behind the same host check and the same session cookie as every other response. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
| assert "<style" not in html | ||
| assert "<base" not in html | ||
| # A `<script>` with a `src` is the only permitted form. | ||
| for tag in re.findall(r"<script[^>]*>", html): |
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 #536. Unblocks #537, #538, #539.
The first JavaScript this project ships: 1,239 lines across four modules, zero third-party code, no bundler, no transpile, no minification, no source maps. What the browser runs is byte-for-byte what a reader sees in devtools.
static/js/main.jsstatic/js/api.jsfetchwrapperstatic/js/render.jsstatic/js/format.jsIntl.DateTimeFormat, dates onlystatic/css/keel.cssstatic/index.htmltests/web/test_client_assets.pyRouting
pushStateon click,popstatefor back/forward,location.pathnamethe only source of truth.Deep links and reloads work because
resolve_client_routeserves the shell for a closed list of seven names, not a wildcard. A wildcard turns a missing.jsinto a 200 of HTML that the browser then refuses undernosniff— surfacing as a MIME error instead of a 404. Hash routing was rejected: a#in every URL an operator copies."keel isn't running"
api.jsnormalises four outcomes into one shape, sorender.jshas no shape branches. HTTP 200 withdata: nullis never treated as an error —api.py's docstring warns about exactly that.A stopped engine keeps the server's
warn; a refusal or unreachable server gets a client-mintedbad. Same closed vocabulary, different judgement, which is whatstateis for.stoppedViewis reached only viadata === null, so zeros cannot render.Proving
render.jshas no arithmetic — and why the first attempt wasn't enoughA four-state lexer strips comments and strings, then rejects
+ - * / %,++,--,Number,parseInt,Math,toFixed,NumberFormat. It is small enough to be obviously correct only becauserender.jsbans template and regex literals — which incidentally makes building an HTML string impossible, so the client has noinnerHTMLat all.Mutation testing showed the scan was insufficient. This passed cleanly:
No operator, no numeric identifier — and exactly the forbidden thing, since
stateis a field precisely so the client never judges a value. Two further rules close it (render.jsnever readsField.value; no relational comparison), and the test carries the mutation that motivated them. Seven mutations verified to fail the suite.Accessibility
One
aria-live/aria-atomicregion, on the engine banner — and deliberately not on the data. A 15-second poll wrapped in a live region would re-announce the whole dashboard twice a minute.Plus: skip link,
<html lang>,aria-current="page"doubling as the CSS hook,role="region"+tabindex="0"+aria-labelledbyon table scrollers,scope="col",<main tabindex="-1">focused on route change, one:focus-visiblerule,<noscript>.Responsive
Three mechanisms, no fourth:
auto-fit/minmaxgrids (no breakpoint), two@mediabreakpoints (34rem, 22rem), and wide tables scrolling insideoverflow-x: autoso the document never scrolls sideways.tsc --noEmit— measured, and deliberately not addedtypescript@5.9.3: 1 package, 0 transitive deps, 23 MB, ~330 ms warm over 1,239 lines. It was run: the client is clean understrict: trueon the first pass, and the premise was asserted rather than trusted — three seeded errors (wrong argument type, atextContnettypo, a misspelled import) were all caught, baseline restored green.Not added to CI. The runtime cost is trivial; the architectural cost is not. A root
package.jsonends the property that "there is no build step" is enforced by there being no toolchain — a bundler afterwards becomes a line in an existing config rather than a dependency decision. A lockfile also becomes somethingsecurity.yml's Snyk job scans and Dependabot churns.The annotations ship either way, so it can be switched on at zero cost. If it is wanted, the cheapest shape is a separate optional job — the
mainruleset requires thetestcontext by name (#236), so adding a step there couples the merge gate to a Node install.#536's
tsccheckbox is therefore unticked, deliberately.Shared-helper disclosure
deploymentandrunningmoved fromtest_server.pyto a newtests/web/conftest.pybyte-for-byte — same names, same bodies, nothing gained a parameter or a default. All 52 existing call sites resolve to the identical fixture through normal conftest discovery. (Stated explicitly because #545's rework was caused by a shared helper that gained a default and silently exempted every existing test.)Parity is on the information, not the output — see #548
Literal parity with
render.pywould have meant shipping five wrong values.render_statusreads report fields throughgetattr(obj, name, default), and five of those attributes do not exist, so the default renders silently — including autonomy printing "off" unconditionally and an expired rail-17 attestation rendering as fresh.Filed as #548. This view uses the API's correct values and records each divergence in
statusView's docstring.Not tested, and how to check by hand
No browser here, and no headless one added — that is the same Node decision. Uncovered, and listed in the test module's docstring: nav click routing without a page load; back/forward; focus landing on
<main>; a screen reader announcing the banner; the layout at narrow widths; the poll firing at 15 s and pausing on a hidden tab.By hand:
keel serve, open/static/, tab through (skip link first, focus ring on every stop), click each nav item then press Back, drag to ~320 px and confirm only tables scroll sideways, watch Network for 30 s, switch tabs and confirm requests stop.Scope note for #537
Six views remain, not seven. Eight nav entries exist today, but
/glossarybecomes an outbound keeltrading.com link at #539 and is deleted at #540. #537's scope needs reconciling.Gates
ruff check keel tests packages— All checks passedmypy— cleanpytest -q— green, 44 new tests🤖 Generated with Claude Code
https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6