Skip to content

feat(web): the client shell — zero dependencies, no build step, served as authored - #549

Open
eaitbrahim wants to merge 1 commit into
mainfrom
feat-536-client-shell
Open

feat(web): the client shell — zero dependencies, no build step, served as authored#549
eaitbrahim wants to merge 1 commit into
mainfrom
feat-536-client-shell

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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.

file role lines
static/js/main.js entry point, History API router, poll 312
static/js/api.js the single fetch wrapper 231
static/js/render.js DOM from payloads; no arithmetic 587
static/js/format.js Intl.DateTimeFormat, dates only 109
static/css/keel.css palette + responsive layout 318
static/index.html shell, landmarks, the one live region 103
tests/web/test_client_assets.py 44 tests 708

Routing

pushState on click, popstate for back/forward, location.pathname the only source of truth.

Deep links and reloads work because resolve_client_route serves the shell for a closed list of seven names, not a wildcard. A wildcard turns a missing .js into a 200 of HTML that the browser then refuses under nosniff — 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.js normalises four outcomes into one shape, so render.js has no shape branches. HTTP 200 with data: null is 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-minted bad. Same closed vocabulary, different judgement, which is what state is for. stoppedView is reached only via data === null, so zeros cannot render.

Proving render.js has no arithmetic — and why the first attempt wasn't enough

A four-state lexer strips comments and strings, then rejects + - * / %, ++, --, Number, parseInt, Math, toFixed, NumberFormat. It is small enough to be obviously correct only because render.js bans template and regex literals — which incidentally makes building an HTML string impossible, so the client has no innerHTML at all.

Mutation testing showed the scan was insufficient. This passed cleanly:

return v.value < 0 ? "bad" : "good"

No operator, no numeric identifier — and exactly the forbidden thing, since state is a field precisely so the client never judges a value. Two further rules close it (render.js never reads Field.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-atomic region, 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-labelledby on table scrollers, scope="col", <main tabindex="-1"> focused on route change, one :focus-visible rule, <noscript>.

Responsive

Three mechanisms, no fourth: auto-fit/minmax grids (no breakpoint), two @media breakpoints (34rem, 22rem), and wide tables scrolling inside overflow-x: auto so the document never scrolls sideways.

tsc --noEmit — measured, and deliberately not added

typescript@5.9.3: 1 package, 0 transitive deps, 23 MB, ~330 ms warm over 1,239 lines. It was run: the client is clean under strict: true on the first pass, and the premise was asserted rather than trusted — three seeded errors (wrong argument type, a textContnet typo, 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.json ends 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 something security.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 main ruleset requires the test context by name (#236), so adding a step there couples the merge gate to a Node install.

#536's tsc checkbox is therefore unticked, deliberately.

Shared-helper disclosure

deployment and running moved from test_server.py to a new tests/web/conftest.py byte-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.py would have meant shipping five wrong values. render_status reads report fields through getattr(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 /glossary becomes 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 passed
  • mypy — clean
  • pytest -q — green, 44 new tests

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6

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):
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.

Client shell: router, status view, responsive layout, accessibility baseline

2 participants