Match Splatoon 3 directory browser styling
Some checks failed
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled

The initial Worker used a simplified renderer that omitted the established layout styles and folder/file icon markup.
This commit is contained in:
Matt Isenhower
2026-09-02 20:42:48 -07:00
parent d1a4c6a531
commit 477ca7f4ac
2 changed files with 299 additions and 24 deletions

View File

@@ -155,7 +155,7 @@ function breadcrumbHtml(prefix) {
let crumbs = ['<a href="/">assets</a>'];
for (let index = 0; index < parts.length; index++) {
crumbs.push('<span aria-hidden="true">/</span>');
crumbs.push('<span class="breadcrumb-separator" aria-hidden="true">/</span>');
if (index === parts.length - 1) {
crumbs.push(`<span aria-current="page">${escapeHtml(parts[index])}</span>`);
} else {
@@ -170,26 +170,29 @@ function breadcrumbHtml(prefix) {
function renderListing(page) {
let rows = [];
if (page.prefix) {
rows.push(`<li><a class="entry parent" href="${parentPath(page.prefix)}"><span>Parent directory</span><span></span><span>Folder</span></a></li>`);
rows.push(`<li><a class="listing-row parent-row" href="${parentPath(page.prefix)}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--parent" aria-hidden="true">↑</span><span class="entry-name">Parent directory</span></span><span class="listing-cell"></span><span class="listing-cell"></span></a></li>`);
}
for (let directory of page.directories) {
rows.push(`<li><a class="entry" href="${directory.pathname}"><span>${escapeHtml(directory.name)}</span><span></span><span>Folder</span></a></li>`);
rows.push(`<li><a class="listing-row" href="${directory.pathname}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--folder" aria-hidden="true"></span><span class="entry-name">${escapeHtml(directory.name)}</span></span><span class="listing-cell"></span><span class="listing-cell"><span class="visually-hidden">Type: </span><span class="type-label">Folder</span></span></a></li>`);
}
for (let file of page.files) {
rows.push(`<li><a class="entry" href="${file.pathname}"><span>${escapeHtml(file.name)}</span><time datetime="${file.uploaded.toISOString()}">${formatDate(file.uploaded)}</time><span>${formatSize(file.size)}</span></a></li>`);
rows.push(`<li><a class="listing-row" href="${file.pathname}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--file" aria-hidden="true"></span><span class="entry-name">${escapeHtml(file.name)}</span></span><span class="listing-cell"><span class="visually-hidden">Last modified: </span><time datetime="${file.uploaded.toISOString()}">${formatDate(file.uploaded)}</time></span><span class="listing-cell"><span class="visually-hidden">Size: </span>${formatSize(file.size)}</span></a></li>`);
}
if (!rows.length) {
rows.push('<li class="empty">This directory is empty.</li>');
rows.push('<li class="empty-state">This directory is empty.</li>');
}
let jsonCursor = page.requestedCursor
? `&amp;cursor=${encodeURIComponent(page.requestedCursor)}`
: '';
let next = page.nextCursor
? `<a class="next" rel="next" href="${page.pathname}?cursor=${encodeURIComponent(page.nextCursor)}">Next page →</a>`
? `<a class="next-page" rel="next" href="${page.pathname}?cursor=${encodeURIComponent(page.nextCursor)}">Next page <span aria-hidden="true">→</span></a>`
: '';
let title = `Asset browser — ${page.pathname}`;
let nonce = crypto.randomUUID().replaceAll('-', '');
let folderCount = page.directories.length;
let fileCount = page.files.length;
let summary = `${folderCount} ${folderCount === 1 ? 'folder' : 'folders'} · ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`;
return {
body: `<!doctype html>
@@ -202,27 +205,295 @@ function renderListing(page) {
<link rel="alternate" type="application/json" href="${page.pathname}?format=json${jsonCursor}">
<title>${escapeHtml(title)}</title>
<style nonce="${nonce}">
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
body { max-width: 64rem; margin: 0 auto; padding: 2rem; background: #f3f5f9; color: #18202f; }
main { overflow: hidden; border: 1px solid #dce1ea; border-radius: 1rem; background: #fff; box-shadow: 0 1.5rem 4rem #261d521f; }
header, footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; }
nav { display: flex; gap: .5rem; overflow: hidden; font: .9rem ui-monospace, monospace; white-space: nowrap; }
nav a, .next { color: #6047d9; font-weight: 700; }
ul { min-width: 34rem; margin: 0; padding: 0; list-style: none; }
.entry { display: grid; grid-template-columns: minmax(0, 1fr) 14rem 7rem; gap: 1rem; padding: 1rem 1.25rem; border-top: 1px solid #dce1ea; color: inherit; text-decoration: none; }
.entry:hover { background: #f7f5ff; }
.parent { color: #657084; }
.empty { padding: 2rem 1.25rem; border-top: 1px solid #dce1ea; color: #657084; text-align: center; }
.summary { color: #657084; font-size: .85rem; white-space: nowrap; }
footer:empty { display: none; }
@media (prefers-color-scheme: dark) { body { background: #0b0e15; color: #f3f4f8; } main { border-color: #2b3240; background: #151923; } .entry { border-color: #2b3240; } .entry:hover { background: #1d2030; } nav a, .next { color: #a997ff; } }
:root {
color-scheme: light dark;
--background: #f3f5f9;
--background-accent: #e8e4ff;
--surface: rgba(255, 255, 255, 0.88);
--surface-strong: #fff;
--text: #18202f;
--muted: #657084;
--border: #dce1ea;
--accent: #6047d9;
--accent-soft: #eeeaff;
--folder: #8a6cff;
--file: #8290a6;
--row-hover: #f7f5ff;
--shadow: 0 24px 70px rgba(38, 29, 82, 0.12);
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 15% 0%, var(--background-accent), transparent 34rem),
var(--background);
color: var(--text);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
a { color: inherit; }
.shell {
width: min(960px, calc(100% - 40px));
margin: 0 auto;
padding: 48px 0;
}
.browser {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.browser-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 68px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface-strong);
}
.breadcrumbs {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
overflow: hidden;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.9rem;
white-space: nowrap;
}
.breadcrumbs a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.breadcrumbs a:hover { text-decoration: underline; }
.breadcrumbs [aria-current="page"] { overflow: hidden; text-overflow: ellipsis; }
.breadcrumb-separator { color: var(--muted); }
.summary { color: var(--muted); font-size: 0.82rem; }
.browser-meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 12px;
}
.json-link {
border-radius: 999px;
padding: 3px 9px;
background: var(--accent-soft);
color: var(--accent);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.72rem;
font-weight: 750;
text-decoration: none;
}
.json-link:hover { text-decoration: underline; }
.listing-wrap { overflow-x: auto; }
.listing {
min-width: 520px;
width: 100%;
font-size: 0.9rem;
}
.listing-items { margin: 0; padding: 0; list-style: none; }
.listing-header, .listing-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 210px 110px;
}
.listing-header > span {
padding: 12px 20px;
color: var(--muted);
font-size: 0.7rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.listing-row {
min-height: 56px;
border-top: 1px solid var(--border);
color: var(--muted);
text-decoration: none;
transition: background-color 120ms ease;
}
.listing-row:hover { background: var(--row-hover); }
.listing-row:focus-visible { border-radius: 4px; outline: 2px solid var(--accent); outline-offset: -3px; }
.listing-cell {
display: flex;
min-width: 0;
align-items: center;
padding: 8px 20px;
white-space: nowrap;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.entry {
max-width: min(560px, 58vw);
gap: 12px;
color: var(--text);
font-weight: 620;
}
.entry-name { overflow: hidden; text-overflow: ellipsis; }
.listing-row:hover .entry-name { color: var(--accent); text-decoration: underline; }
.entry-icon {
position: relative;
display: inline-block;
flex: 0 0 18px;
width: 18px;
height: 18px;
}
.entry-icon--folder {
height: 13px;
margin-top: 3px;
border-radius: 3px;
background: var(--folder);
}
.entry-icon--folder::before {
position: absolute;
top: -4px;
left: 1px;
width: 8px;
height: 5px;
border-radius: 3px 3px 0 0;
background: var(--folder);
content: "";
}
.entry-icon--file { border: 2px solid var(--file); border-radius: 3px; }
.entry-icon--file::after {
position: absolute;
right: 2px;
bottom: 3px;
left: 2px;
height: 2px;
background: var(--file);
box-shadow: 0 -4px 0 var(--file);
content: "";
}
.entry-icon--parent {
display: grid;
place-items: center;
border-radius: 5px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.85rem;
font-weight: 800;
}
.parent-row .entry { color: var(--muted); font-weight: 560; }
.type-label {
display: inline-block;
border-radius: 999px;
padding: 2px 8px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.72rem;
font-weight: 700;
}
.empty-state {
display: grid;
height: 120px;
place-items: center;
border-top: 1px solid var(--border);
text-align: center;
}
.browser-footer {
display: flex;
justify-content: flex-end;
padding: 14px 20px;
border-top: 1px solid var(--border);
background: var(--surface-strong);
}
.browser-footer:empty { display: none; }
.next-page { color: var(--accent); font-size: 0.86rem; font-weight: 700; text-decoration: none; }
.next-page:hover { text-decoration: underline; }
@media (prefers-color-scheme: dark) {
:root {
--background: #0b0e15;
--background-accent: #201a43;
--surface: rgba(20, 24, 35, 0.9);
--surface-strong: #151923;
--text: #f3f4f8;
--muted: #9ba4b5;
--border: #2b3240;
--accent: #a997ff;
--accent-soft: #292443;
--folder: #9b87ff;
--file: #8590a4;
--row-hover: #1d2030;
--shadow: 0 28px 80px rgba(0, 0, 0, 0.4);
}
}
@media (max-width: 640px) {
.shell { width: min(100% - 24px, 960px); padding: 24px 0; }
.browser-header { align-items: flex-start; flex-direction: column; gap: 6px; }
.browser-meta { width: 100%; justify-content: space-between; }
.listing-header, .listing-row { grid-template-columns: minmax(0, 1fr) 190px 92px; }
.listing-header > span, .listing-cell { padding-right: 14px; padding-left: 14px; }
.entry { max-width: calc(100vw - 150px); }
}
</style>
</head>
<body>
<main>
<header><nav aria-label="Breadcrumb">${breadcrumbHtml(page.prefix)}</nav><span class="summary">${page.directories.length} folders · ${page.files.length} files · <a href="${page.pathname}?format=json${jsonCursor}">JSON</a></span></header>
<ul aria-label="Directory entries">${rows.join('')}</ul>
<footer>${next}</footer>
<main class="shell">
<section class="browser" aria-label="Directory contents">
<div class="browser-header">
<nav class="breadcrumbs" aria-label="Breadcrumb">${breadcrumbHtml(page.prefix)}</nav>
<div class="browser-meta"><span class="summary">${summary}</span><a class="json-link" href="${page.pathname}?format=json${jsonCursor}">JSON</a></div>
</div>
<div class="listing-wrap">
<div class="listing">
<div class="listing-header"><span>Name</span><span>Last modified</span><span>Size</span></div>
<ul class="listing-items" aria-label="Directory entries">${rows.join('')}</ul>
</div>
</div>
<div class="browser-footer">${next}</div>
</section>
</main>
</body>
</html>`,

View File

@@ -37,6 +37,10 @@ describe('R2 directory index', () => {
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8');
expect(response.headers.get('cache-control')).toBe('no-store');
expect(html).toContain('<main class="shell">');
expect(html).toContain('class="entry-icon entry-icon--folder"');
expect(html).toContain('class="entry-icon entry-icon--file"');
expect(html).toContain('class="listing-header"');
expect(html).toContain('href="/data/locale/"');
expect(html).toContain('href="/data/schedules.json"');
expect(html).not.toContain('__directory');