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

Merged opened this in answer to

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

Merged

mason/forge:mason/10570-the-forge-comes-alive-calmly-its-own-pix → main 2 commits fork 7 threads · 7 unresolved

  • Merged. mason/10570-the-forge-comes-alive-calmly-its-own-pix was merged into main as f214323.

Review

Sign in to review this change.
15 files changed +481 −14
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 @@
2727 */
2828
2929 import { raw, type Html } from "./html.ts";
30 import { pixelInline, pixelSymbols } from "./pixel.ts";
3031
3132 /** What the drawing needs, on the symbol rather than on every slot. */
3233 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 {
128129 const symbols = (Object.keys(PATHS) as IconName[])
129130 .map((name) => `<symbol id="i-${name}" ${DRAW}>${PATHS[name]}</symbol>`)
130131 .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>`;
132133 }
133134
134135 /**
@@ -149,7 +150,7 @@ export function pointAtSprite(markup: string, spritePath: string): string {
149150 */
150151 export function inlineIcons(markup: string): string {
151152 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),
153154 );
154155 }
155156
src/web/pages/map.ts +10 −1
@@ -19,6 +19,7 @@
1919 * can see, one lane each.
2020 */
2121
22 import { faceAt } from "../pixel.ts";
2223 import type { MapHold } from "../../crew/held.ts";
2324 import { sealedScope } from "../../core/sealed.ts";
2425 import type { Database } from "bun:sqlite";
@@ -712,6 +713,7 @@ function node(
712713 const number = `#${n.issue.number}`;
713714 const who = n.issue.assigneeName;
714715 const whoX = left + 9 + number.length * 6.4;
716 const face = who ? faceAt(who, whoX, bottom - 11, 14) : null;
715717 return html`<a
716718 class="map-node"
717719 data-state="${word}"
@@ -745,7 +747,14 @@ function node(
745747 : html`<tspan x="${left}" dy="16">${second}</tspan>`}
746748 </text>
747749 <text class="map-meta" x="${left}" y="${bottom}">${number}</text>
outdated

faceAt(...) is computed twice. Compute it once into a const.

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
749758 ? html`<g class="map-who">
750759 <circle cx="${whoX + 7}" cy="${bottom - 4}" r="7" />
751760 <text class="map-who-i" x="${whoX + 7}" y="${bottom - 1}"
src/web/pages/runs.ts +10 −2
@@ -11,6 +11,7 @@
1111 * id, so the redirect itself says nothing about what exists.
1212 */
1313
14 import { crewFace } from "../pixel.ts";
1415 import { steerRefusedWords } from "../../actions/steer_words.ts";
1516 import { readLooks } from "../../checks/looks.ts";
1617 import type { Database } from "bun:sqlite";
@@ -247,6 +248,13 @@ const kindIcon = (kind: RunKind) =>
247248
248249 // ---- rows ----
249250
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
250258 /** One run in a list: what it was, why, how it went, and when. */
251259 export function runRow(
252260 base: string,
@@ -259,7 +267,7 @@ export function runRow(
259267 return row({
260268 href: `${base}/runs/${run.id}`,
261269 nav: true,
262 lead: kindIcon(run.kind),
270 lead: runLead(run, actors),
263271 title,
264272 titleAttr: title,
265273 meta: html`<span title="${meta}">${meta}</span>`,
@@ -287,7 +295,7 @@ export function crewActivityCard(ctx: Ctx, repo: Repo, issue: Issue): Html {
287295 ?.username ?? "an agent";
288296 return row({
289297 href: `${base}/runs/${run.id}`,
290 lead: icon("bot"),
298 lead: crewFace(who, run.state === "running" ? "working" : "still", 16, true) ?? icon("bot"),
291299 title: html`${who}
292300 <span class="muted">${reasonWords(run.reason)}</span>`,
293301 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 {
237237 const { repos } = writableAppsOf(ctx, user);
238238 const slug = (r: { ownerName: string; name: string }) => `${r.ownerName}/${r.name}`;
239239 const noApps = html`${empty({
240 icon: "rocket",
240 scene: "anvil",
241241 title: "No apps yet",
242242 note: "Make one, tell the crew what it is, and watch it come up here.",
243243 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";
2222 import { FORGE_TARGET } from "./feed.ts";
2323 import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts";
2424 import { repoBase } from "../chrome.ts";
25 import { crewFace, type FaceState } from "../pixel.ts";
2526
2627 /** The events that move a build or a shipment. */
2728 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 {
5455 </section>`;
5556 }
5657
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 {
5859 const shown = rows.map((r) =>
5960 row({
6061 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.

6263 title: html`<span class="truncate" title="${r.repo}: ${r.full}">${r.title}</span>`,
6364 trail: html`<span class="muted small">${timeAgo(r.at)}</span>`,
6465 }),
@@ -85,7 +86,7 @@ export function movingRegion(db: Database, viewer: User): Html {
8586 const shipped = readShipped(db, viewer);
8687 return html`<div id="live-moving" class="stack" data-live="${MOVING_LIVE}" data-live-src="/live/moving">
8788 ${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")}
8990 ${disclosure("Just shipped", listOf("Just shipped", shipped, "Nothing has shipped yet", false), { small: true })}
9091 </div>`;
9192 }
src/web/studio/run.ts +3 −1
@@ -36,6 +36,7 @@ import { costText, durationText, RUN_PAGE_CSS, runTitle, runTokens } from "../pa
3636 import { placementLine, sandboxLine } from "./sandbox.ts";
3737 import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts";
3838 import { changeHref, tellHref } from "./nouns.ts";
39 import { crewFace } from "../pixel.ts";
3940 import { unnamedRun, voiceFor, type Actor } from "./voice.ts";
4041 import { runWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
4142
@@ -153,7 +154,8 @@ export async function runView(
153154 >
154155 <h1>${title}</h1>
155156 <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}
157159 ${when(
158160 issue,
159161 (i) =>
src/web/studio/words.ts +3 −2
@@ -8,6 +8,7 @@
88 * thing about the same commit. Nothing here knows about a page.
99 */
1010
11 import { crewFace } from "../pixel.ts";
1112 import { shownState } from "../../runs/state.ts";
1213 import type { Database } from "bun:sqlite";
1314 import { ancestries, type AncestryCache } from "./ancestry.ts";
@@ -564,13 +565,13 @@ export const wordVariant = (word: Word): WordVariant => WORD_VARIANT[word];
564565
565566 /** One of the five (or six) studio words, as a badge. An id names the one a
566567 * 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 {
568569 return html`<span
569570 class="badge"
570571 data-variant="${wordVariant(word)}"
571572 data-word="${word}"
572573 ${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"
574575 ? "open"
575576 : word}</span
576577 >`;
src/web/style/base.ts +7
@@ -31,6 +31,13 @@ export const base = /* css */ `
3131 --page: 1120px;
3232 --measure: 72ch;
3333 --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;
3441 --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
3542 --mono: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
3643 }
src/web/style/components.ts +18
@@ -201,6 +201,24 @@ a.stat:hover { border-color: var(--border-strong); }
201201 .btn[aria-busy="true"] .ico-loader { animation-play-state: running; }
202202 @keyframes spin { to { transform: rotate(360deg); } }
203203
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
204222 /* One stored hex, derived three ways, so a chip reads correctly in all six
205223 themes. --label is set by a per-page nonce'd rule; the hex is validated
206224 before it is ever embedded. */
src/web/ui.ts +8 −1
@@ -23,6 +23,7 @@ import {
2323 } from "../kernel/tokens-format.ts";
2424 import { drawChart, type Cell, type Chart } from "../boards/chart.ts";
2525 import { icon, type IconName } from "./icons.ts";
26 import { crewFace, scene, type SceneName } from "./pixel.ts";
2627 import { Html, html, raw, when } from "./html.ts";
2728
2829 export { icon, type IconName };
@@ -822,6 +823,10 @@ function initials(name: string): string {
822823 }
823824
824825 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>`;
825830 return html`<span
826831 class="avatar"
827832 data-h="${hueOf(name)}"
@@ -1348,12 +1353,14 @@ export function confirmAction(o: {
13481353
13491354 export function empty(o: {
13501355 icon?: IconName;
1356 /** A small drawn scene instead of the icon: one picture, then the line. */
1357 scene?: SceneName;
13511358 title: string;
13521359 note?: string;
13531360 action?: Html;
13541361 }): Html {
13551362 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}
13571364 <p class="empty-title">${o.title}</p>
13581365 ${o.note ? html`<p>${o.note}</p>` : null} ${o.action ?? null}
13591366 </div>`;
test/map.test.ts +1 −1
@@ -471,7 +471,7 @@ describe("the map, per app and across the forge", () => {
471471 expect(page).toContain('href="/alice/engine/map"');
472472 expect(page).toContain("Extract renderer");
473473 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*>/,
475475 );
476476 expect(page).toContain('data-autonomy="off"');
477477 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs