Public: anyone can read this; the crew can change it.
A look pictures each page in the dark theme as well as the light #3114
Check · Review · Land · Live done — Landed and live in production.
Each page is now pictured in the dark as well as the light, at phone and desktop width. Pushed.
shipped production is serving it deploy e2a191c
Each page is now pictured in the dark as well as the light, at phone and desktop width. Pushed.
What changed
- Dark pictures: when a head has no dark look yet, the look service takes the phone and desktop shots again with the browser's
prefers-color-schemeforced to dark (src/looks/capture.ts,src/looks/service.ts). They are stored as<commit>-phone-dark.png,<commit>-board-desktop-dark.pngand so on, so light and dark files never collide./:owner/:name/looks/…/phone-dark.pngserves them with the same access rules as the light ones. - Theme on a look:
src/core/looks.tshas athemefield onLook(lightordark), andlookSlotandlookUrltake it into account. A new additive migration (20261020110000-a-look-has-a-theme.ts) adds the column with default'light', so older looks read as light. - Listings stay light:
looksForEnvandlooksForAppsreturn only light looks unless you pass"any"or"dark". The pull page, the front page, the studio, the Open Graph image and the check summary therefore look as before. The sweep that finds missing pictures asks for both themes, so a missing dark look is retaken. - Tests:
test/looks.test.tsnow checks that one preview look records both themes for each page, that the dark slots differ from the light ones, and that the dark file is served. A second test checks that a look recorded with no theme reads as light and shares no slot with a dark one. The fake capturer logs dark shots separately so the existing "three shots per head" assertions still hold.docs/APPS.mdhas a short note on this.
Things to look at
- Dark rows share the old unique key: the repository's schema-additive test refuses a table rebuild, so I couldn't widen the table's unique key.
core/looks.tsstores a dark look underpage = "dark:" + page, and decodes it on read. That is the only place that knows. If you'd rather rebuild the table in a later release, the encoding can go. - Not done: there is no dark thumb, because a thumb is the desktop page reduced and stays light. The check summary's vitals table is still light-only.
- How dark is forced: I forced the dark scheme through the browser's media emulation. Apps built on the scaffold's tokens follow
prefers-color-scheme, so that is the lever. I did not use the forge's owndata-themeswitch insrc/design/themes.ts. - Tests: the suites for looks, schema-additive and schema-migrations pass. A wider run of every test mentioning looks or migrations had one more failure, "a young lock that blocks checkout is recovered by a fresh clone" in the run-recovery suite; I didn't look into it. The only other failure is the real-Chromium capture test in
test/looks.test.ts, which fails in this sandbox onmaintoo.
Try it: open a pull whose forge.json sets app.looks, wait for the preview deploy, then fetch /<owner>/<name>/looks/pull-N/<sha>/phone-dark.png.
🤖 Generated with Claude Code
The capture and storage are sound, but nobody can see a dark picture, so the change is not reachable on a page.
- Fix first: show the dark pictures. Every listing is light-only: the pull page, the front page, the studio and the OG image all call looksForEnv/looksForApps, which default to light. The only way to see a dark look is to guess the URL /:owner/:name/looks/<env>/<sha>/phone-dark.png. The direction says done means reachable on the page, so the pull page needs to show each page's dark phone and desktop picture next to the light one. Use the existing look components and tokens from docs/DESIGN.md, with alt text that says dark, and a link that is an <a> and works with JavaScript off. Add a test that the served pull page contains the -dark.png URL.
- Doors. If the pull page lists looks, the terminal must list them too. src/tty.ts currently shows light only, so show both themes there as well.
- Unverified failures. The pull says the run-recovery test "a young lock that blocks checkout is recovered by a fresh clone" failed and was not investigated. Find out whether this change causes it before landing. The real-Chromium capture test is also not exercised here, so the Emulation.setEmulatedMedia call has never been seen to produce a dark picture. Say how you checked that the shot is actually dark.
- Encoding. Storing a dark look as page="dark:"+page is a contained workaround and I accept it for now, but open a follow-up issue to rebuild the unique key and drop it. The cost is doubled capture work per head (4 extra shots per page), so confirm it stays inside the capture timeout budget.
I've pushed the changes. The pull page now shows the dark pictures. Two of juniper's points I couldn't fully meet: the terminal change and the dark-shot check.
1. Dark pictures on the page. The Look card on a pull now shows each page's dark phone and desktop pictures in a second row under the light pair.
- They reuse the existing
look-pairandlookcomponents, so no new CSS or tokens were needed. - Each picture is an
<a>around the<img>, so it works with JavaScript off. The alt text ends "in the dark", for examplehello /board #N on a desktop in the dark. - Readers see them under the same access rule as the light ones (
mayFetchLook). - The two-pages test now checks that the served pull page contains the
…-dark.pngURLs, the link and the dark alt text. - The front page, studio and OG image still use light only. They show one thumbnail, and a thumb stays light.
2. Terminal. There is no src/tty.ts, and the terminal's pull screen (src/tui/screens/pull.ts) doesn't list looks at all, light or dark. I found nothing in src/tui that reads them, so there was nothing to extend and I left it alone. If you want looks in the terminal, that's a separate feature.
3. Verification.
- Recovery test: "a young lock that blocks checkout is recovered by a fresh clone" fails with
name_takenon a clean checkout without my changes, so this change doesn't cause it. - Dark shot: I have not seen a dark picture come out of Chromium. Here it dies with
process_singleton_posix.cc: socket() failed: Operation not permitted, because the sandbox blocks unix sockets. That is also why the existing real-capture test fails here. - New test: I added a test that serves a page whose background is white, or black under
prefers-color-scheme: dark. It takes the shot with and withoutdark, decodes the PNG and expects pixel 255 for light and 0 for dark. It is skipped only when there is no Chromium, so the forge's check will be the first real run of it, and ifEmulation.setEmulatedMediadoesn't work, it fails there.
4. Encoding and budget.
- Follow-up issue: #3126 covers rebuilding the unique key with
themeand dropping thedark:page encoding. - Timeouts: the capture timeout is per shot (20 s), not per head, and shots run one at a time. A page now costs 5 shots instead of 3. Each takes about 2–4 s (load, a 0.4 s settle, browser start), so a page takes about 10–20 s per head. Each shot signs a fresh 60 s grant, so grants don't expire waiting in the queue.
🤖 Generated with Claude Code
Dark pictures are a reasonable slice, and the storage and tests are careful. One thing is wrong on the page, and a few smaller things should follow.
- (Fix first) The dark row can show a different commit than the light row.
lookCardtakesshown(the light picture) and prints 'at <sha> · time' for it.darkOfPageis filtered by page only, andpictured()returns the newest dark look of any commit. After a push, light is retaken first. So for the length of the dark retake (about 10-20 s per page), the card shows the new light pair and the old head's dark pair under one caption. That is the 'which commit am I looking at' lie this card exists to avoid. Filter the dark looks toshown.commitOidbefore picking, and add a test for it.
- The dark pair has no visible name. A sighted person sees a second pair of pictures with no word on them. The only cue is the alt text, and
.look-darkhas no CSS or DESIGN.md entry, so it is a class with no rule. Put amuted smallline such as 'In the dark' above the row, using the existing tokens, and drop the unused class or document it.
- 360px phone:
.look-pairstacks to a column at narrow widths. That makes four tall pictures per page (phone and desktop, light and dark) one under another. Check that the card does not become a wall of images, for example by putting the dark pair in a collapsed<details>, which still works without JavaScript.
- Both doors: the terminal pull screen shows no looks, light or dark, so there is nothing to extend. I accept that, but the README or APPS.md should say the terminal does not show looks.
- The
dark:page-prefix encoding is a workaround. It is acknowledged and tracked in #3126, so I do not block on it.
The real-Chromium dark-pixel test has not run anywhere yet. Watch the forge check for it.
I've addressed points 1–4 of the review, and the dark pair is now labelled and collapsed on the pull page. The type check is clean. In test/looks.test.ts, 16 tests pass and 2 fail. Both failures need real Chromium, and both fail the same way with my change stashed. They are "a real capture through the grant door" and the real-Chromium dark-pixel test, so neither has run here. I'm leaving both to the forge check, as you suggested.
- Commit mismatch (1): the dark pictures in
lookCardare now filtered toshown.commitOidas well as the page. An older head's dark pair can no longer sit under the new head's caption. A new assertion in the pull-page test records a dark look for an older commit. It checks that the commit doesn't appear on the page and that the shown commit's dark picture still does. - Visible name (2): the dark pair now sits in a
<details class="quiet-details">with the summary "In the dark" (muted small). It reuses the existing disclosure style. I dropped the unusedlook-darkclass, so there is no new CSS and noDESIGN.mdentry. The test checks for the label. - Wall of images (3): the dark pair starts collapsed and works without JavaScript. At 360px the light pair is the only thing open by default.
- Terminal (4):
docs/APPS.mdnow says the terminal pull screen shows no looks, light or dark. I put it there rather than the README, since that file already describes looks. dark:prefix (5): untouched, since it's tracked in #3126.
Not done: the terminal still shows no looks, which you accepted.
Dark pictures are stored in their own slot, served with the same access rule as the light ones, and shown in a collapsed "In the dark" fold. The fold is a native details element, so it works without script and does not stack four tall pictures on a 360px phone. Every other listing (front, studio, OG, runner) still defaults to light. The fold reuses quiet-details, so there is no new CSS and no new accent. The new tests cover the slot, the theme default, the stale-commit guard and a real pixel check. I did not run the real-Chromium pixel test here; the forge check will be its first run. The terminal never showed looks, and docs/APPS.md now says so.
Two things to do next:
- Land #3126 to widen the unique key with theme and drop the dark: page encoding, so no reader has to know about the prefix.
- Give the dark pair a way to open without a click when the reader's own system is in dark mode. Consider also a dark row in the check summary vitals, so a dark-only layout shift is caught.
Each page is now pictured in the dark as well as the light, at phone and desktop width. Pushed.
What changed
prefers-color-schemeforced to dark (src/looks/capture.ts,src/looks/service.ts). They are stored as<commit>-phone-dark.png,<commit>-board-desktop-dark.pngand so on, so light and dark files never collide./:owner/:name/looks/…/phone-dark.pngserves them with the same access rules as the light ones.src/core/looks.tshas athemefield onLook(lightordark), andlookSlotandlookUrltake it into account. A new additive migration (20261020110000-a-look-has-a-theme.ts) adds the column with default'light', so older looks read as light.looksForEnvandlooksForAppsreturn only light looks unless you pass"any"or"dark". The pull page, the front page, the studio, the Open Graph image and the check summary therefore look as before. The sweep that finds missing pictures asks for both themes, so a missing dark look is retaken.test/looks.test.tsnow checks that one preview look records both themes for each page, that the dark slots differ from the light ones, and that the dark file is served. A second test checks that a look recorded with no theme reads as light and shares no slot with a dark one. The fake capturer logs dark shots separately so the existing "three shots per head" assertions still hold.docs/APPS.mdhas a short note on this.Things to look at
core/looks.tsstores a dark look underpage = "dark:" + page, and decodes it on read. That is the only place that knows. If you'd rather rebuild the table in a later release, the encoding can go.prefers-color-scheme, so that is the lever. I did not use the forge's owndata-themeswitch insrc/design/themes.ts.test/looks.test.ts, which fails in this sandbox onmaintoo.Try it: open a pull whose
forge.jsonsetsapp.looks, wait for the preview deploy, then fetch/<owner>/<name>/looks/pull-N/<sha>/phone-dark.png.🤖 Generated with Claude Code
Closes #2736