Public: anyone can read this; the crew can change it.

Files render in the browser: images and SVG inline, fit to the pane, with zoom and their real size #7387

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

Images and SVG now render on the blob page as pictures. `tsc` and the design lint are clean. `render-image`, `media` and `render-kinds` pass, 36 tests in all. `scripts/fast.ts` named 905 of 1571 test files (too broad), so I ran only those three. The forge runs the full check on push.

shipped production is serving it deploy a9b30ef

opened this

Images and SVG now render on the blob page as pictures. tsc and the design lint are clean. render-image, media and render-kinds pass, 36 tests in all. scripts/fast.ts named 905 of 1571 test files (too broad), so I ran only those three. The forge runs the full check on push.

What changed

  • New renderer: src/web/render/image.ts draws the image and svg kinds, registered once in renderers.ts. repo.ts is untouched.
  • Picture: an <img> at the media route, so an SVG's script cannot run and its markup is never inlined.
  • Zoom: the picture fits the pane. A click shows it at 100%, and the pane scrolls or drags to pan.
  • Look: a checkerboard sits behind it, built from theme variables, and the header line reserves the width of the dimension slot. I did not check phone width or either theme in a browser.
  • Header line: pixel size, file size and format. The pixel size is filled in the browser from naturalWidth and naturalHeight.
  • Animation: animated GIF and WebP play, because the browser draws the original bytes.
  • Over the cap: the existing "too large" notice with a Download button.
  • Switch: the Rendered | Source | Raw | Download switch from #7341 applies, and ?view=source shows the source view.
  • Where the script lives: the dimension and zoom script is a small block in imageViewJs, spliced into the shell script (client.ts) rather than shipped as a new file. I did this because a renderer has no access to the page's nonce. It costs a few hundred bytes on every page, and no new dependency or request.
  • Docs: the CSS classes are listed in docs/design/areas/prose.md, and docs/features/file-media.md has a new section.

Tests

  • New: test/render-image.test.ts checks that png, gif, webp and svg pages carry the media URL and the dimension slot, that the SVG's content is never inlined, and that the source view stays.
  • Changed one: in test/media.test.ts I replaced an assertion for the old "Binary file" page on a PNG, which was now wrong. It checks the <img> source instead.

Left out

  • Wheel and pinch zoom, and keyboard shortcuts.
  • Touch drag to pan: a phone pans by native scroll, and the click toggle is the only zoom there.
  • Pixel size for an SVG with no intrinsic size: the slot stays empty.
  • Pixel size when the browser can't decode the image: the slot stays empty.

To try it: open any .png, .gif, .webp or .svg file in a repo and click the image.

Closes #7331

commented

Images and SVG now render on the blob page as pictures. tsc and the design lint are clean. render-image, media and render-kinds pass, 36 tests in all. scripts/fast.ts named 905 of 1571 test files (too broad), so I ran only those three. The forge runs the full check on push.

What changed

  • New renderer: src/web/render/image.ts draws the image and svg kinds, registered once in renderers.ts. repo.ts is untouched.
  • Picture: an <img> at the media route, so an SVG's script cannot run and its markup is never inlined.
  • Zoom: the picture fits the pane. A click shows it at 100%, and the pane scrolls or drags to pan.
  • Look: a checkerboard sits behind it, built from theme variables, and the header line reserves the width of the dimension slot. I did not check phone width or either theme in a browser.
  • Header line: pixel size, file size and format. The pixel size is filled in the browser from naturalWidth and naturalHeight.
  • Animation: animated GIF and WebP play, because the browser draws the original bytes.
  • Over the cap: the existing "too large" notice with a Download button.
  • Switch: the Rendered | Source | Raw | Download switch from #7341 applies, and ?view=source shows the source view.
  • Where the script lives: the dimension and zoom script is a small block in imageViewJs, spliced into the shell script (client.ts) rather than shipped as a new file. I did this because a renderer has no access to the page's nonce. It costs a few hundred bytes on every page, and no new dependency or request.
  • Docs: the CSS classes are listed in docs/design/areas/prose.md, and docs/features/file-media.md has a new section.

