commit 69bb2df5e35b1862672886a267e87f0fc8a2c7b4
parent bccc3407f698280c42edf805591e41f727d7b226
Author: claude-cdn-bot <claude-bot@users.noreply.github.com>
Date: Tue, 13 Oct 2026 08:20:02 +0000
feat(ui): preview toolbar on top and skeleton loading
- Text previews: the line count and 'Copy contents' now sit in a bar above the code instead of below it.
- Previews load behind a skeleton and are swapped in all at once with a short fade: a bar-and-lines skeleton for
text, a shimmer box for images and PDFs. Title, actions and Links render immediately; the skeleton only
covers the preview. aria-busy is set while loading.
- Safety: no-JS pages never hide the preview (the loading state is gated on html[data-js]); a skeleton is never
left up forever (8 s cap); a Turbo-cached half-finished load is retried.
- 9 new browser checks incl. delayed responses (74 total); axe-core still 0 violations.
Diffstat:
4 files changed, 54 insertions(+), 15 deletions(-)
diff --git a/app/static/app.css b/app/static/app.css
@@ -710,7 +710,22 @@ html[data-notice="off"] .site-notice { display: none; }
text-align: right; color: var(--muted); opacity: 0.65; border-right: 1px solid var(--border);
user-select: none; -webkit-user-select: none;
}
-.preview__note { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.55rem 1rem; border-top: 1px solid var(--border); background: var(--surface-2); color: var(--muted); font-size: var(--fs-xs); }
+.preview__bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 2.9rem; padding: 0.4rem 0.75rem 0.4rem 1rem; border-bottom: 1px solid var(--border); background: var(--surface-2); color: var(--muted); font-size: var(--fs-sm); }
+.preview__text.is-ready { animation: page-in 0.2s var(--ease) both; }
+
+/* skeletons: shown while a preview loads, then swapped out in one go */
+.sk { display: block; height: 0.8rem; border-radius: 4px; background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 50%, var(--surface-3) 75%); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; }
+.sk-label { width: 5.5rem; }
+.sk-btn { width: 7rem; height: 2rem; border-radius: var(--r-sm); }
+.sk-lines { display: grid; gap: 0.85rem; padding: 1.1rem 1rem 1.3rem; min-height: 16rem; align-content: start; }
+html[data-js] .preview--image.is-loading, html[data-js] .preview--pdf.is-loading { min-height: 16rem; }
+html[data-js] .preview--image.is-loading img { opacity: 0; }
+html[data-js] .preview--pdf.is-loading iframe { opacity: 0; }
+html[data-js] .preview--image.is-loading::after, html[data-js] .preview--pdf.is-loading::after {
+ content: ""; position: absolute; inset: 0; z-index: 1;
+ background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 50%, var(--surface-3) 75%); background-size: 200% 100%; animation: shimmer 1.4s linear infinite;
+}
+.preview--image img, .preview--pdf iframe { transition: opacity 0.2s var(--ease); }
.link-list { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 1.1rem 1.25rem 0.4rem; box-shadow: var(--shadow-sm); }
.link-list .section-title { margin-bottom: 0.5rem; }
diff --git a/app/static/app.js b/app/static/app.js
@@ -767,7 +767,7 @@
async function loadTextPreview(box) {
if (box.getAttribute('data-state') === 'done' || box.getAttribute('data-state') === 'loading') return;
box.setAttribute('data-state', 'loading');
- const status = (msg) => { box.innerHTML = '<p class="preview__status"></p>'; box.firstChild.textContent = msg; };
+ const status = (msg) => { box.innerHTML = '<p class="preview__status"></p>'; box.firstChild.textContent = msg; box.classList.remove('is-skeleton'); box.setAttribute('aria-busy', 'false'); };
try {
const res = await fetch(box.getAttribute('data-text-src'));
if (!res.ok) throw new Error('HTTP ' + res.status);
@@ -802,13 +802,15 @@
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);
- box.innerHTML = '';
- box.appendChild(pre);
- const note = document.createElement('div');
- note.className = 'preview__note';
- note.innerHTML = '<span></span><button type="button" class="btn btn-sm" data-copy-preview><span>Copy contents</span></button>';
- note.firstChild.textContent = lines.length + ' line' + (lines.length !== 1 ? 's' : '') + (truncated ? ' · preview truncated' : '');
- box.appendChild(note);
+ const bar = document.createElement('div');
+ bar.className = 'preview__bar';
+ bar.innerHTML = '<span></span><button type="button" class="btn btn-sm" data-copy-preview><span>Copy contents</span></button>';
+ bar.firstChild.textContent = lines.length + ' line' + (lines.length !== 1 ? 's' : '') + (truncated ? ' · preview truncated' : '');
+ // everything is built off-screen above, then swapped in at once (replacing the skeleton)
+ box.replaceChildren(bar, pre);
+ box.classList.remove('is-skeleton');
+ box.classList.add('is-ready');
+ box.setAttribute('aria-busy', 'false');
box.setAttribute('data-state', 'done');
} catch (err) {
status("Couldn't load a preview. Use Open raw instead.");
@@ -824,14 +826,31 @@
if (ok) { flashButton(b, 'Copied'); toast('Contents copied'); } else toast('Could not copy', { type: 'error' });
});
// an image that fails to load (deleted upstream, blocked host) degrades to the plain no-preview state
+ function imageFailed(box) {
+ box.className = 'preview preview--none';
+ box.innerHTML = '<div class="preview__none"><p>This image could not be loaded.</p></div>';
+ }
document.addEventListener('error', (e) => {
const img = e.target;
if (!(img instanceof HTMLImageElement)) return;
const box = img.closest('.preview--image');
- if (!box) return;
- box.className = 'preview preview--none';
- box.innerHTML = '<div class="preview__none"><p>This image could not be loaded.</p></div>';
+ if (box) imageFailed(box);
+ }, true);
+ // 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');
}, true);
+ function settleLoadingPreviews() {
+ $$('.preview.is-loading').forEach((box) => {
+ const img = $('img', box);
+ if (img && img.complete) { if (img.naturalWidth > 0) box.classList.remove('is-loading'); else imageFailed(box); return; }
+ // never leave a skeleton up forever (e.g. a PDF viewer that doesn't fire load)
+ setTimeout(() => box.classList.remove('is-loading'), 8000);
+ });
+ }
// ---------------------------------------------------------------------------------------------
// lifecycle
@@ -852,6 +871,7 @@
}
if (page === 'history') renderHistory();
$$('.preview__text[data-text-src]').forEach(loadTextPreview);
+ settleLoadingPreviews();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
@@ -867,6 +887,7 @@
document.addEventListener('turbo:before-cache', () => {
// don't freeze open menus into the cached snapshot
closeMenus(null);
+ $$('.preview__text[data-state="loading"]').forEach((el) => el.setAttribute('data-state', 'idle'));
const nav = $('#primary-nav'); if (nav) nav.classList.remove('is-open');
const tgl = $('.nav-toggle'); if (tgl) tgl.setAttribute('aria-expanded', 'false');
});
diff --git a/app/templates/_page_file.html b/app/templates/_page_file.html
@@ -65,7 +65,7 @@
{% endif %}
</section>
- <section class="preview preview--{{ kind.v or 'none' }}" aria-label="Preview">
+ <section class="preview preview--{{ kind.v or 'none' }}{{ ' is-loading' if kind.v in ('image', 'pdf') }}" aria-label="Preview">
{% if kind.v == 'image' %}
<img src="{{ raw_url }}" alt="Preview of {{ filename }}" decoding="async">
{% elif kind.v == 'video' %}
@@ -76,8 +76,10 @@
<iframe src="{{ raw_url }}" title="PDF preview of {{ filename }}" loading="lazy"></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" data-text-src="{{ raw_url }}" data-text-ext="{{ ext }}" data-state="idle">
- <p class="preview__status">Loading preview…</p>
+ <div class="preview__text is-skeleton" data-text-src="{{ raw_url }}" data-text-ext="{{ ext }}" data-state="idle" aria-busy="true">
+ <div class="preview__bar" aria-hidden="true"><span class="sk sk-label"></span><span class="sk sk-btn"></span></div>
+ <div class="sk-lines" aria-hidden="true">{% for w in [58, 82, 40, 70, 92, 34, 66, 78, 48, 86, 30, 72] %}<span class="sk" style="width: {{ w }}%"></span>{% endfor %}</div>
+ <p class="sr-only">Loading preview…</p>
</div>
{% else %}
<div class="preview__none">
diff --git a/app/templates/base.html b/app/templates/base.html
@@ -41,6 +41,7 @@
page, so Turbo treats it as already-run. app.js owns the toggle. -#}
<script>
(function () {
+ document.documentElement.setAttribute('data-js', '');
try {
var t = localStorage.getItem('cdn_theme');
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';