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.
scout/forge:scout/2736-a-look-pictures-each-page-in-the-dark-th → main 3 commits fork 2 threads · 2 unresolved
8 files
changed +265
−33
docs/APPS.md +11
| @@ -806,6 +806,17 @@ slot used to load, so nothing goes blank. Pictures are served at | ||
| 806 | 806 | `/:owner/:name/looks/<env>/<commit>/<page>-<kind>.png` for another, with the |
| 807 | 807 | repository's own read access, cached by commit. |
| 808 | 808 | |
| 809 | Each page is also pictured in the dark (#2736): phone and desktop again, with | |
| 810 | the browser's `prefers-color-scheme` forced to dark, kept in a slot of its own | |
| 811 | (`phone-dark.png`, `board-desktop-dark.png`) with `theme` = `dark` on its row. | |
| 812 | A look recorded before this has no theme and reads as light; the thumb stays | |
| 813 | light, and every listing of looks means the light ones. | |
| 814 | On the pull page the dark pair sits in a collapsed "In the dark" fold under the | |
| 815 | light one (no script needed, and four tall pictures never stack open on a | |
| 816 | phone), and only the dark pictures of the commit the caption names are shown. | |
| 817 | The terminal pull screen shows no looks, light or dark: looks are a web page | |
| 818 | thing. | |
| 819 | ||
| 809 | 820 | The phone and desktop pictures are what the repository's owner would see: |
| 810 | 821 | headless Chromium is pointed at the forge's own listener with the app's |
| 811 | 822 | hostname resolved to loopback, through the grant door, so the app renders for |
src/actions/attach_look.ts +1
| @@ -135,6 +135,7 @@ export function attachLook( | ||
| 135 | 135 | commitOid: pull.headOid, |
| 136 | 136 | page, |
| 137 | 137 | kind, |
| 138 | theme: "light", | |
| 138 | 139 | path, |
| 139 | 140 | createdAt: new Date().toISOString(), |
| 140 | 141 | error: null, |
src/core/looks.ts +63 −16
| @@ -48,6 +48,18 @@ export const LOOK_WINDOWS: Readonly< | ||
| 48 | 48 | */ |
| 49 | 49 | export const LEGACY_THUMB_WIDTH = 200; |
| 50 | 50 | |
| 51 | /** | |
| 52 | * Which colour scheme a picture was taken in. Every look from before dark | |
| 53 | * pictures existed is light; a dark one is taken with the page's | |
| 54 | * `prefers-color-scheme` forced to dark. | |
| 55 | */ | |
| 56 | export type LookTheme = "light" | "dark"; | |
| 57 | export const LOOK_THEMES: readonly LookTheme[] = ["light", "dark"]; | |
| 58 | export const DEFAULT_THEME: LookTheme = "light"; | |
| 59 | ||
| 60 | /** The kinds also pictured in the dark: a thumb is the desktop page reduced, and stays light. */ | |
| 61 | export const DARK_KINDS: readonly LookKind[] = ["phone", "desktop"]; | |
| 62 | ||
| 51 | 63 | /** A look with no page recorded — every row before `app.looks` existed. */ |
| 52 | 64 | export const DEFAULT_PAGE = "/"; |
| 53 | 65 | |
| @@ -66,9 +78,14 @@ const pageSlug = (page: string): string => | ||
| 66 | 78 | * before `app.looks` existed, so two pages of one commit never collide |
| 67 | 79 | * and old names keep meaning what they always meant. |
| 68 | 80 | */ |
| 69 | export const lookSlot = (page: string, kind: LookKind): string => { | |
| 81 | export const lookSlot = ( | |
| 82 | page: string, | |
| 83 | kind: LookKind, | |
| 84 | theme: LookTheme = DEFAULT_THEME, | |
| 85 | ): string => { | |
| 70 | 86 | const slug = pageSlug(page); |
| 71 | return slug === "" ? kind : `${slug}-${kind}`; | |
| 87 | const base = slug === "" ? kind : `${slug}-${kind}`; | |
| 88 | return theme === "dark" ? `${base}-dark` : base; | |
| 72 | 89 | }; |
| 73 | 90 | |
| 74 | 91 | export interface Look { |
| @@ -79,6 +96,8 @@ export interface Look { | ||
| 79 | 96 | /** The page pictured — `forge.json`'s `app.looks`, `/` when unset. */ |
| 80 | 97 | readonly page: string; |
| 81 | 98 | readonly kind: LookKind; |
| 99 | /** The colour scheme it was taken in; light for every look before dark ones. */ | |
| 100 | readonly theme: LookTheme; | |
| 82 | 101 | /** Where the picture is, relative to the data directory; empty on an error. */ |
| 83 | 102 | readonly path: string; |
| 84 | 103 | readonly createdAt: string; |
| @@ -99,6 +118,7 @@ interface Row { | ||
| 99 | 118 | commit_oid: string; |
| 100 | 119 | page: string; |
| 101 | 120 | kind: LookKind; |
| 121 | theme: LookTheme; | |
| 102 | 122 | path: string; |
| 103 | 123 | created_at: string; |
| 104 | 124 | error: string | null; |
| @@ -107,13 +127,27 @@ interface Row { | ||
| 107 | 127 | note: string | null; |
| 108 | 128 | } |
| 109 | 129 | |
| 130 | /** | |
| 131 | * The `page` column of a dark look. The table's unique key is (app, env, | |
| 132 | * commit, page, kind) and a roll never rebuilds it (migrations are | |
| 133 | * additive while an older process still serves), so a dark look is filed | |
| 134 | * under a page no light one can have — pages start with `/` — and this | |
| 135 | * module is the only place that knows. | |
| 136 | */ | |
| 137 | const DARK_PREFIX = "dark:"; | |
| 138 | const stored = (page: string, theme: LookTheme): string => | |
| 139 | theme === "dark" ? `${DARK_PREFIX}${page}` : page; | |
| 140 | const unstored = (page: string): string => | |
| 141 | page.startsWith(DARK_PREFIX) ? page.slice(DARK_PREFIX.length) : page; | |
| 142 | ||
| 110 | 143 | const toLook = (r: Row): Look => ({ |
| 111 | 144 | id: r.id, |
| 112 | 145 | appId: r.app_id, |
| 113 | 146 | env: r.env, |
| 114 | 147 | commitOid: r.commit_oid, |
| 115 | page: r.page, | |
| 148 | page: unstored(r.page), | |
| 116 | 149 | kind: r.kind, |
| 150 | theme: r.theme, | |
| 117 | 151 | path: r.path, |
| 118 | 152 | createdAt: r.created_at, |
| 119 | 153 | error: r.error, |
| @@ -128,6 +162,7 @@ export interface RecordLookInput { | ||
| 128 | 162 | readonly commitOid: string; |
| 129 | 163 | readonly page?: string | undefined; |
| 130 | 164 | readonly kind: LookKind; |
| 165 | readonly theme?: LookTheme | undefined; | |
| 131 | 166 | readonly path: string; |
| 132 | 167 | readonly error?: string | undefined; |
| 133 | 168 | /** Who it was taken as; the owner unless said. */ |
| @@ -140,9 +175,10 @@ export interface RecordLookInput { | ||
| 140 | 175 | /** Record a picture, or the reason there is none. A retake replaces. */ |
| 141 | 176 | export function recordLook(db: Database, input: RecordLookInput): Look { |
| 142 | 177 | const page = input.page ?? DEFAULT_PAGE; |
| 178 | const theme = input.theme ?? DEFAULT_THEME; | |
| 143 | 179 | db.query( |
| 144 | `INSERT INTO look (id, app_id, env, commit_oid, page, kind, path, error, taken_as, attached_by, note) | |
| 145 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) | |
| 180 | `INSERT INTO look (id, app_id, env, commit_oid, page, kind, path, error, taken_as, attached_by, note, theme) | |
| 181 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) | |
| 146 | 182 | ON CONFLICT (app_id, env, commit_oid, page, kind) DO UPDATE SET |
| 147 | 183 | path = excluded.path, error = excluded.error, taken_as = excluded.taken_as, |
| 148 | 184 | created_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, |
| @@ -151,15 +187,16 @@ export function recordLook(db: Database, input: RecordLookInput): Look { | ||
| 151 | 187 | input.appId, |
| 152 | 188 | input.env, |
| 153 | 189 | input.commitOid, |
| 154 | page, | |
| 190 | stored(page, theme), | |
| 155 | 191 | input.kind, |
| 156 | 192 | input.path, |
| 157 | 193 | input.error ?? null, |
| 158 | 194 | input.takenAs ?? "owner", |
| 159 | 195 | input.attachedBy ?? null, |
| 160 | 196 | input.note ?? null, |
| 197 | theme, | |
| 161 | 198 | ); |
| 162 | return getLook(db, input.appId, input.env, input.commitOid, page, input.kind)!; | |
| 199 | return getLook(db, input.appId, input.env, input.commitOid, page, input.kind, theme)!; | |
| 163 | 200 | } |
| 164 | 201 | |
| 165 | 202 | export function getLook( |
| @@ -169,28 +206,38 @@ export function getLook( | ||
| 169 | 206 | commitOid: string, |
| 170 | 207 | page: string, |
| 171 | 208 | kind: LookKind, |
| 209 | theme: LookTheme = DEFAULT_THEME, | |
| 172 | 210 | ): Look | undefined { |
| 173 | 211 | const row = db |
| 174 | 212 | .query< |
| 175 | 213 | Row, |
| 176 | 214 | [string, string, string, string, string] |
| 177 | 215 | >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 AND commit_oid = ?3 AND page = ?4 AND kind = ?5`) |
| 178 | .get(appId, env, commitOid, page, kind); | |
| 216 | .get(appId, env, commitOid, stored(page, theme), kind); | |
| 179 | 217 | return row ? toLook(row) : undefined; |
| 180 | 218 | } |
| 181 | 219 | |
| 182 | /** Every look of one environment, newest first. */ | |
| 183 | export function looksForEnv(db: Database, appId: string, env: string): Look[] { | |
| 220 | /** | |
| 221 | * Every look of one environment, newest first. The light ones unless | |
| 222 | * `theme` says otherwise (`"any"` for both): what shows a page's picture | |
| 223 | * has always meant the light one. | |
| 224 | */ | |
| 225 | export function looksForEnv( | |
| 226 | db: Database, | |
| 227 | appId: string, | |
| 228 | env: string, | |
| 229 | theme: LookTheme | "any" = DEFAULT_THEME, | |
| 230 | ): Look[] { | |
| 184 | 231 | return db |
| 185 | 232 | .query< |
| 186 | 233 | Row, |
| 187 | [string, string] | |
| 188 | >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 ORDER BY created_at DESC, kind`) | |
| 189 | .all(appId, env) | |
| 234 | [string, string, string] | |
| 235 | >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 AND (?3 = 'any' OR theme = ?3) ORDER BY created_at DESC, kind, theme`) | |
| 236 | .all(appId, env, theme) | |
| 190 | 237 | .map(toLook); |
| 191 | 238 | } |
| 192 | 239 | |
| 193 | /** Every look of several apps in one read, newest first. */ | |
| 240 | /** Every light look of several apps in one read, newest first. */ | |
| 194 | 241 | export function looksForApps(db: Database, appIds: readonly string[]): Look[] { |
| 195 | 242 | const out: Look[] = []; |
| 196 | 243 | const unique = [...new Set(appIds)]; |
| @@ -199,7 +246,7 @@ export function looksForApps(db: Database, appIds: readonly string[]): Look[] { | ||
| 199 | 246 | const batch = unique.slice(i, i + CHUNK); |
| 200 | 247 | const rows = db |
| 201 | 248 | .query<Row, string[]>( |
| 202 | `SELECT * FROM look WHERE app_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")}) | |
| 249 | `SELECT * FROM look WHERE theme = 'light' AND app_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")}) | |
| 203 | 250 | ORDER BY created_at DESC, kind`, |
| 204 | 251 | ) |
| 205 | 252 | .all(...batch); |
| @@ -250,7 +297,7 @@ export const lookVersion = (look: Look): string => | ||
| 250 | 297 | * `app.looks` existed — and by which taking of it, so a browser that kept |
| 251 | 298 | * the last one for a year asks for the new one. */ |
| 252 | 299 | export const lookUrl = (base: string, look: Look): string => |
| 253 | `${base}/looks/${look.env}/${look.commitOid}/${lookSlot(look.page, look.kind)}.png?v=${lookVersion(look)}`; | |
| 300 | `${base}/looks/${look.env}/${look.commitOid}/${lookSlot(look.page, look.kind, look.theme)}.png?v=${lookVersion(look)}`; | |
| 254 | 301 | |
| 255 | 302 | /** Whether a look was attached by a person, not taken by the forge. */ |
| 256 | 303 | export const isAttached = (look: Look): boolean => look.attachedBy !== null; |
src/core/migrations/20261020110000-a-look-has-a-theme.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | // A look knows which colour scheme it was taken in (#2736). `theme` | |
| 2 | // defaults to 'light', so every row from before dark pictures reads as | |
| 3 | // exactly what it was. Additive, so an older process keeps serving across | |
| 4 | // a roll: the unique key is left as it is, and `core/looks.ts` files a | |
| 5 | // dark look under a page of its own. | |
| 6 | export default ` | |
| 7 | ALTER TABLE look ADD COLUMN theme TEXT NOT NULL DEFAULT 'light' | |
| 8 | CHECK (theme IN ('light','dark')); | |
| 9 | `; |
src/looks/capture.ts +8
| @@ -49,6 +49,8 @@ export interface LookInput { | ||
| 49 | 49 | readonly out: string; |
| 50 | 50 | readonly width: number; |
| 51 | 51 | readonly height: number; |
| 52 | /** Force the page's `prefers-color-scheme` to dark; light (the browser's own) when unset. */ | |
| 53 | readonly dark?: boolean | undefined; | |
| 52 | 54 | /** A browser that has no picture by then is killed; the look fails. */ |
| 53 | 55 | readonly timeoutMs: number; |
| 54 | 56 | } |
| @@ -406,6 +408,12 @@ async function shoot( | ||
| 406 | 408 | }, |
| 407 | 409 | sessionId, |
| 408 | 410 | ); |
| 411 | if (input.dark === true) | |
| 412 | await dt.send( | |
| 413 | "Emulation.setEmulatedMedia", | |
| 414 | { features: [{ name: "prefers-color-scheme", value: "dark" }] }, | |
| 415 | sessionId, | |
| 416 | ); | |
| 409 | 417 | // Before navigation: the observer is in place before the page's first script. |
| 410 | 418 | await dt.send( |
| 411 | 419 | "Page.addScriptToEvaluateOnNewDocument", |
src/looks/service.ts +22 −10
| @@ -44,7 +44,11 @@ import { | ||
| 44 | 44 | deleteLooks, |
| 45 | 45 | DEFAULT_PAGE, |
| 46 | 46 | LEGACY_THUMB_WIDTH, |
| 47 | DARK_KINDS, | |
| 48 | DEFAULT_THEME, | |
| 47 | 49 | LOOK_KINDS, |
| 50 | LOOK_THEMES, | |
| 51 | type LookTheme, | |
| 48 | 52 | LOOK_SIZES, |
| 49 | 53 | LOOK_WINDOWS, |
| 50 | 54 | lookSlot, |
| @@ -154,8 +158,9 @@ export const lookPath = ( | ||
| 154 | 158 | commitOid: string, |
| 155 | 159 | page: string, |
| 156 | 160 | kind: LookKind, |
| 161 | theme: LookTheme = DEFAULT_THEME, | |
| 157 | 162 | ): string => |
| 158 | join("looks", appId, env, `${commitOid}-${lookSlot(page, kind)}.png`); | |
| 163 | join("looks", appId, env, `${commitOid}-${lookSlot(page, kind, theme)}.png`); | |
| 159 | 164 | |
| 160 | 165 | /** |
| 161 | 166 | * Whether a picture on record is one to keep. Every one is, except a |
| @@ -374,7 +379,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 374 | 379 | let allOk = true; |
| 375 | 380 | // What each look's page recorded, for the check summary's vitals table. |
| 376 | 381 | const vitals: LookVitals[] = []; |
| 377 | for (const { page, kind } of wanted) { | |
| 382 | for (const { page, kind, theme } of wanted) { | |
| 378 | 383 | if (stopped) return; |
| 379 | 384 | const token = signGrant( |
| 380 | 385 | dataDir, |
| @@ -384,7 +389,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 384 | 389 | asNobody(kind) ? undefined : asOwner, |
| 385 | 390 | ); |
| 386 | 391 | const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`; |
| 387 | const path = lookPath(item.appId, item.env, item.commitOid, page, kind); | |
| 392 | const path = lookPath(item.appId, item.env, item.commitOid, page, kind, theme); | |
| 388 | 393 | const out = join(dataDir, path); |
| 389 | 394 | // Taken to one side and moved in whole. A pull that closes while its |
| 390 | 395 | // picture is being taken sweeps its environment's folder away, and a |
| @@ -392,17 +397,18 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 392 | 397 | // give; this way the sweep and the picture never meet. |
| 393 | 398 | const staged = join( |
| 394 | 399 | stagingRoot(dataDir), |
| 395 | `${item.appId}-${item.env}-${item.commitOid}-${lookSlot(page, kind)}.png`, | |
| 400 | `${item.appId}-${item.env}-${item.commitOid}-${lookSlot(page, kind, theme)}.png`, | |
| 396 | 401 | ); |
| 397 | 402 | mkdirSync(dirname(staged), { recursive: true }); |
| 398 | 403 | const shot = await capture({ |
| 399 | 404 | url, |
| 400 | 405 | out: staged, |
| 401 | 406 | ...LOOK_WINDOWS[kind], |
| 407 | ...(theme === "dark" ? { dark: true } : {}), | |
| 402 | 408 | timeoutMs: CAPTURE_TIMEOUT_MS, |
| 403 | 409 | }); |
| 404 | 410 | // The thumb is the desktop page again, as a stranger: it adds no row. |
| 405 | if (kind !== "thumb") { | |
| 411 | if (kind !== "thumb" && theme === "light") { | |
| 406 | 412 | const look = page === "/" ? kind : `${kind} ${page}`; |
| 407 | 413 | if (shot.ok) vitals.push({ look, entries: shot.value.entries }); |
| 408 | 414 | else if (isNoBrowser(shot.error)) |
| @@ -415,6 +421,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 415 | 421 | env: item.env, |
| 416 | 422 | commitOid: item.commitOid, |
| 417 | 423 | page, |
| 424 | theme, | |
| 418 | 425 | }; |
| 419 | 426 | if (taken.ok) { |
| 420 | 427 | // The environment moved on while the picture was taken: it shows |
| @@ -444,6 +451,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 444 | 451 | env: item.env, |
| 445 | 452 | page, |
| 446 | 453 | kind, |
| 454 | theme, | |
| 447 | 455 | error: taken.error, |
| 448 | 456 | }); |
| 449 | 457 | recordLook(app.db, { ...row, kind, path: "", error: taken.error }); |
| @@ -480,19 +488,23 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 480 | 488 | commitOid: string, |
| 481 | 489 | pages: readonly string[] | undefined, |
| 482 | 490 | failedCounts = false, |
| 483 | ): { page: string; kind: LookKind }[] { | |
| 491 | ): { page: string; kind: LookKind; theme: LookTheme }[] { | |
| 484 | 492 | const have = new Set( |
| 485 | looksForEnv(app.db, appId, env) | |
| 493 | looksForEnv(app.db, appId, env, "any") | |
| 486 | 494 | .filter( |
| 487 | 495 | (l) => |
| 488 | 496 | l.commitOid === commitOid && |
| 489 | 497 | (keepLook(dataDir, l) || (failedCounts && l.error !== null)), |
| 490 | 498 | ) |
| 491 | .map((l) => lookSlot(l.page, l.kind)), | |
| 499 | .map((l) => lookSlot(l.page, l.kind, l.theme)), | |
| 492 | 500 | ); |
| 493 | 501 | return (pages ?? [DEFAULT_PAGE]).flatMap((page) => |
| 494 | LOOK_KINDS.filter((kind) => !have.has(lookSlot(page, kind))).map( | |
| 495 | (kind) => ({ page, kind }), | |
| 502 | LOOK_THEMES.flatMap((theme) => | |
| 503 | LOOK_KINDS.filter( | |
| 504 | (kind) => | |
| 505 | (theme === "light" || DARK_KINDS.includes(kind)) && | |
| 506 | !have.has(lookSlot(page, kind, theme)), | |
| 507 | ).map((kind) => ({ page, kind, theme })), | |
| 496 | 508 | ), |
| 497 | 509 | ); |
| 498 | 510 | } |
src/web/pages/looks.ts +25 −4
| @@ -73,8 +73,8 @@ export function lookImage(ctx: Ctx): Response { | ||
| 73 | 73 | return notFound(); |
| 74 | 74 | const record = getAppByRepo(ctx.app.db, repo.id); |
| 75 | 75 | if (!record) return notFound(); |
| 76 | const look = looksForEnv(ctx.app.db, record.id, env).find( | |
| 77 | (l) => l.commitOid === commit && lookSlot(l.page, l.kind) === slot, | |
| 76 | const look = looksForEnv(ctx.app.db, record.id, env, "any").find( | |
| 77 | (l) => l.commitOid === commit && lookSlot(l.page, l.kind, l.theme) === slot, | |
| 78 | 78 | ); |
| 79 | 79 | if (look === undefined || look.error !== null) return notFound(); |
| 80 | 80 | if (!mayFetchLook(ctx, repo, record, look)) return notFound(); |
| @@ -142,11 +142,14 @@ export function lookCard( | ||
| 142 | 142 | // phone or desktop picture taken as nobody (a public app's), and no card |
| 143 | 143 | // without one. |
| 144 | 144 | const writer = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user); |
| 145 | const looks = looksForEnv(ctx.app.db, record.id, env).filter( | |
| 145 | const every = looksForEnv(ctx.app.db, record.id, env, "any").filter( | |
| 146 | 146 | (l) => |
| 147 | 147 | writer || |
| 148 | 148 | (l.kind !== "thumb" && l.error === null && mayFetchLook(ctx, repo, record, l)), |
| 149 | 149 | ); |
| 150 | // The dark pictures sit beside the light ones, never in place of them. | |
| 151 | const dark = every.filter((l) => l.theme === "dark"); | |
| 152 | const looks = every.filter((l) => l.theme === "light"); | |
| 150 | 153 | if (!writer && looks.length === 0) return html``; |
| 151 | 154 | const attached = looks.filter(isAttached).reverse(); |
| 152 | 155 | const taken = looks.filter((l) => !isAttached(l)); |
| @@ -169,6 +172,7 @@ export function lookCard( | ||
| 169 | 172 | const labelled = pages.length > 1; |
| 170 | 173 | const picture = (look: Look, kind: LookKind, page: string): Html => { |
| 171 | 174 | const url = lookUrl(base, look); |
| 175 | const scheme = look.theme === "dark" ? " in the dark" : ""; | |
| 172 | 176 | return html`<a class="look look-${kind}" href="${url}"> |
| 173 | 177 | <img |
| 174 | 178 | src="${url}" |
| @@ -176,7 +180,7 @@ export function lookCard( | ||
| 176 | 180 | height="${LOOK_SIZES[kind].height}" |
| 177 | 181 | alt="${repo.name}${page === DEFAULT_PAGE |
| 178 | 182 | ? "" |
| 179 | : ` ${page}`} #${pull.number} on a ${kind}" | |
| 183 | : ` ${page}`} #${pull.number} on a ${kind}${scheme}" | |
| 180 | 184 | loading="lazy" |
| 181 | 185 | /> |
| 182 | 186 | </a>`; |
| @@ -202,12 +206,29 @@ export function lookCard( | ||
| 202 | 206 | </p> |
| 203 | 207 | </div>`; |
| 204 | 208 | const moved = shown.commitOid !== headOid; |
| 209 | // Only the dark pictures of the commit the caption names: after a push | |
| 210 | // the light pair is retaken first, and an old head's dark pair must not | |
| 211 | // sit under the new head's words. | |
| 212 | const darkOfPage = dark.filter( | |
| 213 | (l) => l.page === page && l.commitOid === shown.commitOid, | |
| 214 | ); | |
| 215 | const darkPhone = pictured(darkOfPage, "phone"); | |
| 216 | const darkDesktop = pictured(darkOfPage, "desktop"); | |
| 205 | 217 | return html`<div class="look-group"> |
| 206 | 218 | ${label} |
outdated
Give this row a visible 'In the dark' label with existing tokens. |
||
| 207 | 219 | <div class="look-pair"> |
| 208 | 220 | ${when(phone, (l) => picture(l, "phone", page))} |
| 209 | 221 | ${when(desktop, (l) => picture(l, "desktop", page))} |
| 210 | 222 | </div> |
| 223 | ${when(darkPhone ?? darkDesktop, () => | |
| 224 | html`<details class="quiet-details"> | |
| 225 | <summary class="muted small">In the dark</summary> | |
| 226 | <div class="look-pair"> | |
| 227 | ${when(darkPhone, (l) => picture(l, "phone", page))} | |
| 228 | ${when(darkDesktop, (l) => picture(l, "desktop", page))} | |
| 229 | </div> | |
| 230 | </details>`, | |
| 231 | )} | |
| 211 | 232 | <span class="muted small look-of" |
| 212 | 233 | >at ${sha(shown.commitOid, `${base}/commit/${shown.commitOid}`)} · |
| 213 | 234 | ${time(shown.createdAt)}${when( |
test/looks.test.ts +126 −3
| @@ -24,8 +24,8 @@ import { readGrant } from "../src/apps/proxy.ts"; | ||
| 24 | 24 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 25 | 25 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 26 | 26 | import { listEvents } from "../src/core/events.ts"; |
| 27 | import { looksForEnv, recordLook, type Look } from "../src/core/looks.ts"; | |
| 28 | import { encodePng } from "../src/design/image/png.ts"; | |
| 27 | import { lookSlot, lookUrl, looksForEnv, recordLook, type Look } from "../src/core/looks.ts"; | |
| 28 | import { decodePng, encodePng } from "../src/design/image/png.ts"; | |
| 29 | 29 | import { setAppMembers } from "../src/core/app_members.ts"; |
| 30 | 30 | import { listPulls, type Pull } from "../src/core/pulls.ts"; |
| 31 | 31 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| @@ -83,8 +83,10 @@ describe("the look", () => { | ||
| 83 | 83 | // The stand-in browser: writes a picture, or falls over, as told. |
| 84 | 84 | let mode: "ok" | "fail" | "nobrowser" = "ok"; |
| 85 | 85 | const calls: LookInput[] = []; |
| 86 | // The dark pictures, kept apart so what counts the light ones stays true. | |
| 87 | const darkCalls: LookInput[] = []; | |
| 86 | 88 | const fake: Capture = async (input) => { |
| 87 | calls.push(input); | |
| 89 | (input.dark === true ? darkCalls : calls).push(input); | |
| 88 | 90 | if (mode === "fail") return err("the browser fell over"); |
| 89 | 91 | if (mode === "nobrowser") return err(NO_BROWSER); |
| 90 | 92 | writeFileSync(input.out, input.width === 1280 ? DESKTOP_PNG : PNG); |
| @@ -474,6 +476,31 @@ describe("the look", () => { | ||
| 474 | 476 | mode = "ok"; |
| 475 | 477 | }, 120_000); |
| 476 | 478 | |
| 479 | test("a look recorded with no theme reads as light, and light and dark never share a slot", () => { | |
| 480 | const base = { | |
| 481 | appId: record.id, | |
| 482 | env: "production", | |
| 483 | commitOid: "a".repeat(40), | |
| 484 | kind: "phone" as const, | |
| 485 | path: "looks/x.png", | |
| 486 | }; | |
| 487 | const old = recordLook(app.db, base); | |
| 488 | expect(old.theme).toBe("light"); | |
| 489 | const dark = recordLook(app.db, { ...base, theme: "dark", path: "looks/x-dark.png" }); | |
| 490 | expect(dark.id).not.toBe(old.id); | |
| 491 | expect(dark.theme).toBe("dark"); | |
| 492 | expect(lookSlot("/", "phone")).toBe("phone"); | |
| 493 | expect(lookSlot("/", "phone", "dark")).toBe("phone-dark"); | |
| 494 | expect(lookSlot("/board", "phone", "dark")).toBe("board-phone-dark"); | |
| 495 | const mine = (theme: "light" | "dark" | "any") => | |
| 496 | looksForEnv(app.db, record.id, "production", theme).filter( | |
| 497 | (l) => l.commitOid === base.commitOid, | |
| 498 | ); | |
| 499 | expect(mine("light").map((l) => l.path)).toEqual(["looks/x.png"]); | |
| 500 | expect(mine("dark").map((l) => l.path)).toEqual(["looks/x-dark.png"]); | |
| 501 | expect(mine("any")).toHaveLength(2); | |
| 502 | }); | |
| 503 | ||
| 477 | 504 | test("what a page recorded is kept as plain numbers, a missing type as an empty list", () => { |
| 478 | 505 | expect( |
| 479 | 506 | parseRecorded({ |
| @@ -746,6 +773,65 @@ describe("the look", () => { | ||
| 746 | 773 | `alt="hello /board #${opened.value.number} on a phone"`, |
| 747 | 774 | ); |
| 748 | 775 | expect(page).toContain(`alt="hello #${opened.value.number} on a phone"`); |
| 776 | ||
| 777 | // Each page is also pictured in the dark, at phone and desktop, in a | |
| 778 | // slot of its own: the same page and kind, a different file. | |
| 779 | const dark = await until(() => { | |
| 780 | const found = looksForEnv(app.db, record.id, env, "dark").filter( | |
| 781 | (l) => l.commitOid === commitOid, | |
| 782 | ); | |
| 783 | return found.length === 4 ? found : undefined; | |
| 784 | }, 30_000); | |
| 785 | expect(dark.every((l) => l.theme === "dark")).toBe(true); | |
| 786 | expect(dark.map((l) => l.kind).sort()).toEqual(["desktop", "desktop", "phone", "phone"]); | |
| 787 | expect(darkCalls.every((c) => c.dark === true)).toBe(true); | |
| 788 | const darkPhone = dark.find((l) => l.page === "/" && l.kind === "phone")!; | |
| 789 | expect(darkPhone.path).toBe( | |
| 790 | `looks/${record.id}/${env}/${commitOid}-phone-dark.png`, | |
| 791 | ); | |
| 792 | expect(darkPhone.path).not.toBe(homePhone.path); | |
| 793 | expect(existsSync(join(dataDir, darkPhone.path))).toBe(true); | |
| 794 | expect(existsSync(join(dataDir, homePhone.path))).toBe(true); | |
| 795 | // The light listing is what every page has always shown: no dark row in it. | |
| 796 | expect(rows.some((l) => l.theme === "dark")).toBe(false); | |
| 797 | const served = await web( | |
| 798 | `/alice/hello/looks/${env}/${commitOid}/phone-dark.png`, | |
| 799 | aliceCookie, | |
| 800 | ); | |
| 801 | expect(served.status).toBe(200); | |
| 802 | expect(lookUrl("/alice/hello", darkPhone)).toContain("/phone-dark.png?v="); | |
| 803 | // And it is on the pull page, as a link that works without script. | |
| 804 | const darkPage = await ( | |
| 805 | await web(`/alice/hello/pulls/${opened.value.number}`, aliceCookie) | |
| 806 | ).text(); | |
| 807 | expect(darkPage).toContain( | |
| 808 | `<a class="look look-phone" href="/alice/hello/looks/${env}/${commitOid}/phone-dark.png?v=`, | |
| 809 | ); | |
| 810 | expect(darkPage).toContain( | |
| 811 | `src="/alice/hello/looks/${env}/${commitOid}/board-desktop-dark.png?v=`, | |
| 812 | ); | |
| 813 | expect(darkPage).toContain( | |
| 814 | `alt="hello /board #${opened.value.number} on a desktop in the dark"`, | |
| 815 | ); | |
| 816 | // The dark pair is named, and folded away until asked for. | |
| 817 | expect(darkPage).toMatch( | |
| 818 | /<details class="quiet-details">\s*<summary class="muted small">In the dark<\/summary>/, | |
| 819 | ); | |
| 820 | // An older head's dark picture never sits under the shown commit's caption. | |
| 821 | const stale = "b".repeat(40); | |
| 822 | recordLook(app.db, { | |
| 823 | appId: record.id, | |
| 824 | env, | |
| 825 | commitOid: stale, | |
| 826 | kind: "phone", | |
| 827 | theme: "dark", | |
| 828 | path: `looks/${record.id}/${env}/${stale}-phone-dark.png`, | |
| 829 | }); | |
| 830 | const after = await ( | |
| 831 | await web(`/alice/hello/pulls/${opened.value.number}`, aliceCookie) | |
| 832 | ).text(); | |
| 833 | expect(after).not.toContain(stale); | |
| 834 | expect(after).toContain(`${commitOid}/phone-dark.png?v=`); | |
| 749 | 835 | }, 120_000); |
| 750 | 836 | |
| 751 | 837 | test("the sweep pictures a running production missing one: a thumb from before thumbs were the page is retaken as a stranger; one that failed is not retried each sweep", async () => { |
| @@ -1105,4 +1191,41 @@ describe("the look", () => { | ||
| 1105 | 1191 | }, |
| 1106 | 1192 | 120_000, |
| 1107 | 1193 | ); |
| 1194 | test.skipIf(chromium === undefined)( | |
| 1195 | "a dark capture is truly dark: the same page, light and dark, differs in its pixels", | |
| 1196 | async () => { | |
| 1197 | const page = Bun.serve({ | |
| 1198 | port: 0, | |
| 1199 | hostname: "127.0.0.1", | |
| 1200 | fetch: () => | |
| 1201 | new Response( | |
| 1202 | "<!doctype html><style>body{margin:0;background:#fff}@media (prefers-color-scheme: dark){body{background:#000}}</style><p>hi</p>", | |
| 1203 | { headers: { "content-type": "text/html" } }, | |
| 1204 | ), | |
| 1205 | }); | |
| 1206 | try { | |
| 1207 | const corner = async (dark: boolean): Promise<number> => { | |
| 1208 | const out = join(dataDir, `scheme-${dark ? "dark" : "light"}.png`); | |
| 1209 | const taken = await takeLook({ | |
| 1210 | url: `http://127.0.0.1:${page.port}/`, | |
| 1211 | out, | |
| 1212 | width: 390, | |
| 1213 | height: 844, | |
| 1214 | dark, | |
| 1215 | timeoutMs: 30_000, | |
| 1216 | }); | |
| 1217 | expect(taken.ok).toBe(true); | |
| 1218 | const image = decodePng(readFileSync(out)); | |
| 1219 | if (!image.ok) throw new Error(image.error); | |
| 1220 | const last = image.value.rgba.length - 4; | |
| 1221 | return image.value.rgba[last]!; | |
| 1222 | }; | |
| 1223 | expect(await corner(false)).toBe(255); | |
| 1224 | expect(await corner(true)).toBe(0); | |
| 1225 | } finally { | |
| 1226 | page.stop(true); | |
| 1227 | } | |
| 1228 | }, | |
| 1229 | 120_000, | |
| 1230 | ); | |
| 1108 | 1231 | }); |
new above
offline
Filter by
shown.commitOidhere, otherwise an old head's dark pictures sit under the new head's caption. Add a test.