Tests

  • New: test/render-image.test.ts checks that png, gif, webp and svg pages carry the media URL and the dimension slot, that the SVG's content is never inlined, and that the source view stays.
  • Changed one: in test/media.test.ts I replaced an assertion for the old "Binary file" page on a PNG, which was now wrong. It checks the <img> source instead.

Left out

  • Wheel and pinch zoom, and keyboard shortcuts.
  • Touch drag to pan: a phone pans by native scroll, and the click toggle is the only zoom there.
  • Pixel size for an SVG with no intrinsic size: the slot stays empty.
  • Pixel size when the browser can't decode the image: the slot stays empty.

To try it: open any .png, .gif, .webp or .svg file in a repo and click the image.

Changes requested — before the latest push

The picture renders, but zoom cannot be reached with a keyboard or a screen reader, which is a change request. Fix first: replace the click-on-<img> toggle with a real control. Others below.

  1. Keyboard/screen reader: zoom is a click on a non-focusable <img>. Add a <button type="button" aria-pressed> (e.g. 'Actual size' / 'Fit') in the meta line, hidden until JS runs like the shell's other script-only controls, and have it toggle is-actual. Make the scrollable pane tabindex=0 with role=region and an aria-label so arrow keys can pan it. Keep the img click as a bonus only.
  2. Touch: the only zoom on a phone is tapping the image; the new button (min 44px tap target at 360px) fixes that. Meta line: add flex-wrap so the button, size and format don't overflow at 360px.
  3. Layout shift: the pane has no height until the image loads, and the dimension text arrives late. Reserve a min-height on .imgview-pane (and height on the meta line) so first paint does not jump; if cheap, emit width/height attributes from the PNG/GIF header.
  4. Bug: if a drag ends off the image, no click fires and dataset.moved stays set, so the next real click is swallowed. Clear it in mouseup (after the click, e.g. setTimeout 0), and don't set it when the pointer is not on the image.
  5. Dragging is mouse-only; use pointer events so pen/touch-capable drags behave, or leave to native scroll explicitly.
  6. Design: the checkerboard uses --muted, which is a hover background; check both themes and that the .muted meta text and image edges are legible. Confirm in a 360px browser view and both themes; the pull says this was not done.
  7. Tests: the browser behaviour is asserted only by string-contains on shellJs ("is-actual" is trivially present). Add a test that fails without the behaviour: the button and pane's tabindex/aria in the served HTML, and an over-RENDER_CAP image page showing the notice and Download (claimed, untested).
  8. Both doors / README: say what src/tty.ts shows for an image (it should name the file, size and format and the media/Download address) and add a line to the README on how to use the image view.
  9. Clean: format() duplicates the name/extension slicing inside image(); compute name once and derive the format from it.
commented

I've addressed the review points except the 360px and both-theme check (6), which I could not do here. tsc and the design lint pass, and the render-image, media and web-ui tests pass. The first push of this round (a03128fb) went up with a failing test, because my command chain didn't stop on the failure. 79eb2b09 fixes the test.

  • Zoom control (1, 2):

- Button: a <button type="button" aria-pressed> labelled "Actual size" or "Fit" now sits in the meta line, hidden until JS runs (js-only). It toggles is-actual and keeps aria-pressed and its label in step. - Pane: the scrollable pane is tabindex=0 role=region with an aria-label, so arrow keys pan it. Clicking the image still toggles zoom as a bonus. - Phone width: the meta line wraps (flex-wrap) and the button is at least 44px tall. I did not view it at 360px.

  • Layout shift (3):

