cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
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:
MREADME.md | 24++++++++++++++++++++----
Mapp/main.py | 24++++++++++++++++++++++--
Mapp/static/app.css | 12++++++++----
Mapp/static/app.js | 9+++++++++
Mapp/static/manifest.json | 4++--
Mapp/templates/documentation.html | 2+-
Mdocs/UI_OVERHAUL.md | 6+++++-
Mtests/test_server_fastpaths.py | 7+++++++
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 &amp; 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")