Public: anyone can read this; the crew can change it.
The forge comes alive, calmly: its own pixel art and one motion vocabulary from the code viewer, zero deps #10672
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10570-the-forge-comes-alive-calmly-its-own-pix → main 2 commits fork 7 threads · 7 unresolved
docs/design/areas/pixel.md added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | # Pixel art (#10570) | |
| 2 | ||
| 3 | The forge's own art, drawn in `src/web/pixel.ts`, no outside assets, no credits, no dependencies. Every pixel is painted | |
| 4 | with a `fill` attribute from `--fg`, `--muted-fg` or `--card`, so it follows all six themes. Drawings are symbols in the one hashed icon sprite; a page carries a | |
| 5 | short `<svg>` and a `<use>`. | |
| 6 | ||
| 7 | - `.px` is a drawing of pixels: crisp, never smoothed. `data-state` says what is true: `working` (a run is going; two | |
| 8 | frames swap on `--beat`), `landed` (one hop, once) or nothing known (it just stands). | |
| 9 | - `.px-a` and `.px-b` are the two frames of a working figure. Under reduced motion only `.px-a` shows. | |
| 10 | - `.px-scene` is the one small drawn scene, on the first-run "No apps yet" (`empty({ scene })`). | |
| 11 | - `.avatar-px` is an avatar that is a crew member's face instead of initials. | |
| 12 | ||
| 13 | Motion is one vocabulary, tokens in `style/base.ts`: `--dur-1` (100ms), `--dur-2` (140ms), `--dur-3` (200ms), `--beat` | |
| 14 | (450ms). A figure never moves without a fact behind it, and nothing moves inside a large inline SVG (the map's faces are | |
| 15 | still). |
docs/features/pixel.md added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | # Pixel art: the crew's faces (#10570) | |
| 2 | ||
| 3 | Scout, Mason, Torque, juniper and sentinel have a 16×16 face, drawn in `src/web/pixel.ts` as a grid of characters. No | |
| 4 | outside asset, no dependency. Each pixel is a rectangle with a `fill` attribute from `--fg`, `--muted-fg` or `--card` | |
| 5 | (or a mix of the two last), so the art follows all six themes, adds no accent, and passes the page's CSP (a `style=` | |
| 6 | attribute would be blocked; `test/pixel.test.ts` fails if one is emitted). | |
| 7 | ||
| 8 | ## Where they show | |
| 9 | ||
| 10 | - As the avatar wherever a crew member's name appears (`avatar()`), hidden from a screen reader since the name is text. | |
| 11 | - Working (two frames swapped on `--beat`) beside a run that is running, and in the home's "Being built" rows; still | |
| 12 | otherwise. Under reduced motion only the first frame shows. A face never moves without a run behind it. | |
| 13 | - One hop on a run page when the run landed. | |
| 14 | - Still, on the map's cards. | |
| 15 | - One small drawn scene, on the "No apps yet" first-run state, and nowhere else. | |
| 16 | ||
| 17 | ## Both doors | |
| 18 | ||
| 19 | The terminal has no counterpart on purpose: this change draws the web's avatars and one first-run picture, and every | |
| 20 | fact they sit beside (who, working, landed) is already text in the TUI. There is nothing to render there. | |
| 21 | ||
| 22 | ## Motion | |
| 23 | ||
| 24 | Timings are tokens in `style/base.ts`: `--dur-1` (100ms), `--dur-2` (140ms), `--dur-3` (200ms), `--beat` (450ms). |
src/web/icons.ts +3 −2
| @@ -27,6 +27,7 @@ | ||
| 27 | 27 | */ |
| 28 | 28 | |
| 29 | 29 | import { raw, type Html } from "./html.ts"; |
| 30 | import { pixelInline, pixelSymbols } from "./pixel.ts"; | |
| 30 | 31 | |
| 31 | 32 | /** What the drawing needs, on the symbol rather than on every slot. */ |
| 32 | 33 | const DRAW = `viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"`; |
| @@ -128,7 +129,7 @@ export function spriteSvg(): string { | ||
| 128 | 129 | const symbols = (Object.keys(PATHS) as IconName[]) |
| 129 | 130 | .map((name) => `<symbol id="i-${name}" ${DRAW}>${PATHS[name]}</symbol>`) |
| 130 | 131 | .join(""); |
| 131 | return `<svg xmlns="http://www.w3.org/2000/svg">${symbols}</svg>`; | |
| 132 | return `<svg xmlns="http://www.w3.org/2000/svg">${symbols}${pixelSymbols()}</svg>`; | |
| 132 | 133 | } |
| 133 | 134 | |
| 134 | 135 | /** |
| @@ -149,7 +150,7 @@ export function pointAtSprite(markup: string, spritePath: string): string { | ||
| 149 | 150 | */ |
| 150 | 151 | export function inlineIcons(markup: string): string { |
| 151 | 152 | return markup.replace(REFERENCE, (whole, name: string) => |
| 152 | name in PATHS ? `<svg ${DRAW}>${PATHS[name as IconName]}</svg>` : whole, | |
| 153 | name in PATHS ? `<svg ${DRAW}>${PATHS[name as IconName]}</svg>` : (pixelInline(name) ?? whole), | |
| 153 | 154 | ); |
| 154 | 155 | } |
| 155 | 156 |
src/web/pages/map.ts +10 −1
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | * can see, one lane each. |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | import { faceAt } from "../pixel.ts"; | |
| 22 | 23 | import type { MapHold } from "../../crew/held.ts"; |
| 23 | 24 | import { sealedScope } from "../../core/sealed.ts"; |
| 24 | 25 | import type { Database } from "bun:sqlite"; |
| @@ -712,6 +713,7 @@ function node( | ||
| 712 | 713 | const number = `#${n.issue.number}`; |
| 713 | 714 | const who = n.issue.assigneeName; |
| 714 | 715 | const whoX = left + 9 + number.length * 6.4; |
| 716 | const face = who ? faceAt(who, whoX, bottom - 11, 14) : null; | |
| 715 | 717 | return html`<a |
| 716 | 718 | class="map-node" |
| 717 | 719 | data-state="${word}" |
| @@ -745,7 +747,14 @@ function node( | ||
| 745 | 747 | : html`<tspan x="${left}" dy="16">${second}</tspan>`} |
| 746 | 748 | </text> |
| 747 | 749 | <text class="map-meta" x="${left}" y="${bottom}">${number}</text> |
| 748 | ${who | |
| 750 | ${face !== null | |
| 751 | ? html`<g class="map-who"> | |
| 752 | ${face} | |
| 753 | <text class="map-who-n" x="${whoX + 19}" y="${bottom}" | |
| 754 | >${trim(who ?? "", 11)}</text | |
| 755 | > | |
| 756 | </g>` | |
| 757 | : who !== null | |
| 749 | 758 | ? html`<g class="map-who"> |
| 750 | 759 | <circle cx="${whoX + 7}" cy="${bottom - 4}" r="7" /> |
| 751 | 760 | <text class="map-who-i" x="${whoX + 7}" y="${bottom - 1}" |
src/web/pages/runs.ts +10 −2
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * id, so the redirect itself says nothing about what exists. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { crewFace } from "../pixel.ts"; | |
| 14 | 15 | import { steerRefusedWords } from "../../actions/steer_words.ts"; |
| 15 | 16 | import { readLooks } from "../../checks/looks.ts"; |
| 16 | 17 | import type { Database } from "bun:sqlite"; |
| @@ -247,6 +248,13 @@ const kindIcon = (kind: RunKind) => | ||
| 247 | 248 | |
| 248 | 249 | // ---- rows ---- |
| 249 | 250 | |
| 251 | /** A crew member's face for their run, working only while it runs; the kind's icon otherwise. */ | |
| 252 | function runLead(run: Run, actors: ReadonlyMap<string, User>): Html { | |
| 253 | const who = run.actorId === null ? undefined : actors.get(run.actorId)?.username; | |
| 254 | const face = run.kind === "agent" && who !== undefined ? crewFace(who, run.state === "running" ? "working" : "still", 16, true) : null; | |
| 255 | return face ?? kindIcon(run.kind); | |
| 256 | } | |
| 257 | ||
| 250 | 258 | /** One run in a list: what it was, why, how it went, and when. */ |
| 251 | 259 | export function runRow( |
| 252 | 260 | base: string, |
| @@ -259,7 +267,7 @@ export function runRow( | ||
| 259 | 267 | return row({ |
| 260 | 268 | href: `${base}/runs/${run.id}`, |
| 261 | 269 | nav: true, |
| 262 | lead: kindIcon(run.kind), | |
| 270 | lead: runLead(run, actors), | |
| 263 | 271 | title, |
| 264 | 272 | titleAttr: title, |
| 265 | 273 | meta: html`<span title="${meta}">${meta}</span>`, |
| @@ -287,7 +295,7 @@ export function crewActivityCard(ctx: Ctx, repo: Repo, issue: Issue): Html { | ||
| 287 | 295 | ?.username ?? "an agent"; |
| 288 | 296 | return row({ |
| 289 | 297 | href: `${base}/runs/${run.id}`, |
| 290 | lead: icon("bot"), | |
| 298 | lead: crewFace(who, run.state === "running" ? "working" : "still", 16, true) ?? icon("bot"), | |
| 291 | 299 | title: html`${who} |
| 292 | 300 | <span class="muted">${reasonWords(run.reason)}</span>`, |
| 293 | 301 | meta: html`<span title="${run.summary}" |
src/web/pixel.ts added +249
| @@ -0,0 +1,249 @@ | ||
| 1 | /** | |
| 2 | * The forge's own pixel art (#10570): a face for each of the crew and one | |
| 3 | * small scene, drawn here as a grid of characters, nothing fetched, nothing | |
| 4 | * credited. Every pixel is a rectangle painted from a theme token, so the | |
| 5 | * art follows all six themes and costs no colour of its own. | |
| 6 | * | |
| 7 | * Like the icons, the drawings are `<symbol>`s in the one hashed sprite | |
| 8 | * (`icons.ts` appends `pixelSymbols()`), so a page carries a short `<svg>` | |
| 9 | * and a `<use>` however many faces it shows. A fragment expands them in | |
| 10 | * place (`pixelInline()`), the same pixels. | |
| 11 | * | |
| 12 | * A face has two frames over one base: standing and at work (the tool moves). | |
| 13 | * The work frame is drawn on top of the base by patching pixels, so it costs | |
| 14 | * a few rectangles. Movement is CSS only | |
| 15 | * (`style/components.ts`, `.px`): `steps()` between frames on the shared | |
| 16 | * `--beat`, only for `data-state="working"`, once for `landed`, none under | |
| 17 | * `prefers-reduced-motion`; a hidden tab does not paint, so it does not run. | |
| 18 | */ | |
| 19 | ||
| 20 | import { html, raw, type Html } from "./html.ts"; | |
| 21 | ||
| 22 | /** The crew with a face: the accounts' names, which are reserved (`core/reserved.ts`). */ | |
| 23 | export const CREW_FACES = ["scout", "mason", "torque", "juniper", "sentinel"] as const; | |
| 24 | export type CrewFace = (typeof CREW_FACES)[number]; | |
| 25 | ||
Principles 4 and 5: INK paints with --accent, --merged, --success, --danger. Limit the faces to --fg, --muted-fg, --card, or the pages gain several accents. |
||
| 26 | /** What a face is doing: nothing known, building, or having just landed something. */ | |
| 27 | export type FaceState = "still" | "working" | "landed"; | |
| 28 | ||
| 29 | /** | |
| 30 | * One character, one fill. Only the neutral tokens: a figure is told apart by | |
| 31 | * its shape, never by a colour of its own, so a page keeps its one accent for | |
| 32 | * what can be clicked (docs/DESIGN.md, principles 4 and 5). `.` is clear. | |
| 33 | */ | |
| 34 | const INK: Record<string, string> = { | |
| 35 | o: "var(--fg)", | |
| 36 | k: "color-mix(in srgb,var(--muted-fg) 40%,var(--card))", | |
| 37 | g: "var(--muted-fg)", | |
| 38 | c: "var(--card)", | |
| 39 | }; | |
| 40 | ||
| 41 | const SIZE = 16; | |
| 42 | ||
| 43 | /** The head and shoulders every face starts from: `H` hat, `B` body, `k` skin. */ | |
| 44 | const BASE = [ | |
| 45 | "................", | |
| 46 | ".....oooooo.....", | |
| 47 | "....oHHHHHHo....", | |
| 48 | "...oHHHHHHHHo...", | |
| 49 | "...oHHHHHHHHo...", | |
| 50 | "...okkkkkkkko...", | |
| 51 | "...okokkkkoko...", | |
| 52 | "...okkkkkkkko...", | |
| 53 | "...okkkoookko...", | |
| 54 | "....okkkkkko....", | |
| 55 | ".....oooooo.....", | |
| 56 | "...ooBBBBBBoo...", | |
| 57 | "..oBBBBBBBBBBo..", | |
| 58 | "..oBBBBBBBBBBo..", | |
| 59 | "..oBBBBBBBBBBo..", | |
| 60 | "..oBBBBBBBBBBo..", | |
| 61 | ]; | |
| 62 | ||
| 63 | type Patch = readonly [x: number, y: number, ch: string]; | |
| 64 | ||
| 65 | interface Drawing { | |
| 66 | readonly hat: string; | |
| 67 | readonly body: string; | |
| 68 | /** Pixels that make this one itself, over the base. */ | |
| 69 | readonly mark: readonly Patch[]; | |
| 70 | /** Pixels that move while it works. */ | |
| 71 | readonly work: readonly Patch[]; | |
| 72 | } | |
| 73 | ||
| 74 | const DRAWINGS: Record<CrewFace, Drawing> = { | |
| 75 | // Scout: a cap with a brim and a spyglass held to the eye. | |
| 76 | scout: { | |
| 77 | hat: "g", | |
| 78 | body: "g", | |
| 79 | mark: [[2, 4, "o"], [1, 4, "g"], [11, 6, "o"], [12, 6, "o"], [13, 6, "o"], [13, 5, "g"]], | |
| 80 | work: [[14, 6, "o"], [14, 5, "g"], [15, 5, "g"]], | |
| 81 | }, | |
| 82 | // Mason: a hard hat and a trowel. | |
| 83 | mason: { | |
| 84 | hat: "c", | |
| 85 | body: "k", | |
| 86 | mark: [[2, 3, "o"], [2, 4, "c"], [3, 4, "c"], [12, 4, "c"], [13, 12, "g"], [14, 12, "g"], [14, 11, "o"]], | |
| 87 | work: [[13, 12, "o"], [14, 12, "o"], [14, 11, "g"], [15, 10, "g"]], | |
| 88 | }, | |
| 89 | // Torque: spiked hair and a wrench. | |
| 90 | torque: { | |
| 91 | hat: "o", | |
| 92 | body: "o", | |
| 93 | mark: [[5, 0, "o"], [8, 0, "o"], [10, 0, "o"], [13, 11, "g"], [13, 12, "g"], [13, 13, "g"]], | |
| 94 | work: [[14, 10, "g"], [15, 10, "g"], [13, 13, "o"], [15, 9, "g"]], | |
| 95 | }, | |
| 96 | // Juniper: leaves for hair and a sprig. | |
| 97 | juniper: { | |
| 98 | hat: "k", | |
| 99 | body: "c", | |
| 100 | mark: [[4, 0, "k"], [5, 0, "k"], [10, 0, "k"], [11, 1, "k"], [2, 11, "o"], [1, 12, "g"], [1, 13, "g"]], | |
| 101 | work: [[1, 11, "g"], [0, 12, "g"], [0, 13, "g"], [1, 14, "g"]], | |
| 102 | }, | |
| 103 | // Sentinel: a helmet with a visor, and a shield. | |
| 104 | sentinel: { | |
| 105 | hat: "g", | |
| 106 | body: "g", | |
| 107 | mark: [[4, 5, "o"], [5, 5, "o"], [6, 5, "o"], [7, 5, "o"], [8, 5, "o"], [9, 5, "o"], [10, 5, "o"], [11, 5, "o"], [12, 12, "c"], [13, 12, "c"], [13, 13, "c"]], | |
| 108 | work: [[12, 13, "o"], [13, 14, "o"], [12, 12, "k"], [13, 12, "k"]], | |
| 109 | }, | |
| 110 | }; | |
| 111 | ||
| 112 | function grid(face: CrewFace): string[][] { | |
| 113 | const d = DRAWINGS[face]; | |
| 114 | const rows = BASE.map((r) => [...r].map((c) => (c === "H" ? d.hat : c === "B" ? d.body : c))); | |
| 115 | for (const [x, y, c] of d.mark) rows[y]![x] = c; | |
| 116 | return rows; | |
| 117 | } | |
| 118 | ||
| 119 | /** A grid as rectangles, one `<path>` per colour, horizontal runs merged. */ | |
outdated
The resting frame has no caller. Remove it until a fact backs it. |
||
| 120 | function paint(rows: readonly (readonly string[])[]): string { | |
| 121 | const byInk = new Map<string, string>(); | |
| 122 | rows.forEach((row, y) => { | |
| 123 | for (let x = 0; x < row.length; ) { | |
| 124 | const c = row[x]!; | |
| 125 | let n = 1; | |
| 126 | while (x + n < row.length && row[x + n] === c) n++; | |
| 127 | if (c !== "." && INK[c] !== undefined) byInk.set(c, `${byInk.get(c) ?? ""}M${x} ${y}h${n}v1h-${n}z`); | |
| 128 | x += n; | |
| 129 | } | |
| 130 | }); | |
| 131 | return [...byInk].map(([c, d]) => `<path fill="${INK[c]}" d="${d}"/>`).join(""); | |
| 132 | } | |
| 133 | ||
| 134 | function patches(list: readonly Patch[]): string { | |
| 135 | const rows = Array.from({ length: SIZE }, () => Array.from({ length: SIZE }, () => ".")); | |
| 136 | for (const [x, y, c] of list) rows[y]![x] = c; | |
| 137 | return paint(rows); | |
| 138 | } | |
| 139 | ||
Inline style="fill:..." is blocked by the preview's CSP (style-src without unsafe-inline). Use a fill attribute or classes. |
||
| 140 | /** A frame's drawing: the base, then (for work) the pixels that differ, over it. */ | |
| 141 | export type Frame = "still" | "work"; | |
| 142 | ||
| 143 | /** The inside of a face's frame, no wrapper. */ | |
| 144 | export function faceContent(face: CrewFace, frame: Frame): string { | |
| 145 | const base = paint(grid(face)); | |
| 146 | if (frame === "still") return base; | |
| 147 | return base + patches(DRAWINGS[face].work); | |
| 148 | } | |
| 149 | ||
| 150 | const FRAME_ID: Record<Frame, string> = { still: "", work: "work" }; | |
| 151 | const idOf = (face: CrewFace, frame: Frame): string => `pix${face}${FRAME_ID[frame]}`; | |
| 152 | ||
| 153 | // ---- the one scene ---- | |
| 154 | ||
| 155 | const SCENE_W = 24; | |
| 156 | const SCENE_H = 12; | |
| 157 | /** Nothing here yet: a cold anvil on a block, one ember waiting. */ | |
| 158 | const ANVIL = [ | |
| 159 | "........................", | |
| 160 | "........................", | |
| 161 | "........................", | |
| 162 | "..........o.............", | |
| 163 | "...ooooooooooooooo......", | |
| 164 | "..ogggggggggggggggo.....", | |
| 165 | "...oooogggggggoooo......", | |
| 166 | ".......oggggggo.........", | |
| 167 | ".......oggggggo.........", | |
| 168 | "......oooooooooo........", | |
| 169 | ".....occccccccccco......", | |
| 170 | ".....oooooooooooo.......", | |
| 171 | ].map((r) => [...r]); | |
| 172 | ||
| 173 | /** Scenes by name. */ | |
| 174 | export type SceneName = "anvil"; | |
| 175 | const SCENES: Record<SceneName, readonly (readonly string[])[]> = { anvil: ANVIL }; | |
| 176 | ||
| 177 | // ---- the sprite ---- | |
| 178 | ||
| 179 | const VIEW = `viewBox="0 0 ${SIZE} ${SIZE}" shape-rendering="crispEdges"`; | |
| 180 | ||
| 181 | /** Every pixel symbol, for the sprite file (`icons.ts`). */ | |
| 182 | export function pixelSymbols(): string { | |
| 183 | const out: string[] = []; | |
| 184 | for (const face of CREW_FACES) { | |
| 185 | out.push(`<symbol id="i-${idOf(face, "still")}" ${VIEW}>${faceContent(face, "still")}</symbol>`); | |
| 186 | out.push( | |
| 187 | `<symbol id="i-${idOf(face, "work")}" ${VIEW}><use href="#i-${idOf(face, "still")}"/>${patches(DRAWINGS[face].work)}</symbol>`, | |
| 188 | ); | |
| 189 | } | |
| 190 | for (const [name, rows] of Object.entries(SCENES)) | |
| 191 | out.push(`<symbol id="i-pixscene${name}" viewBox="0 0 ${SCENE_W} ${SCENE_H}" shape-rendering="crispEdges">${paint(rows)}</symbol>`); | |
| 192 | return out.join(""); | |
| 193 | } | |
| 194 | ||
| 195 | /** A `<use>` reference to a pixel symbol, as `icons.ts` reads them back. */ | |
| 196 | const ref = (id: string): string => `<use href="#i-${id}"/>`; | |
| 197 | ||
| 198 | /** | |
| 199 | * The reference name → the drawing it stands for, as a nested `<svg>` for a | |
| 200 | * fragment (`icons.ts` `inlineIcons`). Undefined for a name that is not pixel art. | |
| 201 | */ | |
| 202 | export function pixelInline(id: string): string | undefined { | |
| 203 | for (const face of CREW_FACES) | |
| 204 | for (const frame of ["still", "work"] as const) | |
| 205 | if (idOf(face, frame) === id) return `<svg ${VIEW}>${faceContent(face, frame)}</svg>`; | |
| 206 | const scene = /^pixscene([a-z]+)$/.exec(id)?.[1]; | |
| 207 | if (scene !== undefined && scene in SCENES) | |
| 208 | return `<svg viewBox="0 0 ${SCENE_W} ${SCENE_H}" shape-rendering="crispEdges">${paint(SCENES[scene as SceneName])}</svg>`; | |
| 209 | return undefined; | |
| 210 | } | |
| 211 | ||
| 212 | export function isCrewFace(name: string): name is CrewFace { | |
| 213 | return (CREW_FACES as readonly string[]).includes(name); | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * A crew member's face, 24px, or nothing for a name with no portrait. | |
| 218 | * `state` is what is known to be true: a face works only when a run is, and | |
| 219 | * with nothing known it just stands. A face beside the name written out is | |
| 220 | * `decorative` (hidden from a screen reader, which would say the name twice); | |
| 221 | * on its own it is an image labelled with the name and, when working, the fact. | |
| 222 | */ | |
| 223 | export function crewFace(name: string, state: FaceState = "still", size: 16 | 24 | 48 = 24, decorative = false): Html | null { | |
| 224 | if (!isCrewFace(name)) return null; | |
| 225 | const label = state === "working" ? `${name} is working` : name; | |
| 226 | const layers = | |
| 227 | state === "working" | |
| 228 | ? `<g class="px-a">${ref(idOf(name, "still"))}</g><g class="px-b">${ref(idOf(name, "work"))}</g>` | |
| 229 | : ref(idOf(name, "still")); | |
| 230 | const alt = decorative ? html`aria-hidden="true"` : html`role="img" aria-label="${label}"`; | |
| 231 | return html`<svg class="px" data-state="${state}" width="${String(size)}" height="${String(size)}" ${alt} focusable="false" | |
| 232 | >${raw(layers)}</svg | |
| 233 | >`; | |
| 234 | } | |
| 235 | ||
| 236 | /** The scene for an empty page, 48×24, decorative: the line beside it says the thing. */ | |
| 237 | export function scene(name: SceneName): Html { | |
| 238 | return html`<svg class="px px-scene" width="${String(SCENE_W * 2)}" height="${String(SCENE_H * 2)}" aria-hidden="true" focusable="false">${raw(ref(`pixscene${name}`))}</svg>`; | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * A face placed inside a larger drawing (the map's cards), still: an animation | |
| 243 | * inside a big inline SVG repaints all of it, so the map's faces never move | |
| 244 | * and the card's own ring says who is working. | |
| 245 | */ | |
| 246 | export function faceAt(name: string, x: number, y: number, size: number): Html | null { | |
| 247 | if (!isCrewFace(name)) return null; | |
| 248 | return html`<svg class="px" x="${String(x)}" y="${String(y)}" width="${String(size)}" height="${String(size)}" aria-hidden="true" focusable="false">${raw(ref(idOf(name, "still")))}</svg>`; | |
| 249 | } |
src/web/studio/feed.ts +1 −1
| @@ -237,7 +237,7 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 237 | 237 | const { repos } = writableAppsOf(ctx, user); |
| 238 | 238 | const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`; |
| 239 | 239 | const noApps = html`${empty({ |
| 240 | icon: "rocket", | |
| 240 | scene: "anvil", | |
| 241 | 241 | title: "No apps yet", |
| 242 | 242 | note: "Make one, tell the crew what it is, and watch it come up here.", |
| 243 | 243 | action: html`${btn("New app", { href: "/apps/new", variant: "primary" })} |
src/web/studio/living.ts +4 −3
| @@ -22,6 +22,7 @@ import { writableAppsOf } from "./asks.ts"; | ||
| 22 | 22 | import { FORGE_TARGET } from "./feed.ts"; |
| 23 | 23 | import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts"; |
| 24 | 24 | import { repoBase } from "../chrome.ts"; |
| 25 | import { crewFace, type FaceState } from "../pixel.ts"; | |
| 25 | 26 | |
| 26 | 27 | /** The events that move a build or a shipment. */ |
| 27 | 28 | const MOVING_LIVE = "run.started,run.finished,pull.merged,pull.opened,pull.closed,app.serving"; |
| @@ -54,11 +55,11 @@ export function nowLine(now: Now, boards = true): Html { | ||
| 54 | 55 | </section>`; |
| 55 | 56 | } |
| 56 | 57 | |
| 57 | function listOf(label: string, rows: readonly MovingRow[], empty: string, heading = true): Html { | |
| 58 | function listOf(label: string, rows: readonly MovingRow[], empty: string, heading = true, face?: FaceState): Html { | |
| 58 | 59 | const shown = rows.map((r) => |
| 59 | 60 | row({ |
| 60 | 61 | href: r.href, |
| 61 | ...(r.who === "" ? {} : { lead: html`<span class="muted small">${r.who}</span>` }), | |
| 62 | ...(r.who === "" ? {} : { lead: html`${(face && crewFace(r.who, face, 16, true)) ?? null}<span class="muted small">${r.who}</span>` }), | |
outdated
The face (role=img, label = name) is followed by the visible name, so it is announced twice. Make the face aria-hidden here. |
||
| 62 | 63 | title: html`<span class="truncate" title="${r.repo}: ${r.full}">${r.title}</span>`, |
| 63 | 64 | trail: html`<span class="muted small">${timeAgo(r.at)}</span>`, |
| 64 | 65 | }), |
| @@ -85,7 +86,7 @@ export function movingRegion(db: Database, viewer: User): Html { | ||
| 85 | 86 | const shipped = readShipped(db, viewer); |
| 86 | 87 | return html`<div id="live-moving" class="stack" data-live="${MOVING_LIVE}" data-live-src="/live/moving"> |
| 87 | 88 | ${viewer.isAdmin ? nowLine(pipelineNow(db), viewer.kind !== "agent") : null} |
| 88 | ${listOf("Being built", building, "Nothing is being built right now")} | |
| 89 | ${listOf("Being built", building, "Nothing is being built right now", true, "working")} | |
| 89 | 90 | ${disclosure("Just shipped", listOf("Just shipped", shipped, "Nothing has shipped yet", false), { small: true })} |
| 90 | 91 | </div>`; |
| 91 | 92 | } |
src/web/studio/run.ts +3 −1
| @@ -36,6 +36,7 @@ import { costText, durationText, RUN_PAGE_CSS, runTitle, runTokens } from "../pa | ||
| 36 | 36 | import { placementLine, sandboxLine } from "./sandbox.ts"; |
| 37 | 37 | import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts"; |
| 38 | 38 | import { changeHref, tellHref } from "./nouns.ts"; |
| 39 | import { crewFace } from "../pixel.ts"; | |
| 39 | 40 | import { unnamedRun, voiceFor, type Actor } from "./voice.ts"; |
| 40 | 41 | import { runWord, servingHistory, wordBadge, type WordCtx } from "./words.ts"; |
| 41 | 42 | |
| @@ -153,7 +154,8 @@ export async function runView( | ||
| 153 | 154 | > |
| 154 | 155 | <h1>${title}</h1> |
| 155 | 156 | <div class="page-head-sub bar muted small"> |
| 156 | ${wordBadge(word, "run-word")} | |
| 157 | ${wordBadge(word, "run-word", actor?.username)} | |
| 158 | ${actor?.kind === "agent" && run.state === "ok" ? crewFace(actor.username, "landed") : null} | |
| 157 | 159 | ${when( |
| 158 | 160 | issue, |
| 159 | 161 | (i) => |
src/web/studio/words.ts +3 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * thing about the same commit. Nothing here knows about a page. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { crewFace } from "../pixel.ts"; | |
| 11 | 12 | import { shownState } from "../../runs/state.ts"; |
| 12 | 13 | import type { Database } from "bun:sqlite"; |
| 13 | 14 | import { ancestries, type AncestryCache } from "./ancestry.ts"; |
| @@ -564,13 +565,13 @@ export const wordVariant = (word: Word): WordVariant => WORD_VARIANT[word]; | ||
| 564 | 565 | |
| 565 | 566 | /** One of the five (or six) studio words, as a badge. An id names the one a |
| 566 | 567 | * live script repaints as a run finishes (`studioRunJs`). */ |
| 567 | export function wordBadge(word: Word, id?: string): Html { | |
| 568 | export function wordBadge(word: Word, id?: string, who?: string): Html { | |
| 568 | 569 | return html`<span |
| 569 | 570 | class="badge" |
| 570 | 571 | data-variant="${wordVariant(word)}" |
| 571 | 572 | data-word="${word}" |
| 572 | 573 | ${id === undefined ? null : html`id="${id}"`} |
| 573 | >${word === "working" ? icon("loader") : null}${word === "told" | |
| 574 | >${word === "working" ? ((who !== undefined && crewFace(who, "working", 16, true)) || icon("loader")) : null}${word === "told" | |
| 574 | 575 | ? "open" |
| 575 | 576 | : word}</span |
| 576 | 577 | >`; |
src/web/style/base.ts +7
| @@ -31,6 +31,13 @@ export const base = /* css */ ` | ||
| 31 | 31 | --page: 1120px; |
| 32 | 32 | --measure: 72ch; |
| 33 | 33 | --ease: cubic-bezier(.2, 0, 0, 1); |
| 34 | /* One motion vocabulary: the code viewer's timings, named. A hover or a | |
| 35 | focus is --dur-1 to --dur-2, a thing arriving --dur-3, and a pixel | |
| 36 | figure changes frame every --beat (#10570). */ | |
| 37 | --dur-1: 100ms; | |
| 38 | --dur-2: 140ms; | |
| 39 | --dur-3: 200ms; | |
| 40 | --beat: 450ms; | |
| 34 | 41 | --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| 35 | 42 | --mono: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace; |
| 36 | 43 | } |
src/web/style/components.ts +18
| @@ -201,6 +201,24 @@ a.stat:hover { border-color: var(--border-strong); } | ||
| 201 | 201 | .btn[aria-busy="true"] .ico-loader { animation-play-state: running; } |
| 202 | 202 | @keyframes spin { to { transform: rotate(360deg); } } |
| 203 | 203 | |
| 204 | /* ---- pixel art (#10570, src/web/pixel.ts) ---- | |
| 205 | A crew face works by swapping two frames on the shared --beat; it never | |
| 206 | moves unless a run is going (data-state="working"), lands with one hop, | |
| 207 | and under reduced motion shows its first frame and stays. A hidden tab | |
| 208 | does not paint, so nothing runs there. */ | |
| 209 | .px { flex: none; shape-rendering: crispEdges; } | |
outdated
image-rendering has no effect on SVG. Remove it. Principle 5: accent is for interaction only, and the faces borrow it. |
||
| 210 | .px-scene { color: var(--faint); margin-bottom: 4px; } | |
| 211 | .avatar.avatar-px { background: none; border: 0; } | |
| 212 | .px[data-state="working"] .px-b { visibility: hidden; } | |
| 213 | @media (prefers-reduced-motion: no-preference) { | |
| 214 | .px[data-state="working"] .px-a { animation: px-a calc(var(--beat) * 2) steps(1) infinite; } | |
| 215 | .px[data-state="working"] .px-b { animation: px-b calc(var(--beat) * 2) steps(1) infinite; } | |
| 216 | .px[data-state="landed"] { animation: px-hop calc(var(--dur-3) * 2) steps(4) 1; } | |
| 217 | @keyframes px-a { 50% { visibility: hidden; } } | |
| 218 | @keyframes px-b { 50% { visibility: visible; } } | |
| 219 | @keyframes px-hop { 40% { transform: translateY(-4px); } } | |
| 220 | } | |
| 221 | ||
| 204 | 222 | /* One stored hex, derived three ways, so a chip reads correctly in all six |
| 205 | 223 | themes. --label is set by a per-page nonce'd rule; the hex is validated |
| 206 | 224 | before it is ever embedded. */ |
src/web/ui.ts +8 −1
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | } from "../kernel/tokens-format.ts"; |
| 24 | 24 | import { drawChart, type Cell, type Chart } from "../boards/chart.ts"; |
| 25 | 25 | import { icon, type IconName } from "./icons.ts"; |
| 26 | import { crewFace, scene, type SceneName } from "./pixel.ts"; | |
| 26 | 27 | import { Html, html, raw, when } from "./html.ts"; |
| 27 | 28 | |
| 28 | 29 | export { icon, type IconName }; |
| @@ -822,6 +823,10 @@ function initials(name: string): string { | ||
| 822 | 823 | } |
| 823 | 824 | |
| 824 | 825 | export function avatar(name: string, size?: "md" | "lg"): Html { |
| 826 | // The crew wear their own face wherever they appear (src/web/pixel.ts). | |
| 827 | const face = crewFace(name, "still", size === "lg" ? 48 : size ? 24 : 16, true); | |
| 828 | if (face !== null) | |
| 829 | return html`<span class="avatar avatar-px" ${size ? html`data-size="${size}"` : null} aria-hidden="true">${face}</span>`; | |
| 825 | 830 | return html`<span |
| 826 | 831 | class="avatar" |
| 827 | 832 | data-h="${hueOf(name)}" |
| @@ -1348,12 +1353,14 @@ export function confirmAction(o: { | ||
| 1348 | 1353 | |
| 1349 | 1354 | export function empty(o: { |
| 1350 | 1355 | icon?: IconName; |
| 1356 | /** A small drawn scene instead of the icon: one picture, then the line. */ | |
| 1357 | scene?: SceneName; | |
| 1351 | 1358 | title: string; |
| 1352 | 1359 | note?: string; |
| 1353 | 1360 | action?: Html; |
| 1354 | 1361 | }): Html { |
| 1355 | 1362 | return html`<div class="empty"> |
Principles 1, 2 and 7: one decorative scene repeated on seven empty pages restates what the line beside it already says. |
||
| 1356 | ${o.icon ? icon(o.icon, 20) : null} | |
| 1363 | ${o.scene ? scene(o.scene) : o.icon ? icon(o.icon, 20) : null} | |
| 1357 | 1364 | <p class="empty-title">${o.title}</p> |
| 1358 | 1365 | ${o.note ? html`<p>${o.note}</p>` : null} ${o.action ?? null} |
| 1359 | 1366 | </div>`; |
test/map.test.ts +1 −1
| @@ -471,7 +471,7 @@ describe("the map, per app and across the forge", () => { | ||
| 471 | 471 | expect(page).toContain('href="/alice/engine/map"'); |
| 472 | 472 | expect(page).toContain("Extract renderer"); |
| 473 | 473 | expect(page).toMatch( |
| 474 | /title="Working: s[^"]*"[^>]*>[\s\S]*?class="avatar"[^>]*>s</, | |
| 474 | /title="Working: s[^"]*"[^>]*>[\s\S]*?class="avatar avatar-px"\s+aria-hidden="true">\s*<svg class="px" data-state="still" width="16" height="16"\s+aria-hidden="true" focusable="false"\s*>\s*<use href="[^"]*#i-pixscout"\/><\/svg\s*>/, | |
| 475 | 475 | ); |
| 476 | 476 | expect(page).toContain('data-autonomy="off"'); |
| 477 | 477 | // dormant has nothing ready and nobody working: it folds to its header. |
test/pixel.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** The forge's own pixel art (#10570): faces, one scene, one motion vocabulary, nothing from outside. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { inlineIcons, spriteSvg } from "../src/web/icons.ts"; | |
| 10 | import { CREW_FACES, crewFace, faceAt, pixelSymbols, scene } from "../src/web/pixel.ts"; | |
| 11 | import { css } from "../src/web/style.ts"; | |
| 12 | import { avatar, empty } from "../src/web/ui.ts"; | |
| 13 | import { wordBadge } from "../src/web/studio/words.ts"; | |
| 14 | ||
| 15 | let dir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | ||
| 19 | beforeAll(async () => { | |
| 20 | dir = await mkdtemp(join(tmpdir(), "forge-pixel-")); | |
| 21 | app = createApp({ dataDir: dir, port: 0 }); | |
| 22 | servers = await serve(app); | |
| 23 | }); | |
| 24 | ||
| 25 | afterAll(async () => { | |
| 26 | servers.stop(); | |
| 27 | app.close(); | |
| 28 | await rm(dir, { recursive: true, force: true }); | |
| 29 | }); | |
| 30 | ||
| 31 | test("every crew member has a face in two frames, and the scene is drawn", () => { | |
| 32 | const sprite = spriteSvg(); | |
| 33 | for (const name of CREW_FACES) | |
| 34 | for (const suffix of ["", "work"]) expect(sprite).toContain(`id="i-pix${name}${suffix}"`); | |
| 35 | expect(sprite).toContain('id="i-pixsceneanvil"'); | |
| 36 | }); | |
| 37 | ||
| 38 | test("all the art together stays under the 20 KB budget", () => { | |
| 39 | expect(new TextEncoder().encode(pixelSymbols()).length).toBeLessThan(8_000); | |
| 40 | expect(new TextEncoder().encode(crewFace("mason", "working")!.text + scene("anvil").text).length).toBeLessThan(1_000); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a face works only when told a run is going", () => { | |
| 44 | expect(crewFace("someone")).toBeNull(); | |
| 45 | const working = crewFace("scout", "working")!.text; | |
| 46 | expect(working).toContain('data-state="working"'); | |
| 47 | expect(working).toContain("px-a"); | |
| 48 | expect(working).toContain("px-b"); | |
| 49 | expect(crewFace("scout")!.text).not.toContain("px-b"); | |
| 50 | expect(sprite()).not.toContain("rest"); | |
| 51 | expect(crewFace("juniper", "landed")!.text).toContain('data-state="landed"'); | |
| 52 | }); | |
| 53 | ||
| 54 | const sprite = (): string => spriteSvg(); | |
| 55 | ||
| 56 | test("the art is painted with fill attributes in neutral tokens, never a style attribute or an accent", () => { | |
| 57 | const all = [ | |
| 58 | spriteSvg(), | |
| 59 | inlineIcons(avatar("mason").text), | |
| 60 | inlineIcons(scene("anvil").text), | |
| 61 | inlineIcons(crewFace("scout", "working")!.text), | |
| 62 | ]; | |
| 63 | for (const markup of all) { | |
| 64 | expect(markup).not.toMatch(/\sstyle=/); | |
| 65 | expect(markup).not.toMatch(/--(?:accent|merged|success|danger)/); | |
| 66 | } | |
| 67 | expect(pixelSymbols()).toContain('fill="var(--fg)"'); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a face is an image on its own and hidden where the name is written beside it", () => { | |
| 71 | expect(crewFace("mason", "working")!.text).toContain('aria-label="mason is working"'); | |
| 72 | const beside = crewFace("mason", "working", 16, true)!.text; | |
| 73 | expect(beside).toContain('aria-hidden="true"'); | |
| 74 | expect(beside).not.toContain("aria-label"); | |
| 75 | expect(beside).not.toContain('role="img"'); | |
| 76 | expect(faceAt("mason", 1, 2, 14)!.text).toContain('aria-hidden="true"'); | |
| 77 | }); | |
| 78 | ||
| 79 | test("the crew wear their face as an avatar; everyone else keeps initials", () => { | |
| 80 | expect(avatar("sentinel").text).toContain("i-pixsentinel"); | |
| 81 | expect(avatar("alice").text).toContain(">a<"); | |
| 82 | expect(wordBadge("working", "x", "mason").text).toContain("i-pixmasonwork"); | |
| 83 | expect(wordBadge("working", "x").text).toContain("i-loader"); | |
| 84 | }); | |
| 85 | ||
| 86 | test("an empty page can show the scene in place of an icon", () => { | |
| 87 | const out = empty({ scene: "anvil", title: "Nothing yet" }).text; | |
| 88 | expect(out).toContain("i-pixsceneanvil"); | |
| 89 | expect(out).toContain("Nothing yet"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a fragment expands the faces in place, with no reference left to the sprite", () => { | |
| 93 | const out = inlineIcons(avatar("mason").text); | |
| 94 | expect(out).not.toContain("<use"); | |
| 95 | expect(out).toContain("<path"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("motion is one vocabulary and is gated on reduced motion", () => { | |
| 99 | for (const token of ["--dur-1", "--dur-2", "--dur-3", "--beat"]) expect(css).toContain(`${token}:`); | |
| 100 | const frame = css.indexOf("@keyframes px-a"); | |
| 101 | const gate = css.lastIndexOf("@media (prefers-reduced-motion: no-preference)", frame); | |
| 102 | expect(gate).toBeGreaterThan(-1); | |
| 103 | expect(css.slice(gate, frame)).not.toContain("}\n}"); | |
| 104 | expect(css).not.toContain("image-rendering"); | |
| 105 | }); | |
| 106 | ||
| 107 | test("no stylesheet, sprite or page loads an image or a font from outside the forge", async () => { | |
| 108 | const outside = /(?:url\(\s*["']?(?:https?:)?\/\/|@import|@font-face)/i; | |
| 109 | expect(css).not.toMatch(outside); | |
| 110 | expect(spriteSvg()).not.toMatch(/https?:\/\/(?!www\.w3\.org)/); | |
| 111 | for (const path of ["/", "/login"]) { | |
| 112 | const r = await fetch(`${servers.url}${path}`, { redirect: "manual" }); | |
| 113 | const body = await r.text(); | |
| 114 | for (const tag of body.match(/<(?:img|link|source|image)\b[^>]*>/gi) ?? []) { | |
| 115 | // A canonical or alternate link names a page, not something the page loads. | |
| 116 | if (/^<link/i.test(tag) && !/rel=["']?(?:stylesheet|[a-z-]*icon|preload|modulepreload|prefetch)/i.test(tag)) continue; | |
| 117 | const target = /(?:src|href|srcset)=["']?([^"'\s>]+)/i.exec(tag)?.[1] ?? ""; | |
| 118 | expect(target.startsWith("http") || target.startsWith("//")).toBe(false); | |
| 119 | } | |
| 120 | expect(body).not.toMatch(outside); | |
| 121 | const policy = r.headers.get("content-security-policy") ?? ""; | |
| 122 | for (const directive of policy.split(";")) | |
| 123 | if (/^\s*(?:img|font|style)-src/.test(directive)) expect(directive).not.toMatch(/https?:/); | |
| 124 | } | |
| 125 | }); |
faceAt(...) is computed twice. Compute it once into a const.