commit 2d37174d152c6a15eedcb65fd9c971adda503857
parent 613126629ab21280ab6087e3c5e36a5e3788ae2e
Author: claude-cdn-bot <claude-bot@users.noreply.github.com>
Date: Wed, 14 Oct 2026 03:09:16 +0000
feat(ui): whole-page file skeleton, wrapping link boxes, file taps go through Turbo
- Link boxes are plain wrapping text instead of read-only inputs: the full URL is always visible, nothing scrolls
sideways (swiping inside an input scrolled the text and showed only a truncated 'https://raw.cdn...'), a tap still
selects the whole link, and Copy copies the same text. Embed snippet wraps too.
- File pages show a whole-page skeleton (title, actions, links, preview) and reveal everything at once when the preview
is ready, capped at 0.7 s so a huge image can't hold the links back. Gated on html[data-js]; no-JS pages are untouched.
- Tapping a file in a list shows the same skeleton after 140 ms if the server is slow (never flashes on fast/prefetched
visits) and it is never stored in Turbo's snapshot cache, so Back returns the real listing.
- Pre-existing issue fixed: Turbo refuses URLs that don't look like HTML, so every tap on a file with an extension
(app.js, photo.png) was a full page reload. File-link taps now start a Turbo visit (with a plain-link fallback;
modifier-clicks and new-tab clicks are left to the browser).
- PDF preview no longer uses loading=lazy (it never loaded while the page was hidden behind the skeleton).
- 15 new browser checks (89 total); repo tests and axe-core (0 violations) pass.
Diffstat:
6 files changed, 138 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
@@ -239,3 +239,8 @@ jsDelivr.
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.
+- **File pages and Turbo.** Turbo refuses to follow URLs that don't look like HTML (`/assets/app.js`), which would make
+ every tap on a file a full page reload. `app.js` therefore starts those visits itself (`Turbo.session.navigator.startVisit`,
+ with a plain-link fallback). A file page shows a whole-page skeleton and reveals everything at once when its preview is
+ ready (capped at 0.7 s); while a tapped file page is still being fetched, the same skeleton is shown after 140 ms, and is
+ never put in Turbo's cache.
diff --git a/app/static/app.css b/app/static/app.css
@@ -662,7 +662,9 @@ html[data-notice="off"] .site-notice { display: none; }
.empty-state p { margin: 0; }
/* ============================== 7. File page ============================== */
-.file-view { display: grid; gap: 1.5rem; }
+.file-view { display: block; }
+.file-view__content { display: grid; gap: 1.5rem; }
+.file-view.is-revealed .file-view__content { animation: page-in 0.22s var(--ease) both; }
.file-head {
display: grid; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; gap: 0.9rem 1.25rem;
@@ -734,18 +736,35 @@ html[data-js] .preview--image.is-loading::after, html[data-js] .preview--pdf.is-
.link-row__label { font-weight: 600; font-size: var(--fs-md); }
.link-row__label small { display: block; color: var(--muted); font-weight: 400; font-size: var(--fs-xs); }
.link-row__value {
- min-width: 0; width: 100%; padding: 0.45rem 0.7rem;
+ display: block; min-width: 0; padding: 0.5rem 0.7rem;
background: var(--surface-2); color: var(--text);
border: 1px solid var(--border); border-radius: var(--r-sm);
- font-family: var(--mono); font-size: var(--fs-xs); text-overflow: ellipsis;
+ font-family: var(--mono); font-size: var(--fs-xs); line-height: 1.5;
+ white-space: normal; overflow-wrap: anywhere; word-break: break-all;
+ -webkit-user-select: all; user-select: all;
}
-.link-row__value:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
-.link-row__code { display: block; overflow-x: auto; white-space: nowrap; padding: 0.45rem 0.7rem; border-radius: var(--r-sm); font-size: var(--fs-xs); }
.notfound { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 1rem; padding: 4rem 0 2rem; }
.notfound__icon { display: grid; place-items: center; width: 4.5rem; height: 4.5rem; border-radius: 20px; background: var(--warning-soft); color: var(--warning); border: 1px solid var(--warning-border); }
.notfound__actions { display: flex; gap: 0.6rem; flex-wrap: wrap; justify-content: center; margin-top: 0.5rem; }
+/* whole-page skeleton for file pages: shown (JS only) until the preview is ready, then everything appears at once */
+.file-skel { display: none; gap: 1.5rem; }
+html[data-js] .file-view.is-pending .file-skel, .file-skel.is-standalone { display: grid; }
+html[data-js] .file-view.is-pending .file-view__content { display: none; }
+.file-skel__head { display: grid; gap: 0.8rem; }
+.sk-title { width: 55%; height: 1.9rem; border-radius: 6px; }
+.sk-pills { width: 38%; height: 1.2rem; }
+.file-skel__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
+.sk-action { height: 2.5rem; flex: 1 1 6rem; max-width: 9rem; border-radius: var(--r); }
+.file-skel__card, .file-skel__preview { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-sm); }
+.file-skel__card { display: grid; gap: 1.1rem; padding: 1.2rem 1.25rem; }
+.file-skel__row { display: grid; gap: 0.55rem; }
+.sk-rowlabel { width: 30%; }
+.sk-rowbar { width: 100%; height: 2.1rem; border-radius: var(--r-sm); }
+.file-skel__preview { display: grid; gap: 0.85rem; align-content: start; min-height: 16rem; padding: 1.1rem 1rem 1.3rem; }
+.sk-previewbar { width: 100%; height: 2rem; border-radius: var(--r-sm); margin-bottom: 0.4rem; }
+
/* ============================== 8. Upload ============================== */
.upload { max-width: 44rem; }
.dropzone {
@@ -922,6 +941,7 @@ html[data-js] .preview--image.is-loading::after, html[data-js] .preview--pdf.is-
.file-head__actions .btn { flex: 1 1 0; }
.link-row { grid-template-columns: minmax(0, 1fr) auto; }
.link-row__label { grid-column: 1 / -1; }
+ .link-row { align-items: start; }
.link-row__label small { display: inline; margin-left: 0.4rem; }
.toc ol { columns: 1; }
/* small copy buttons become icon-only: the copy glyph is universal, and the link box gets the room back */
diff --git a/app/static/app.js b/app/static/app.js
@@ -815,7 +815,9 @@
box.setAttribute('data-state', 'done');
} catch (err) {
status("Couldn't load a preview. Use Open raw instead.");
- box.setAttribute('data-state', 'idle');
+ box.setAttribute('data-state', 'failed');
+ } finally {
+ settleFileView();
}
}
document.addEventListener('click', async (e) => {
@@ -830,6 +832,7 @@
function imageFailed(box) {
box.className = 'preview preview--none';
box.innerHTML = '<div class="preview__none"><p>This image could not be loaded.</p></div>';
+ settleFileView();
}
document.addEventListener('error', (e) => {
const img = e.target;
@@ -837,12 +840,28 @@
const box = img.closest('.preview--image');
if (box) imageFailed(box);
}, true);
+ // File pages show a whole-page skeleton and appear at once when the preview is ready, or after a short cap
+ // so a huge image can never hold the links back.
+ const REVEAL_CAP_MS = 700;
+ function revealFileView(view) {
+ if (!view.classList.contains('is-pending')) return;
+ view.classList.remove('is-pending');
+ view.classList.add('is-revealed');
+ view.removeAttribute('aria-busy');
+ }
+ function settleFileView() {
+ const view = $('.file-view.is-pending');
+ if (!view) return;
+ const waiting = $('.preview__text[data-state="idle"], .preview__text[data-state="loading"], .preview.is-loading', view);
+ if (!waiting) { revealFileView(view); return; }
+ if (!view._capTimer) view._capTimer = setTimeout(() => revealFileView(view), REVEAL_CAP_MS);
+ }
// images and PDFs sit behind a shimmer until they have loaded, then appear at once
document.addEventListener('load', (e) => {
const t = e.target;
if (!(t instanceof HTMLImageElement || t instanceof HTMLIFrameElement)) return;
const box = t.closest('.preview.is-loading');
- if (box) box.classList.remove('is-loading');
+ if (box) { box.classList.remove('is-loading'); settleFileView(); }
}, true);
function settleLoadingPreviews() {
$$('.preview.is-loading').forEach((box) => {
@@ -863,7 +882,7 @@
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'); });
+ $$('pre').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() || '');
@@ -873,6 +892,7 @@
if (page === 'history') renderHistory();
$$('.preview__text[data-text-src]').forEach(loadTextPreview);
settleLoadingPreviews();
+ settleFileView();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
@@ -893,6 +913,62 @@
const tgl = $('.nav-toggle'); if (tgl) tgl.setAttribute('aria-expanded', 'false');
});
+ // Tapping a file in a list: if the server takes more than a beat, show the file-page skeleton right away
+ // (like GitHub) instead of leaving the old page dimmed. Fast or prefetched visits never flash it.
+ let tappedFile = '';
+ let skelTimer = 0;
+ let savedMain = null;
+ function showFileSkeleton() {
+ const main = $('#main'); const tpl = $('#file-skel-tpl');
+ if (!main || !tpl || savedMain) return;
+ savedMain = { html: main.innerHTML, cls: main.className };
+ const node = tpl.content.firstElementChild.cloneNode(true);
+ node.classList.add('is-standalone');
+ main.replaceChildren(node);
+ main.setAttribute('aria-busy', 'true');
+ window.scrollTo(0, 0);
+ }
+ function clearFileSkeleton(restore) {
+ clearTimeout(skelTimer);
+ const main = $('#main');
+ if (restore && savedMain && main) { main.innerHTML = savedMain.html; main.className = savedMain.cls; main.removeAttribute('aria-busy'); }
+ savedMain = null;
+ }
+ // Turbo only follows URLs that look like HTML, so a tap on /assets/app.js (or any file with an extension) would be
+ // a full page reload: no skeleton, no smooth transition. File pages are ordinary HTML pages, so visit them through
+ // Turbo's navigator ourselves. Anything unusual (modifier keys, new tab, Turbo missing) is left to the browser.
+ function turboVisitFile(url) {
+ const nav = window.Turbo && window.Turbo.session && window.Turbo.session.navigator;
+ if (!nav || typeof nav.startVisit !== 'function') return false;
+ try {
+ // startVisit (unlike proposeVisit/Turbo.visit) skips the "looks like HTML" URL check
+ const id = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + Math.random();
+ nav.startVisit(url, id);
+ return true;
+ } catch (err) { return false; }
+ }
+ document.addEventListener('click', (e) => {
+ const a = e.target instanceof Element && e.target.closest('a.file-link');
+ tappedFile = '';
+ if (!a) return;
+ const url = new URL(a.href, location.href);
+ tappedFile = url.pathname;
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ if (a.target || url.origin !== location.origin) return;
+ if (turboVisitFile(url)) e.preventDefault();
+ }, true);
+ document.addEventListener('turbo:visit', (e) => {
+ clearFileSkeleton(true);
+ const url = e.detail && e.detail.url;
+ if (!tappedFile || !url) return;
+ let path = ''; try { path = new URL(url).pathname; } catch (err) { return; }
+ if (path !== tappedFile) return;
+ skelTimer = setTimeout(showFileSkeleton, 140);
+ });
+ // the new page is about to replace this one: stop the timer, and never let Turbo cache the skeleton
+ document.addEventListener('turbo:before-cache', () => clearFileSkeleton(true));
+ document.addEventListener('turbo:before-render', () => { clearTimeout(skelTimer); savedMain = null; tappedFile = ''; });
+
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js').catch((err) => console.error('service worker registration failed:', err));
diff --git a/app/templates/_page_file.html b/app/templates/_page_file.html
@@ -1,4 +1,5 @@
{% from "_icons.html" import icon %}
+{% from "_skeleton.html" import file_skeleton %}
{#- Everything here is built from values the server already has (path, extension, size), so the page costs
nothing extra to render. Previews load straight from the raw domain; text is fetched by app.js. -#}
{% set raw_url = raw_base_url ~ '/' ~ (path | urlencode) %}
@@ -19,6 +20,7 @@
} %}
{% for k, exts in kinds.items() %}{% if ext in exts %}{% set kind.v = k %}{% endif %}{% endfor %}
+{% set pending = kind.v in ('image', 'pdf', 'text') %}
{% set embed = none %}
{% if kind.v == 'image' %}{% set embed = '<img src="' ~ raw_url ~ '" alt="">' %}
{% elif ext == 'css' %}{% set embed = '<link rel="stylesheet" href="' ~ raw_url ~ '">' %}
@@ -27,7 +29,9 @@
{% elif kind.v == 'audio' %}{% set embed = '<audio src="' ~ raw_url ~ '" controls></audio>' %}
{% endif %}
-<article class="file-view">
+<article class="file-view{{ ' is-pending' if pending }}"{{ ' aria-busy="true"'|safe if pending }}>
+ {{ file_skeleton() }}
+ <div class="file-view__content">
<header class="file-head">
<div class="file-head__main">
<h1 class="file-name">{{ filename }}</h1>
@@ -49,7 +53,7 @@
{% macro link_row(label, value, hint='') %}
<div class="link-row">
<span class="link-row__label">{{ label }}{% if hint %}<small>{{ hint }}</small>{% endif %}</span>
- <input class="link-row__value" type="text" readonly value="{{ value }}" aria-label="{{ label }}" data-select-on-focus>
+ <code class="link-row__value" data-link="{{ label }}">{{ value }}</code>
<button type="button" class="btn btn-sm" data-copy="{{ value }}" aria-label="Copy {{ label }}">{{ icon('copy', 14) }}<span>Copy</span></button>
</div>
{% endmacro %}
@@ -59,7 +63,7 @@
{% if embed %}
<div class="link-row link-row--code">
<span class="link-row__label">Embed<small>HTML snippet</small></span>
- <code class="link-row__code">{{ embed }}</code>
+ <code class="link-row__value" data-link="Embed">{{ embed }}</code>
<button type="button" class="btn btn-sm" data-copy="{{ embed }}" aria-label="Copy embed snippet">{{ icon('copy', 14) }}<span>Copy</span></button>
</div>
{% endif %}
@@ -73,7 +77,7 @@
{% elif kind.v == 'audio' %}
<div class="preview__audio">{{ icon('file', 28, 1.5) }}<audio src="{{ raw_url }}" controls preload="metadata"></audio></div>
{% elif kind.v == 'pdf' %}
- <iframe src="{{ raw_url }}" title="PDF preview of {{ filename }}" loading="lazy"></iframe>
+ <iframe src="{{ raw_url }}" title="PDF preview of {{ filename }}"></iframe>
<p class="preview__hint">Nothing showing? Your browser may not display PDFs inline — <a href="{{ raw_url }}" target="_blank" rel="noopener" data-turbo="false">open it directly</a>.</p>
{% elif kind.v == 'text' %}
<div class="preview__text is-skeleton" data-text-src="{{ raw_url }}" data-text-ext="{{ ext }}" data-state="idle" aria-busy="true">
@@ -88,4 +92,5 @@
</div>
{% endif %}
</section>
+ </div>
</article>
diff --git a/app/templates/_skeleton.html b/app/templates/_skeleton.html
@@ -0,0 +1,18 @@
+{#- Placeholder shown while a file page is getting ready (see .file-view.is-pending in app.css and the
+ turbo:visit handler in app.js). Pure decoration: hidden from assistive tech. -#}
+{% macro file_skeleton() %}
+<div class="file-skel" aria-hidden="true">
+ <div class="file-skel__head">
+ <span class="sk sk-title"></span>
+ <span class="sk sk-pills"></span>
+ <div class="file-skel__actions"><span class="sk sk-action"></span><span class="sk sk-action"></span><span class="sk sk-action"></span></div>
+ </div>
+ <div class="file-skel__card">
+ {% for i in range(4) %}<div class="file-skel__row"><span class="sk sk-rowlabel"></span><span class="sk sk-rowbar"></span></div>{% endfor %}
+ </div>
+ <div class="file-skel__preview">
+ <span class="sk sk-previewbar"></span>
+ {% for w in [58, 82, 40, 70, 92, 34, 66] %}<span class="sk" style="width: {{ w }}%"></span>{% endfor %}
+ </div>
+</div>
+{% endmacro %}
diff --git a/app/templates/base.html b/app/templates/base.html
@@ -1,4 +1,5 @@
{% from "_listing.html" import footer_stats %}
+{% from "_skeleton.html" import file_skeleton %}
{#- Shared page layout. Child templates set `page_title` (and optionally `page_description`) at the top level and
fill `head_extra`, `page_class`, `content` and `scripts`. Everything shared (head, header, breadcrumb, footer,
dialogs, toasts) lives here so a change is made once.
@@ -99,6 +100,7 @@
</div>
</dialog>
+ <template id="file-skel-tpl">{{ file_skeleton() }}</template>
<div id="toasts" class="toasts" role="status" aria-live="polite"></div>
{% block scripts %}{% endblock %}
</body>