diff --git a/keel/web/server.py b/keel/web/server.py index 2ef60ca..fa31f7d 100644 --- a/keel/web/server.py +++ b/keel/web/server.py @@ -646,8 +646,18 @@ def _serve_static(self, url_path: str) -> None: `ROUTES` path gets -- containment and the Content-Type table are `staticfiles`'s job (`tests/web/test_staticfiles.py` pins the resolver in isolation); this method's only responsibility is refusing anything it returns `None` for, uniformly, so a missing - static file and a missing page look identical to a client probing the server.""" + static file and a missing page look identical to a client probing the server. + + **A file wins over a client route, always** (#536). `resolve_client_route` is consulted + only where `resolve_static_asset` found nothing, so no name in `CLIENT_ROUTES` can shadow + a shipped asset -- and, more importantly, the reverse cannot happen either: a `.js` file + that is missing or misspelled stays a 404 rather than becoming a 200 of HTML that the + browser then refuses to execute under `nosniff`, which is a MIME-type error several steps + removed from its cause. See `CLIENT_ROUTES`'s own note on why that list is closed. + """ resolved = staticfiles.resolve_static_asset(staticfiles.STATIC_ROOT, url_path) + if resolved is None: + resolved = staticfiles.resolve_client_route(staticfiles.STATIC_ROOT, url_path) content_type = staticfiles.content_type_for(resolved) if resolved is not None else None if resolved is None or content_type is None: self._refuse(404, "No such page", f"Nothing is served at {url_path}.") diff --git a/keel/web/static/css/keel.css b/keel/web/static/css/keel.css new file mode 100644 index 0000000..20e72b1 --- /dev/null +++ b/keel/web/static/css/keel.css @@ -0,0 +1,318 @@ +/* keel's client stylesheet (#536). + * + * Served exactly as authored: no preprocessor, no autoprefixer, no minifier. The file a reader + * opens in devtools is this file, byte for byte, which is the same property the JavaScript is + * held to and for the same reason (§4 of the design spec's Philosophy). + * + * ── THE PALETTE IS A COPY, AND A TEST PINS THAT IT STAYS ONE ────────────────────────────────── + * + * Every `--token: #hex` below is byte-identical to the corresponding declaration in + * `keel/web/render.py`'s `_STYLE`, which is where #532 chose these values and where + * `tests/web/test_palette_contrast.py` measures their WCAG ratios. A copy is a second source of + * truth, so `tests/web/test_client_assets.py::test_the_client_palette_is_byte_identical_to_the_ + * rendered_one` parses BOTH stylesheets with that test module's own parser and fails on any + * divergence -- which means the contrast gate that guards render.py's palette transitively + * guards this one, without a second copy of the WCAG arithmetic. + * + * REJECTED: extracting the palette to a shared file that `render.py` reads at import time and + * inlines into `_STYLE`. It would remove the duplication, but it adds a file read to module + * import for a module the build order (§"Build order", step 7 / #540) deletes outright, and it + * would put the stylesheet's single source of truth outside the package's Python at the exact + * moment the Python is going away. The duplication has a known end date and a test that makes it + * loud until then; the extraction would outlive its own reason. + * + * The reversal condition is stated for the same reason: when #540 deletes `render.py`'s HTML, + * this file becomes the only palette, the pin test is deleted, and + * `tests/web/test_palette_contrast.py::_load_themes` re-points at this file. That is a + * three-line change and it is the whole migration. + * + * ── THE STATE CLASSES ARE #532'S, NOT A SECOND CONVENTION ───────────────────────────────────── + * + * `.good` / `.warn` / `.bad` / `.muted` are spelled exactly as `render.py` spells them, so the + * two front-ends style the same judgement the same way and the merge at #540 is a deletion + * rather than a reconciliation. The API's `state` vocabulary has five words to these four + * classes; `js/render.js`'s `STATE_CLASS` table holds that mapping and explains it. + */ + +:root { + --bg: #fbfaf8; --fg: #1c1b19; --muted: #6b6862; --line: #e3dfd8; + --card: #ffffff; --accent: #1a5578; --warn: #8a5a00; --bad: #7b2915; --good: #1f5f4f; + --control-line: #84817c; +} +:root:not([data-theme="light"]) { color-scheme: light dark; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #16150f; --fg: #ecead5; --muted: #9a968a; --line: #2f2d25; + --card: #1d1c15; --accent: #86b1e5; --warn: #d9a441; --bad: #e07a6a; --good: #83d3b2; + --control-line: #706d66; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +/* ── focus ──────────────────────────────────────────────────────────────────────────────────── + * + * One rule, applied to everything focusable, rather than per-component focus styling. `:focus- + * visible` rather than `:focus` so a mouse click does not paint a ring, while every keyboard + * path does -- which is the acceptance criterion ("focus is visible throughout"), and it is a + * criterion a per-component approach fails by omission the first time a component is added + * without one. + * + * `outline-offset` is what makes the ring visible against `--accent` backgrounds (the buttons): + * an outline drawn flush against a filled control is hard to see on the control it is marking. + */ +:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 2px; + border-radius: 3px; +} + +/* The skip link. Off-screen until focused -- `display: none` would remove it from the tab order + * entirely, which is the one thing it exists to be in. */ +.skip { + position: absolute; + left: -100vw; + top: 0; + background: var(--card); + color: var(--fg); + border: 1px solid var(--control-line); + padding: 0.5rem 0.9rem; + border-radius: 0 0 8px 0; + z-index: 10; +} +.skip:focus { left: 0; } + +/* ── header and navigation ──────────────────────────────────────────────────────────────────── + * + * The nav is a `