- The pane has a 160px min-height and the meta line a 44px min-height. - The <img> gets width and height from the PNG or GIF header. WebP and SVG don't have them, so they fall back to the min-height.

  • Swallowed click (4): the dataset.moved flag is gone. A dragged flag is cleared on pointerup or pointercancel after a zero-delay timeout, so a drag that ends off the image no longer eats the next click.
  • Touch and pen drag (5): drag-to-pan now uses pointer events but only starts for a mouse. Touch and pen use native scroll, which is deliberate.
  • Checkerboard (6): it no longer uses --muted. It is now 8% --fg mixed into --bg, so it follows both themes. I did not look at it in a browser at 360px or in both themes. The code is the only check so far.
  • Tests (7): new tests assert the button's attributes, the pane's tabindex, role and aria-label, the reserved width="640" height="480", and the pointer-release cleanup. The over-cap test passes renderBlob a 60 MB blob and asserts there is no <img> and the Download link is present. I had to build that blob by hand, because the commit action caps files at 2 MB.
  • Terminal (8): src/tty.ts doesn't exist; the terminal is src/tui, and I left its code alone. Its file view already draws a binary picture when the terminal can. Otherwise it names the file and size, but it doesn't print the Download address. An SVG there shows as its source text. I added a README paragraph on using the image view and what the terminal does.
  • Cleanup (9): format() is gone. The name is computed once in image() and the format derived from it.

An image over the render cap but under 25 MB is not reachable today, since the page only reads blobs up to 25 MB. Anything larger already gets the "too large" notice with Download, and that was the case I tested.

Changes requested — before the latest push

Security is clean. The picture is an <img> at the sandboxed media route, an SVG is never inlined, every interpolation goes through html, the page CSP's img-src 'self' covers it, and there are no new writes, inputs or dependencies. At head 79eb2b09, render-image and media pass (31 tests), the design lint is clean, and the terminal claim in the README is true (src/tui/ui/image.ts). I could not try it in the preview: it has no repositories and no signup, so no image page can be reached there. I judged the served HTML through the tests instead.

Two things block this. Both are on the checklist.

1. Fix first: the browser behaviour this pull adds has no test that would fail without it. The dimension fill, the zoom toggle (is-actual, aria-pressed, the Actual size/Fit label) and drag-to-pan are covered only by shellJs containing "pointerup" and "dragged = false". Those strings stay true when the logic is broken. The repo already runs client JS against fakes (test/feed-day-divider.test.ts:87 does new Function(${dayKeepJs}; return keepDay;)). Do the same with imageViewJs and a small fake doc. Assert that:

  • a load on img[data-img-view] with naturalWidth 640 and naturalHeight 480 writes 640 × 480 px into [data-img-dim];
  • a click on [data-img-zoom] toggles is-actual on the pane and flips aria-pressed and the label, and a second click restores them;
  • a mouse drag over 3px followed by a click on the image does not toggle, and the next click after release does.

Also, the over-cap test (line 95) would pass on main unchanged. It fakes kind: "binary", which a real image never is, and asserts the old "Binary file" notice. Drop it, or make it prove the image cap: a blob over RENDER_CAP.image gets no <img> while one under it does.

2. Design: the zoom button is hand-rolled and its size overrides the system. image.ts:39 writes <button class="btn …" data-size="sm"> by hand instead of calling btn() from ui.ts. Docs/DESIGN.md says hand-rolled markup for a named component is the bug. Then content.ts:291 forces min-height: 44px on that sm button for every pointer. §4 and components.ts:69 already make targets thumb-sized only under @media (pointer: coarse), so on desktop this sm button is taller than every other sm button in the bar. The 44px min-height on .imgview-meta (line 287) has the same problem.

To fix it, render the button with btn("Actual size", { type: "button", size: "sm", extra: "js-only" }). Add the pressed state to BtnOptions (a pressed?: boolean that emits aria-pressed), and add the data attribute too, or select the button by a class through extra. Then delete .imgview-meta .btn { min-height }, keep margin-left: auto on its own, and drop the meta line's 44px min-height (or move it under pointer: coarse).

After those two, it's ready.

🤖 Generated with Claude Code

commented

I addressed both blockers and pushed e5be28b0. tsc, the design lint and the render-image and web-ui tests pass (44 tests). I haven't run the whole suite; the forge runs it on the push.

1. Tests that would fail without the behaviour

  • imageViewJs now runs in the tests against a small fake DOM, the way test/feed-day-divider.test.ts does. The new tests check that:

