commit 2165b7df88a348a05ff4092a9f0e2e91467b6c49
parent 83e95065a896cf606f957c21fb52379c61e2e193
Author: claude-cdn-bot <claude-bot@users.noreply.github.com>
Date: Tue, 13 Oct 2026 01:54:06 +0000
fix(ui): accessibility, gzip for static bundle, manifest and docs
- axe-core clean on every page (light/dark, desktop/mobile): underline in-text links, focusable scroll regions,
contrast fixes for badges, h1 for folder pages, docs 'Jump to' no longer skips a heading level
- main.py: gzip app.css/app.js/prefetch.js/fasttree.js from a small in-memory cache (+ tests); sw.js stays untouched
- manifest theme/background colours match the page; README documents the new layout and UI conventions
Diffstat:
8 files changed, 74 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
@@ -147,8 +147,9 @@ Moving around is meant to feel like a static site. Four things work together:
the pages linked from the current one as soon as the browser is idle.
3. **Browser-cached, gzipped pages.** Rendered pages are identical for everyone, so they are sent with
`Cache-Control: private, max-age=30` and gzip (`app/fast_html.py`). A prefetched page is answered from
- the browser's cache; one that wasn't arrives about 5x smaller (a typical folder listing is ~60 KB of
- HTML, ~12 KB gzipped).
+ the browser's cache; one that wasn't arrives about 5x smaller. The shared stylesheet and script are
+ separate cache-busted files (`static/app.css`, `static/app.js`, ~10 KB and ~12 KB gzipped, cached across
+ every page), so the page HTML itself is only markup: a small folder listing is ~38 KB raw, ~5 KB gzipped.
4. **Fewer storage round trips.** Opening a folder used to cost a wasted `HEAD` request plus the listing.
When the parent folder's listing is cached (it is, because that's the page you clicked from) the `HEAD`
is skipped, and simultaneous requests for one folder share a single Hugging Face call.
@@ -206,8 +207,10 @@ app/
gh_oidc.py GitHub Actions OIDC token verification
shortener.py URL shortener
stats.py /stats
- templates/ Jinja2 pages (index.html renders most page types)
- static/ icons, manifest, service worker, prefetch.js, fasttree.js
+ templates/ Jinja2 pages: base.html (shared layout), index.html + _page_*.html (listing, file, batch,
+ upload, history, search, 404), _listing.html (fragments shared with fasttree.js),
+ _navbar/_breadcrumb/_icons partials, plus documentation / gh_sync_docs / stats / url_shortener
+ static/ app.css + app.js (the whole UI), icons, manifest, service worker, prefetch.js, fasttree.js
tests/ JS unit/DOM/end-to-end tests (node) + offline server checks (python)
Dockerfile · render.yaml · start.sh
```
@@ -223,3 +226,16 @@ governed by the [Code of Conduct](CODE_OF_CONDUCT.md). To report a vulnerability
[MIT](LICENSE) © 2026 Amit Dutta. All runtime dependencies are under permissive licenses (MIT, BSD,
Apache-2.0, Unlicense). The only third-party browser asset is [Turbo](https://github.com/hotwired/turbo) (MIT), loaded from
jsDelivr.
+
+## UI notes
+
+- **One stylesheet, one script.** All styling is in `app/static/app.css` (tokens at the top; light/dark via
+ `html[data-theme]`, set before first paint) and all behaviour in `app/static/app.js`. Both are loaded on every
+ page and versioned with a content hash (`?v=`), so a deploy never pairs new HTML with old CSS.
+- **Delegated events only.** Turbo swaps `<body>` on each visit, so `app.js` binds everything on `document`
+ (via `data-action`, `data-copy`, ids) and re-runs an idempotent `init()` on `turbo:load`.
+- **File previews** (`_page_file.html`): images, video, audio and PDF are plain elements loading from the raw
+ domain; text/code is fetched by `app.js` (first 256 KB) and rendered with `textContent`. HTML and SVG-as-text are
+ shown as source and never executed on the main origin.
+- **File-type colours** come from the file name alone (`[data-name$=".png" i]` rules at the end of `app.css`),
+ so server-rendered and browser-built rows look identical without extra placeholders.
diff --git a/app/main.py b/app/main.py
@@ -1,3 +1,4 @@
+import gzip
import hashlib
import os
import shutil
@@ -17,7 +18,7 @@ from collections import deque
from pathlib import Path
from pydantic import BaseModel
from fastapi import FastAPI, Request, File, UploadFile, HTTPException, Form
-from fastapi.responses import StreamingResponse, HTMLResponse, PlainTextResponse, RedirectResponse, FileResponse
+from fastapi.responses import StreamingResponse, HTMLResponse, Response, PlainTextResponse, RedirectResponse, FileResponse
from .fast_html import FastTemplates
from .fast_tree import tree_payload, shell_payload, json_response
@@ -68,8 +69,20 @@ def _asset_version() -> str:
templates.env.globals["asset_v"] = _asset_version()
+_GZ_CACHE: dict = {}
+
+
+def _gzipped(path: Path) -> bytes:
+ """gzip of a static file, kept in memory (a few files, keyed by mtime so a redeploy is picked up)."""
+ key = (path.name, path.stat().st_mtime_ns)
+ data = _GZ_CACHE.get(key)
+ if data is None:
+ data = _GZ_CACHE[key] = gzip.compress(path.read_bytes(), 9)
+ return data
+
+
@app.get("/static/{filename}")
-async def static_no_cache_root(filename: str):
+async def static_no_cache_root(filename: str, request: Request):
if filename in _NO_STORE_FILES:
cache_control = "no-store, no-cache, must-revalidate, max-age=0"
elif filename in _REVALIDATE_FILES:
@@ -80,6 +93,13 @@ async def static_no_cache_root(filename: str):
file_path = STATIC_DIR / filename
if not file_path.is_file():
raise HTTPException(status_code=404)
+ if filename in _REVALIDATE_FILES and "gzip" in request.headers.get("accept-encoding", "").lower():
+ media = "text/css" if filename.endswith(".css") else "text/javascript"
+ return Response(
+ _gzipped(file_path),
+ media_type=f"{media}; charset=utf-8",
+ headers={"Cache-Control": cache_control, "Content-Encoding": "gzip", "Vary": "Accept-Encoding"},
+ )
return FileResponse(file_path, headers={"Cache-Control": cache_control})
diff --git a/app/static/app.css b/app/static/app.css
@@ -34,7 +34,7 @@
--highlight: #ff6a42;
/* status */
- --success: #15803d;
+ --success: #166534;
--success-soft: #e8f6ec;
--warning: #a14a08;
--warning-soft: #fdf3e1;
@@ -167,6 +167,10 @@ p { margin: 0 0 1em; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 3px; }
+/* links inside running text must not rely on colour alone */
+p a, li a, .lede a, .callout a, .hero h1 a, .footnote a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
+.hero h1 a { text-decoration-thickness: 0.07em; text-underline-offset: 0.14em; }
+.site-footer p a { text-decoration: underline; text-decoration-color: var(--border-strong); }
code, kbd, samp, pre { font-family: var(--mono); }
code {
@@ -644,7 +648,7 @@ body.turbo-loading .page { opacity: 0.55; pointer-events: none; transition: opac
width: 4rem; height: 4rem; border-radius: 16px;
background: color-mix(in srgb, var(--ft) 14%, var(--surface));
border: 1px solid color-mix(in srgb, var(--ft) 35%, var(--border));
- color: var(--ft);
+ color: color-mix(in srgb, var(--ft) 62%, var(--text));
font-family: var(--mono); font-size: 0.8rem; font-weight: 800; letter-spacing: 0.02em;
}
.file-name { font-size: clamp(1.35rem, 1rem + 1.6vw, 1.9rem); overflow-wrap: anywhere; letter-spacing: -0.02em; }
@@ -763,7 +767,7 @@ body.turbo-loading .page { opacity: 0.55; pointer-events: none; transition: opac
.prose h1 { margin-bottom: 0.4rem; }
.prose h2 { margin: 2.5rem 0 0.75rem; font-size: 1.4rem; }
.prose > h2 { padding-top: 1.2rem; border-top: 1px solid var(--border); }
-.prose h3 { margin: 1.2rem 0 0.4rem; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; }
+.toc-title, .prose h3 { margin: 1.2rem 0 0.4rem; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; }
.prose p, .prose li { color: var(--muted); }
.prose p { margin: 0.4em 0 0.8em; }
.prose section { margin-top: 2.75rem; scroll-margin-top: calc(var(--header-h) + 1rem); }
@@ -774,7 +778,7 @@ body.turbo-loading .page { opacity: 0.55; pointer-events: none; transition: opac
.footnote { margin-top: 3rem; padding-top: 1.2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: var(--fs-sm); }
.toc { margin: 1.5rem 0 2.5rem; padding: 1.1rem 1.3rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); }
-.toc h3 { margin-top: 0; }
+.toc .toc-title { margin-top: 0; }
.toc ol { margin: 0; padding-left: 1.2em; columns: 2; column-gap: 2rem; font-size: var(--fs-md); }
.toc a { color: var(--text); }
.toc a:hover { color: var(--accent); }
diff --git a/app/static/app.js b/app/static/app.js
@@ -784,6 +784,7 @@
if (lines.length > PREVIEW_LINES) { lines = lines.slice(0, PREVIEW_LINES); truncated = true; }
// build with textContent so file contents can never become markup
const pre = document.createElement('pre');
+ pre.setAttribute('aria-label', 'File contents');
const code = document.createElement('code');
lines.forEach((l) => { const s = document.createElement('span'); s.className = 'ln'; s.textContent = l; code.appendChild(s); });
pre.appendChild(code);
@@ -827,6 +828,14 @@
applySort();
scheduleTruncate();
const page = document.body && document.body.dataset.page;
+ // scrollable code blocks must be reachable by keyboard
+ $$('pre, .link-row__code').forEach((el) => { if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); });
+ // folder pages have no visible heading (the breadcrumb is the title); give assistive tech one
+ if (page === 'listing' && !$('main h1')) {
+ const name = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || '');
+ const main = $('#main');
+ if (name && main) { const h = document.createElement('h1'); h.className = 'sr-only'; h.textContent = name; main.insertBefore(h, main.firstChild); }
+ }
if (page === 'history') renderHistory();
$$('.preview__text[data-text-src]').forEach(loadTextPreview);
}
diff --git a/app/static/manifest.json b/app/static/manifest.json
@@ -5,8 +5,8 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
- "background_color": "#000000",
- "theme_color": "#000000",
+ "background_color": "#fafaf9",
+ "theme_color": "#fafaf9",
"orientation": "any",
"lang": "en",
"icons": [
diff --git a/app/templates/documentation.html b/app/templates/documentation.html
@@ -10,7 +10,7 @@
guesswork — if something here is wrong, the code is the tie-breaker, and this page should get fixed to match it.</p>
<div class="toc">
- <h3>Jump to</h3>
+ <p class="toc-title">Jump to</p>
<ol>
<li><a href="#browsing">Browsing & viewing</a></li>
<li><a href="#gh-sync">GitHub Actions sync</a></li>
diff --git a/docs/UI_OVERHAUL.md b/docs/UI_OVERHAUL.md
@@ -33,7 +33,11 @@ placeholder contract only.
`<dialog>` modals, tabs, empty/404 states.
(Upload stays a single request per drop so multi-file drops keep their shared batch page; the queue shows
per-file rows with one shared progress value.)
-- [ ] **4. Polish** - a11y pass, mobile pass, manifest/OG fixes, SRI for Turbo, configurable site name.
+- [x] **4. Polish** - axe-core audit clean (0 violations on 13 pages x light/dark x desktop/mobile), underlined in-text
+ links, focusable scroll regions, contrast fixes, h1 on every page, manifest colours match the page, absolute
+ `og:image`, README updated, `app.css`/`app.js` served gzipped (~22 KB total, cached).
+ Not done: SRI on the Turbo script (can't verify the live CDN bytes from here; vendoring Turbo is the safer
+ follow-up) and a configurable site name (needs a backend setting).
## Safety rule for previews
diff --git a/tests/test_server_fastpaths.py b/tests/test_server_fastpaths.py
@@ -166,5 +166,12 @@ r = c.get("/no/such/folder", headers=gz)
check(r.status_code == 404 and "max-age" not in r.headers.get("cache-control", ""), "404 pages are not cached")
r = c.get("/static/sw.js", headers=gz)
check(r.headers["cache-control"].startswith("no-store") and "content-encoding" not in r.headers, "service worker is still no-store and untouched")
+for name in ("app.css", "app.js"):
+ r = c.get(f"/static/{name}", headers=gz)
+ plain = (m.STATIC_DIR / name).read_bytes()
+ check(r.headers.get("content-encoding") == "gzip" and r.content == plain and len(r.headers.get("content-length", "0")) > 0
+ and r.headers["vary"] == "Accept-Encoding", f"/static/{name} is gzipped and decodes to the original")
+ r = c.get(f"/static/{name}", headers={"accept-encoding": "identity"})
+ check("content-encoding" not in r.headers and r.content == plain, f"/static/{name} stays plain for clients without gzip")
print("\nall server fast-path checks passed")