Public: anyone can read this; the crew can change it.
Design: the looks page is built from the design system, and its look-* classes leave §5d #3221
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3198-design-the-looks-page-is-built-from-the → main 1 commit fork
5 files
changed +60
−61
docs/DESIGN.md +2 −1
| @@ -205,6 +205,7 @@ specification. | ||
| 205 | 205 | | **run log** | `pre.run-log` | `data-run` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script appends the run's lines from the page's one `/events` stream and follows the bottom until the reader scrolls up; the page never reloads itself | a scrolling pane that follows the tail | |
| 206 | 206 | | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents | |
| 207 | 207 | | **inbox** (`asks_page.ts`) | one list above the feed | — | _On you_: what asks you and what is assigned to you, one row each, each with what it waits for (`needsYouSection`), and one count in the top bar. Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard | |
| 208 | | **look** (`pages/looks.ts`) | `.look` in a `.looks` strip | `.look[data-slot=phone\|desktop\|thumb\|now]` sizes the frame; `.looks[data-compare]` sets two sides (before · main, then after · the pull) beside each other | a picture always carries its own `width`/`height`, so nothing shifts when it loads; a phone keeps 200px, a desktop takes the rest up to its own size, and under 640px a strip stacks; an attached picture is a `figure` in the strip with its line under it | the picture in the terminal (§6·0c) | | |
| 208 | 209 | | **board** (`pages/board.ts`) | `.board` (`.board-grid`, `.board-region`) | `data-wall` (across the room: no keys, no pointer, larger), `data-focus=working\|landed\|decided\|today` | the one whole-screen layout: four live regions fill a second monitor ≥1100px and each list scrolls on its own, one column below; inside, only §5 parts (list card rows, badges, meters, `kbd`); a swapped region's heading wears `data-fresh` for a moment | four panes, one focused by its number | |
| 209 | 210 | |
| 210 | 211 | Two things are deliberately **not** in the vocabulary: skeleton loaders (the |
| @@ -387,7 +388,7 @@ gone, so this list only shrinks. | ||
| 387 | 388 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 388 | 389 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-body` `.rc-form` `.rc-head` `.rc-outdated` `.rc-resolved-line` `.wd` |
| 389 | 390 | - **`style/content.ts`, review verdicts** — the body under a verdict: `.review-body` |
| 390 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs, checks and look screenshots (a pull's look sets the before side, main's, sits first, then the pull's after side: a `.look-compare` of two `.look-side`): `.app-url` `.check-progress` `.check-steps` `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.log-tail-row` `.look` `.look-attached` `.look-attached-phone` `.look-attachments` `.look-card` `.look-compare` `.look-desktop` `.look-now` `.look-of` `.look-pair` `.look-phone` `.look-side` `.look-thumb` `.pinned-failures` `.pinned-slot` `.run-actions` `.run-jump` `.run-live` `.run-steer` `.run-summary` `.step-state` `.tests-bar` `.tests-words` `.txt` `.who` | |
| 391 | - **`style/content.ts`, apps, runs, and the crew** — run pages, their logs and checks (a pull's look is §5's **look**): `.app-url` `.check-progress` `.check-steps` `.ln-forge` `.ln-pulse` `.ln-say` `.ln-tool` `.ln-you` `.log-tail-row` `.pinned-failures` `.pinned-slot` `.run-actions` `.run-jump` `.run-live` `.run-steer` `.run-summary` `.step-state` `.tests-bar` `.tests-words` `.txt` `.who` | |
| 391 | 392 | - **`style/content.ts`, the code browser: explorer beside content** — the explorer column's own parts: `.code-main` `.code-split` `.ex-empty` `.ex-find` `.ex-head` `.ex-list` `.ex-name` `.ex-ref` `.ex-twist` `.explorer-toggle` |
| 392 | 393 | - **`style/content.ts`, the finder** — the finder's footnote: `.finder-note` |
| 393 | 394 | - **`style/content.ts`, the editor** — the editor's parts beyond the §5 row: `.editor-mirror` `.editor-path` `.editor-preview` `.editor-stack` `.editor-tab` `.mirrored` `.ml` |
src/web/pages/looks.ts +31 −29
| @@ -161,7 +161,7 @@ export function lookCard( | ||
| 161 | 161 | html`<p class="muted small"> |
| 162 | 162 | No picture yet. One is taken when the preview comes up green. |
| 163 | 163 | </p>`, |
| 164 | { head, extra: "look-card" }, | |
| 164 | { head }, | |
| 165 | 165 | ); |
| 166 | 166 | } |
| 167 | 167 | // Home first, then the rest in the order the manifest is likeliest to |
| @@ -182,7 +182,7 @@ export function lookCard( | ||
| 182 | 182 | ): Html => { |
| 183 | 183 | const url = lookUrl(base, look); |
| 184 | 184 | const scheme = look.theme === "dark" ? " in the dark" : ""; |
| 185 | return html`<a class="look look-${kind}" href="${url}"> | |
| 185 | return html`<a class="look" data-slot="${kind}" href="${url}"> | |
| 186 | 186 | <img |
| 187 | 187 | src="${url}" |
| 188 | 188 | width="${LOOK_SIZES[kind].width}" |
| @@ -202,18 +202,18 @@ export function lookCard( | ||
| 202 | 202 | (l) => l.commitOid === headOid && l.error !== null, |
| 203 | 203 | ); |
| 204 | 204 | const label = when(labelled, () => |
| 205 | html`<p class="muted small look-page">${page}</p>`, | |
| 205 | html`<p class="muted small">${page}</p>`, | |
| 206 | 206 | ); |
| 207 | 207 | const shown = phone ?? desktop; |
| 208 | 208 | if (shown === undefined) |
| 209 | return html`<div class="look-group"> | |
| 209 | return html`<section class="stack stack-tight"> | |
| 210 | 210 | ${label} |
| 211 | 211 | <p class="muted small"> |
| 212 | 212 | ${failed === undefined |
| 213 | 213 | ? "No picture yet. One is taken when the preview comes up green." |
| 214 | 214 | : html`No picture of ${sha(headOid)}: ${failed.error}.`} |
| 215 | 215 | </p> |
| 216 | </div>`; | |
| 216 | </section>`; | |
| 217 | 217 | const moved = shown.commitOid !== headOid; |
| 218 | 218 | const mainOfPage = mainLooks.filter((l) => l.page === page); |
| 219 | 219 | const beforePhone = pictured(mainOfPage, "phone"); |
| @@ -231,26 +231,26 @@ export function lookCard( | ||
| 231 | 231 | ); |
| 232 | 232 | const darkPhone = pictured(darkOfPage, "phone"); |
| 233 | 233 | const darkDesktop = pictured(darkOfPage, "desktop"); |
| 234 | return html`<div class="look-group"> | |
| 234 | return html`<section class="stack stack-tight"> | |
| 235 | 235 | ${label} |
| 236 | <div class="look-compare"> | |
| 237 | <div class="look-side look-before"> | |
| 236 | <div class="looks" data-compare> | |
| 237 | <div> | |
| 238 | 238 | <p class="muted small">Before · main</p> |
| 239 | 239 | ${beforeShown.length === 0 |
| 240 | ? html`<p class="muted small look-nobefore"> | |
| 240 | ? html`<p class="muted small"> | |
| 241 | 241 | Main has no look of ${page} yet. |
| 242 | 242 | </p>` |
| 243 | : html`<div class="look-pair"> | |
| 243 | : html`<div class="looks"> | |
| 244 | 244 | ${beforeShown.map((l) => picture(l, l.kind, page, "main"))} |
| 245 | 245 | </div> |
| 246 | <span class="muted small look-of" | |
| 247 | >at ${sha(beforeShown[0]!.commitOid, `${base}/commit/${beforeShown[0]!.commitOid}`)} · | |
| 248 | ${time(beforeShown[0]!.createdAt)}</span | |
| 249 | >`} | |
| 246 | <p class="muted small"> | |
| 247 | at ${sha(beforeShown[0]!.commitOid, `${base}/commit/${beforeShown[0]!.commitOid}`)} · | |
| 248 | ${time(beforeShown[0]!.createdAt)} | |
| 249 | </p>`} | |
| 250 | 250 | </div> |
| 251 | <div class="look-side look-after"> | |
| 251 | <div> | |
| 252 | 252 | <p class="muted small">After · #${pull.number}</p> |
| 253 | <div class="look-pair"> | |
| 253 | <div class="looks"> | |
| 254 | 254 | ${when(phone, (l) => picture(l, "phone", page))} |
| 255 | 255 | ${when(desktop, (l) => picture(l, "desktop", page))} |
| 256 | 256 | </div> |
| @@ -259,14 +259,14 @@ export function lookCard( | ||
| 259 | 259 | ${when(darkPhone ?? darkDesktop, () => |
| 260 | 260 | html`<details class="quiet-details"> |
| 261 | 261 | <summary class="muted small">In the dark</summary> |
| 262 | <div class="look-pair"> | |
| 262 | <div class="looks"> | |
| 263 | 263 | ${when(darkPhone, (l) => picture(l, "phone", page))} |
| 264 | 264 | ${when(darkDesktop, (l) => picture(l, "desktop", page))} |
| 265 | 265 | </div> |
| 266 | 266 | </details>`, |
| 267 | 267 | )} |
| 268 | <span class="muted small look-of" | |
| 269 | >at ${sha(shown.commitOid, `${base}/commit/${shown.commitOid}`)} · | |
| 268 | <p class="muted small"> | |
| 269 | at ${sha(shown.commitOid, `${base}/commit/${shown.commitOid}`)} · | |
| 270 | 270 | ${time(shown.createdAt)}${when( |
| 271 | 271 | moved, |
| 272 | 272 | () => |
| @@ -274,16 +274,16 @@ export function lookCard( | ||
| 274 | 274 | since${failed === undefined |
| 275 | 275 | ? html`; a new picture follows its deploy` |
| 276 | 276 | : html`; no picture of ${sha(headOid)}: ${failed.error}`}`, |
| 277 | )}</span | |
| 278 | > | |
| 279 | </div>`; | |
| 277 | )} | |
| 278 | </p> | |
| 279 | </section>`; | |
| 280 | 280 | }; |
| 281 | 281 | // What people attached sits in the same strip, each with its line and |
| 282 | 282 | // who put it there. Their size is their own: the picture's, not a window's. |
| 283 | 283 | const person = (look: Look): Html => { |
| 284 | 284 | const url = lookUrl(base, look); |
| 285 | 285 | const size = attachedSize(ctx, look); |
| 286 | return html`<figure class="look-attached look-attached-${look.kind}"> | |
| 286 | return html`<figure data-slot="${look.kind}"> | |
| 287 | 287 | <a class="look" href="${url}"> |
| 288 | 288 | <img |
| 289 | 289 | src="${url}" |
| @@ -299,10 +299,12 @@ export function lookCard( | ||
| 299 | 299 | </figure>`; |
| 300 | 300 | }; |
| 301 | 301 | return card( |
| 302 | html`${pages.map(group)}${when(attached.length > 0, () => | |
| 303 | html`<div class="look-pair look-attachments">${attached.map(person)}</div>`, | |
| 304 | )}`, | |
| 305 | { head, extra: "look-card" }, | |
| 302 | html`<div class="stack"> | |
| 303 | ${pages.map(group)}${when(attached.length > 0, () => | |
| 304 | html`<div class="looks">${attached.map(person)}</div>`, | |
| 305 | )} | |
| 306 | </div>`, | |
| 307 | { head }, | |
| 306 | 308 | ); |
| 307 | 309 | } |
| 308 | 310 | |
| @@ -346,7 +348,7 @@ export function lookNow(ctx: Ctx, repo: Repo, record: AppRecord): Html { | ||
| 346 | 348 | const base = repoBase(repo); |
| 347 | 349 | const size = LOOK_SIZES[look.kind]; |
| 348 | 350 | return html`<a |
| 349 | class="look-now" | |
| 351 | class="look" data-slot="now" | |
| 350 | 352 | href="${base}/app/production/open" |
| 351 | 353 | title="How it looks now, at ${look.commitOid.slice(0, 7)}" |
| 352 | 354 | > |
| @@ -361,7 +363,7 @@ export function lookNow(ctx: Ctx, repo: Repo, record: AppRecord): Html { | ||
| 361 | 363 | |
| 362 | 364 | /** A small picture as a thumbnail that opens the pull. */ |
| 363 | 365 | export function lookThumb(href: string, src: string): Html { |
| 364 | return html`<a class="look-thumb" href="${href}" | |
| 366 | return html`<a class="look" data-slot="thumb" href="${href}" | |
| 365 | 367 | ><img src="${src}" width="30" height="40" alt="" loading="lazy" |
| 366 | 368 | /></a>`; |
| 367 | 369 | } |
src/web/style/components.ts +22
| @@ -294,6 +294,28 @@ a.sha:hover { color: var(--accent); } | ||
| 294 | 294 | .card[data-tone="merged"] { border-color: color-mix(in srgb, var(--merged) 40%, var(--border)); } |
| 295 | 295 | .card[data-tone="attn"] { border-color: color-mix(in srgb, var(--attn) 40%, var(--border)); } |
| 296 | 296 | |
| 297 | /* ---- look: a picture the forge took, in a hairline frame ---- | |
| 298 | A strip sets a phone beside a desktop: the phone keeps a phone's | |
| 299 | narrowness, the desktop takes what is left, never more than its own size; | |
| 300 | under 640px they stack. data-compare sets two sides (before, after) beside | |
| 301 | each other instead. A thumb shows the top of the picture; now is | |
| 302 | production small. */ | |
| 303 | .look { display: block; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--muted); } | |
| 304 | .look img { display: block; width: 100%; height: auto; } | |
| 305 | .look[data-slot="thumb"] { flex: none; width: 30px; height: 40px; border-radius: 4px; } | |
| 306 | .look[data-slot="thumb"] img { height: 100%; object-fit: cover; object-position: top; } | |
| 307 | .look[data-slot="now"] { flex: none; width: 168px; } | |
| 308 | .looks { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; } | |
| 309 | .looks > [data-slot="phone"] { flex: 0 0 200px; } | |
| 310 | .looks > [data-slot="desktop"] { flex: 1 1 320px; min-width: 0; max-width: 1280px; } | |
| 311 | .looks > figure { margin: 0; display: flex; flex-direction: column; gap: 6px; } | |
| 312 | .looks[data-compare] { gap: 20px; } | |
| 313 | .looks[data-compare] > * { flex: 1 1 320px; min-width: 0; display: flex; flex-direction: column; gap: 6px; } | |
| 314 | @media (max-width: 640px) { | |
| 315 | .looks:not([data-compare]) { flex-direction: column; } | |
| 316 | .looks > [data-slot="phone"] { flex-basis: auto; width: 200px; } | |
| 317 | } | |
| 318 | ||
| 297 | 319 | /* ---- list rows ---- */ |
| 298 | 320 | .list > li + li { border-top: 1px solid var(--border); } |
| 299 | 321 | .row { |
src/web/style/content.ts −26
| @@ -864,32 +864,6 @@ form.inline { display: inline; margin-left: auto; } | ||
| 864 | 864 | .env-card .card-head { gap: 10px; } |
| 865 | 865 | .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); } |
| 866 | 866 | .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; } |
| 867 | /* The look: a phone beside a desktop, each in a hairline frame. The phone | |
| 868 | keeps a phone's narrowness; the desktop takes what is left, never more | |
| 869 | than its own size. A thumbnail shows the top of the phone picture; the | |
| 870 | studio head shows production small. */ | |
| 871 | .look-card .card-head { gap: 10px; flex-wrap: wrap; } | |
| 872 | .look-card .look-of { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; } | |
| 873 | .look-pair { display: flex; gap: 14px; align-items: flex-start; } | |
| 874 | .look { display: block; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--muted); } | |
| 875 | .look img { display: block; width: 100%; height: auto; } | |
| 876 | .look-compare { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; } | |
| 877 | .look-side { flex: 1 1 320px; min-width: 0; } | |
| 878 | .look-side > p { margin: 0 0 6px; } | |
| 879 | .look-side .look-pair { flex-wrap: wrap; } | |
| 880 | .look-attachments { flex-wrap: wrap; margin-top: 10px; } | |
| 881 | .look-attached { margin: 0; display: flex; flex-direction: column; gap: 6px; flex: 1 1 200px; min-width: 0; max-width: 1280px; } | |
| 882 | .look-attached-phone { flex: 0 0 200px; } | |
| 883 | .look-phone { flex: 0 0 200px; } | |
| 884 | .look-desktop { flex: 1 1 320px; min-width: 0; max-width: 1280px; } | |
| 885 | @media (max-width: 640px) { | |
| 886 | .look-pair { flex-direction: column; } | |
| 887 | .look-phone { flex-basis: auto; width: 200px; } | |
| 888 | } | |
| 889 | .look-thumb { display: block; flex: none; width: 30px; height: 40px; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--muted); } | |
| 890 | .look-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; } | |
| 891 | .look-now { display: block; flex: none; width: 168px; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; background: var(--muted); } | |
| 892 | .look-now img { display: block; width: 100%; height: auto; } | |
| 893 | 867 | /* The checks line inside the merge box, in the reviews chip's voice. */ |
| 894 | 868 | .checks-line { |
| 895 | 869 | display: flex; align-items: center; gap: 6px; flex-wrap: wrap; |
test/looks.test.ts +5 −5
| @@ -670,7 +670,7 @@ describe("the look", () => { | ||
| 670 | 670 | const production = looksForEnv(app.db, record.id, "production").find( |
| 671 | 671 | (l) => l.kind === "thumb", |
| 672 | 672 | )!; |
| 673 | expect(page).toContain(`class="look-now"`); | |
| 673 | expect(page).toContain(`class="look" data-slot="now"`); | |
| 674 | 674 | expect(page).toContain( |
| 675 | 675 | `src="/alice/hello/looks/production/${production.commitOid}/thumb.png?v=`, |
| 676 | 676 | ); |
| @@ -761,8 +761,8 @@ describe("the look", () => { | ||
| 761 | 761 | const page = await ( |
| 762 | 762 | await web(`/alice/hello/pulls/${opened.value.number}`, aliceCookie) |
| 763 | 763 | ).text(); |
| 764 | expect(page).toContain(`<p class="muted small look-page">/</p>`); | |
| 765 | expect(page).toContain(`<p class="muted small look-page">/board</p>`); | |
| 764 | expect(page).toContain(`<p class="muted small">/</p>`); | |
| 765 | expect(page).toContain(`<p class="muted small">/board</p>`); | |
| 766 | 766 | expect(page).toContain( |
| 767 | 767 | `src="/alice/hello/looks/${env}/${commitOid}/board-phone.png?v=`, |
| 768 | 768 | ); |
| @@ -805,7 +805,7 @@ describe("the look", () => { | ||
| 805 | 805 | await web(`/alice/hello/pulls/${opened.value.number}`, aliceCookie) |
| 806 | 806 | ).text(); |
| 807 | 807 | expect(darkPage).toContain( |
| 808 | `<a class="look look-phone" href="/alice/hello/looks/${env}/${commitOid}/phone-dark.png?v=`, | |
| 808 | `<a class="look" data-slot="phone" href="/alice/hello/looks/${env}/${commitOid}/phone-dark.png?v=`, | |
| 809 | 809 | ); |
| 810 | 810 | expect(darkPage).toContain( |
| 811 | 811 | `src="/alice/hello/looks/${env}/${commitOid}/board-desktop-dark.png?v=`, |
| @@ -1120,7 +1120,7 @@ describe("the look", () => { | ||
| 1120 | 1120 | await web(`/alice/hello/pulls/${pull.number}`, aliceCookie) |
| 1121 | 1121 | ).text(); |
| 1122 | 1122 | const group = (page: string) => |
| 1123 | text.split(`<p class="muted small look-page">${page}</p>`)[1]!.split("look-group")[0]!; | |
| 1123 | text.split(`<p class="muted small">${page}</p>`)[1]!.split("<section")[0]!; | |
| 1124 | 1124 | const map = group("/map"); |
| 1125 | 1125 | expect(map).toContain(`/looks/production/${mainOid}/map-phone.png?v=`); |
| 1126 | 1126 | expect(map).toContain(`/looks/${env}/${oid}/map-phone.png?v=`); |
new above
offline