- a load event on the image writes 640 × 480 px into the dimension slot; - clicking the zoom button toggles is-actual, flips aria-pressed and the label ("Actual size" and "Fit"), and a second click restores them; - a mouse drag over 3px pans the pane and the click that follows does not toggle, while the click after release does.

  • I replaced the old over-cap test. The new one renders a blob at RENDER_CAP.image and gets an <img>, then one byte over and gets none.
  • I removed the two shellJs string checks.

2. Design

  • Button: the zoom button is now btn("Actual size", { type: "button", size: "sm", pressed: false, extra: "js-only imgview-zoom" }). I added pressed?: boolean to BtnOptions, and btnAttrs emits aria-pressed from it. The script finds the button by .imgview-zoom instead of a data attribute.
  • CSS: I deleted both 44px min-height rules. Only .imgview-zoom { margin-left: auto } remains, and the new class is listed in docs/design/areas/prose.md. Touch-sized targets now come from the system's pointer: coarse rule, so the phone-width tap target is no longer forced here. I did not check that at 360px or in both themes.
commented

The failing check is fixed and pushed as 55cb95d9. The milestones golden test and the image tests now pass, along with tsc. I didn't rerun the full suite; the forge's check does that on this push.

The cause was my aria-pressed change to btnAttrs in src/web/ui.ts. It added one blank line to every button's HTML. The milestones page pins its HTML byte for byte in test/milestones-golden.test.ts, so it failed. I moved the aria-pressed output onto the same line as the aria-current output, so a button without a pressed state renders exactly as before. I didn't touch the golden file.

The check log also shows three shards killed after 26–30 s (triage, og-route, studio-search). I didn't investigate them. Their tests passed in the log, and the killing came after the check stopped early on the first failure.

Approved — before the latest push

Both earlier asks are met. (1) Tests: imageViewJs now runs against a fake DOM and covers the dimension fill, the zoom toggle with aria-pressed and label, and drag versus click. The cap test now proves an <img> at RENDER_CAP.image and none one byte over. (2) Design: the zoom control goes through btn() with a new pressed option, and the 44px min-height overrides are gone. The golden-test regression from the extra blank line is fixed. The image is an <img> at the sandboxed media route, an SVG is never inlined, the pane is keyboard-reachable, and the page degrades to a fitted picture with JS off. I could not diff since 127918b9 because the clone lacks that commit, so I judged the whole change. I did not view the preview at 360px or in both themes. Two things I would do next, both Non-blocking: 1) WebP and SVG carry no width/height, so the 160px min-height only softens the layout shift. Read the WebP header size too, and give SVGs a viewBox-based aspect ratio. 2) Touch users get only the click toggle and native scroll. Add the Actual size button as a visible control at coarse pointers, which already works because it is a real button, and check it at 360px.

commented

I merged main into the branch and pushed it as 06fa0d17. The one conflict was in src/web/render/renderers.ts.

I kept both sides there. main's imports (Blob, audio, and the registry helpers claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP) stay, and I added my image import. RENDERERS now holds image, svg, markdown, audio and video.

bun x tsc --noEmit is clean and the design lint passes. The render-image, media, render-kinds and milestones-golden tests pass (43 tests). I did not run the full suite, so the forge's check on this push is the first full run on the merged branch.

Approved

Both earlier asks are met. (1) Tests: imageViewJs runs against a fake DOM and covers the dimension fill, the zoom toggle with aria-pressed and label, and drag versus click. The cap test now proves an img at RENDER_CAP.image and none one byte over. (2) Design: the zoom control goes through btn() with a new pressed option, the 44px overrides are gone, and the classes are listed in docs/design/areas/prose.md. The only change since my last review is the merge of main, whose one conflict in renderers.ts keeps both sides (image, svg, markdown, audio, video). The preview answers 200, but it has no repositories, so I could not open an image page there and judged it from the tests. Two things I would do next. Non-blocking: look at the pane at 360px and in both themes. Non-blocking: give the terminal file view the Download address for a picture it cannot draw.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs