diff --git a/src/demo-page.ts b/src/demo-page.ts index 4fd4fd5..c2bf3ca 100644 --- a/src/demo-page.ts +++ b/src/demo-page.ts @@ -65,7 +65,11 @@ export const DEMO_PAGE_HTML = ` .tile { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; + cursor: pointer; transition: border-color 0.15s, transform 0.15s; } + .tile:hover { border-color: var(--accent); transform: translateY(-1px); } + .tile.loading, .tile.error { cursor: default; } + .tile.loading:hover, .tile.error:hover { border-color: var(--border); transform: none; } .tile-header { padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-size: 13px; font-weight: 600; @@ -89,6 +93,125 @@ export const DEMO_PAGE_HTML = ` } .file-size.ok { color: var(--accent-2); } .file-size.high { color: var(--warn); } + + /* Baseline tile — the unmodified source for comparison */ + .baseline-wrap { margin-bottom: 20px; } + .baseline-tile { + background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; overflow: hidden; + display: grid; grid-template-columns: 1fr 1fr; + cursor: pointer; transition: border-color 0.15s; + } + .baseline-tile:hover { border-color: var(--accent-2); } + .baseline-tile.loading, .baseline-tile.error { cursor: default; } + .baseline-tile.loading:hover, .baseline-tile.error:hover { border-color: var(--border); } + .baseline-tile .baseline-image { + background: #000; + display: flex; align-items: center; justify-content: center; + min-height: 200px; max-height: 360px; overflow: hidden; + } + .baseline-tile .baseline-image img { + max-width: 100%; max-height: 360px; display: block; object-fit: contain; + } + .baseline-tile .baseline-info { + padding: 18px 20px; + display: flex; flex-direction: column; gap: 10px; + } + .baseline-tile .baseline-label { + color: var(--accent-2); font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.08em; + } + .baseline-tile h2 { + margin: 0; font-size: 16px; font-weight: 600; + } + .baseline-tile .baseline-meta { + margin-top: 6px; + font-family: var(--mono); font-size: 11px; + color: var(--text-dim); + display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 6px 16px; + } + .baseline-tile .baseline-meta .row { display: flex; justify-content: space-between; gap: 8px; } + .baseline-tile .baseline-meta strong { color: var(--text); font-weight: 500; } + .baseline-tile .baseline-note { + font-size: 11px; color: var(--text-dim); line-height: 1.5; + border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; + } + .baseline-tile.error .baseline-image { background: #2a1a1a; color: #ff8b8b; font-size: 12px; padding: 16px; text-align: center; } + .baseline-tile.loading .baseline-image::before { + content: "loading…"; color: var(--text-dim); font-size: 12px; + } + @media (max-width: 720px) { + .baseline-tile { grid-template-columns: 1fr; } + } + + /* Modal */ + .modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.85); + display: none; + z-index: 100; + overflow-y: auto; + padding: 24px; + } + .modal-backdrop.open { display: flex; align-items: flex-start; justify-content: center; } + .modal { + background: var(--panel); border: 1px solid var(--border); + border-radius: 12px; + max-width: calc(100vw - 48px); + width: auto; + margin: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + display: flex; flex-direction: column; + } + .modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + gap: 16px; + } + .modal-title { font-size: 15px; font-weight: 600; } + .modal-title .target { color: var(--accent); } + .modal-title .display-info { color: var(--text-dim); font-weight: 400; font-size: 12px; margin-left: 8px; } + .modal-close { + background: var(--panel-2); border: 1px solid var(--border); + color: var(--text); width: 32px; height: 32px; + border-radius: 6px; cursor: pointer; font-size: 18px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + .modal-close:hover { background: var(--border); } + .modal-image-wrap { + background: #000; display: flex; align-items: center; justify-content: center; + padding: 0; max-height: calc(100vh - 240px); + overflow: auto; + } + .modal-image-wrap img { + display: block; height: auto; + /* width is set inline by JS to match target (or viewport cap) */ + } + .modal-meta { + padding: 14px 18px; + font-family: var(--mono); font-size: 12px; + color: var(--text-dim); + display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px 24px; + border-top: 1px solid var(--border); + } + .modal-meta .row { display: flex; justify-content: space-between; gap: 8px; } + .modal-meta .row strong { color: var(--text); font-weight: 500; } + .modal-explanation { + padding: 14px 18px; + font-size: 12px; + color: var(--text-dim); + border-top: 1px solid var(--border); + line-height: 1.6; + } + .modal-explanation code { + background: var(--panel-2); padding: 1px 5px; + border-radius: 3px; font-size: 11px; + color: var(--text); + } details { margin-top: 24px; } summary { cursor: pointer; color: var(--text-dim); font-size: 13px; padding: 8px 0; } details[open] summary { color: var(--text); } @@ -102,7 +225,9 @@ export const DEMO_PAGE_HTML = ` display: flex; gap: 16px; flex-wrap: wrap; font-size: 11px; color: var(--text-dim); margin-top: 12px; } - .legend span::before { + .legend .target::before, + .legend .source::before, + .legend .equal::before { content: "■"; margin-right: 4px; } .legend .target::before { color: var(--accent); } @@ -126,13 +251,16 @@ export const DEMO_PAGE_HTML = `
' + encodeW + '×' + encodeH + ' ' +
+ '(target × 1.5, mod-16 aligned). Your browser is downscaling that to ' + displayWidth + 'px ' +
+ 'for display — that downscale is the artifact filter, the same mechanism canon describes for ' +
+ '"control the character of the loss."';
+ } else if (binding === 'source') {
+ const scaleVerb = encodeW < displayWidth ? 'upscaling' : encodeW > displayWidth ? 'downscaling' : 'rendering 1:1';
+ const scaleClause = encodeW === displayWidth
+ ? 'and your browser is rendering 1:1 at ' + displayWidth + 'px'
+ : 'and your browser is ' + scaleVerb + ' from ' + encodeW + 'px to ' + displayWidth + 'px for display';
+ explain =
+ 'Source is small enough that source × 1.5 = ' + encodeW + 'px binds instead of ' +
+ 'target × 1.5. The proxy encoded at the modest overshoot, ' + scaleClause + '. ' +
+ 'Without the source × 1.5 cap, this would have manufactured pixels from no signal.';
+ } else if (binding === 'equal') {
+ explain =
+ 'Source dimensions already match the target. No scaling at the encoder; the only work is ' +
+ 'format conversion and quality adjustment.';
+ }
+ modalExplanation.innerHTML = explain;
+
+ modalBackdrop.classList.add('open');
+ // Focus close button for keyboard accessibility
+ modalCloseBtn.focus();
+}
+
+function closeModal() {
+ modalBackdrop.classList.remove('open');
+ // Free the image so it doesn't stay in memory
+ modalImageWrap.innerHTML = '';
+}
+
+function openModalForBaseline(tile) {
+ if (tile.classList.contains('loading') || tile.classList.contains('error')) return;
+ const d = tile.dataset;
+ const sourceW = parseInt(d.sourceW || '0', 10);
+ const sourceH = parseInt(d.sourceH || '0', 10);
+ const format = d.format;
+ const cache = d.cache;
+ const size = parseInt(d.size || '0', 10);
+ const path = d.path;
+
+ // For the baseline, show the source at its native width, capped to viewport
+ const viewportCap = window.innerWidth - 80;
+ const displayWidth = sourceW ? Math.min(sourceW, viewportCap) : viewportCap;
+ const isClamped = sourceW && displayWidth < sourceW;
+
+ modalTitle.innerHTML =
+ 'original source' +
+ '' +
+ (sourceW
+ ? (isClamped
+ ? 'displayed at ' + displayWidth + 'px (your viewport is narrower than source ' + sourceW + 'px)'
+ : 'displayed at ' + sourceW + 'px (1:1 with source)')
+ : '') +
+ '';
+
+ const img = document.createElement('img');
+ img.src = path;
+ img.alt = 'original source preview';
+ img.style.width = displayWidth + 'px';
+ modalImageWrap.innerHTML = '';
+ modalImageWrap.appendChild(img);
+
+ modalMeta.innerHTML =
+ 'w, q, or f options applied, so the bytes ' +
+ 'are streamed from the origin unchanged. Use these dimensions and file size as ' +
+ 'the comparison baseline for the transcoded tiles below.';
+
+ modalBackdrop.classList.add('open');
+ modalCloseBtn.focus();
+}
+
+// Tile click → open modal (regular tiles)
+grid.addEventListener('click', (e) => {
+ const tile = e.target.closest('.tile');
+ if (tile) openModalForTile(tile);
+});
+grid.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ const tile = e.target.closest('.tile');
+ if (tile) {
+ e.preventDefault();
+ openModalForTile(tile);
+ }
+ }
+});
+
+// Baseline tile click → open baseline modal
+baselineWrap.addEventListener('click', (e) => {
+ const tile = e.target.closest('.baseline-tile');
+ if (tile) openModalForBaseline(tile);
+});
+baselineWrap.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ const tile = e.target.closest('.baseline-tile');
+ if (tile) {
+ e.preventDefault();
+ openModalForBaseline(tile);
+ }
+ }
+});
+
+// Close handlers
+modalCloseBtn.addEventListener('click', closeModal);
+modalBackdrop.addEventListener('click', (e) => {
+ if (e.target === modalBackdrop) closeModal();
+});
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && modalBackdrop.classList.contains('open')) closeModal();
+});
+
reloadBtn.addEventListener('click', loadGrid);
sourceSelect.addEventListener('change', () => { customUrl.value = ''; loadGrid(); });
customUrl.addEventListener('change', loadGrid);
diff --git a/src/lib/parse-proxy-path.test.ts b/src/lib/parse-proxy-path.test.ts
index 06a5f93..922156a 100644
--- a/src/lib/parse-proxy-path.test.ts
+++ b/src/lib/parse-proxy-path.test.ts
@@ -41,6 +41,18 @@ describe("parseProxyPath — image", () => {
);
});
+ test("reattaches search portion when source URL has query string", () => {
+ // When the browser receives /image/w=800/https://cdn.com/img.jpg?w=2000,
+ // the URL parser splits pathname (/image/w=800/https://cdn.com/img.jpg)
+ // from search (?w=2000). The proxy parser must reattach the search to the
+ // source URL, not lose it.
+ const result = parseProxyPath(
+ "/image/w=800/https://cdn.com/img.jpg",
+ "?w=2000",
+ );
+ expect(result.sourceUrl).toBe("https://cdn.com/img.jpg?w=2000");
+ });
+
test("preserves source URL with path segments", () => {
const result = parseProxyPath(
"/image/w=800/https://example.com/path/to/image.jpg",
diff --git a/src/lib/parse-proxy-path.ts b/src/lib/parse-proxy-path.ts
index a34e748..360a3c1 100644
--- a/src/lib/parse-proxy-path.ts
+++ b/src/lib/parse-proxy-path.ts
@@ -36,7 +36,10 @@ export class ProxyPathError extends Error {
}
}
-export function parseProxyPath(pathname: string): ParsedRequest {
+export function parseProxyPath(
+ pathname: string,
+ search: string = "",
+): ParsedRequest {
// Strip leading slash
const trimmed = pathname.startsWith("/") ? pathname.slice(1) : pathname;
@@ -65,7 +68,12 @@ export function parseProxyPath(pathname: string): ParsedRequest {
throw new ProxyPathError("No source URL found in path");
}
- const sourceUrl = rest.slice(urlStart);
+ // Reattach search/query string to the source URL. The browser splits the
+ // request URL into pathname+search, but the source URL may legitimately
+ // contain a query string (e.g. ?w=2000 for Unsplash, signed URLs, etc.).
+ // The search portion of the request URL is appended to whatever URL we
+ // extract from the path.
+ const sourceUrl = rest.slice(urlStart) + search;
const optionsSegment = rest.slice(0, urlStart).replace(/\/$/, "");
const options = optionsSegment ? parseOptions(optionsSegment) : {};
diff --git a/src/worker.ts b/src/worker.ts
index 22cf6c2..cddb018 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -135,7 +135,8 @@ async function handleImageProxy(
): Promise