Public: anyone can read this; the crew can change it.

Design: /map's lanes use §5's list and row, and the map-lane-* classes leave §5d #3662

Merged opened this

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

Merged

mason/forge:mason/3651-design-map-s-lanes-use-5-s-list-and-row → main 3 commits fork

  • Merged. mason/3651-design-map-s-lanes-use-5-s-list-and-row was merged into main as 50a126b.

Review

Sign in to review this change.
6 files changed +78 −77
docs/DESIGN.md +2 −2
@@ -157,7 +157,7 @@ specification.
157157 | **meter** `meter()` | `meter` | `data-tone=attn` when the share is a limit reached | native `<meter min=0 max=100>`, a whole percentage, an `aria-label` saying what it is a share of; no width rule per value, so no inline style | `▓▓▓░░ 62%` |
158158 | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name |
159159 | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar |
160 | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta |
160 | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`; settings: `.setting-list`, `.row-fields`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `fold` makes the row a `<details>`'s `<summary>`, its lead the caret turned when open (/map's lanes); `.row-meta` is one honest line: nowrap, ellipsis, `title` — except in a `.setting-list`, where it wraps | one line: glyph, title, right-aligned meta |
161161 | **edit row** | `.edit-row` | — | one line on a desktop, stacked below 720px | a form line |
162162 | **colour field** | `.color-field` | — | native `<input type=color>` plus its hex; works with no script | a hex entry |
163163 | **table** | `.table` | `data-dense`; `.num` cells right-aligned | — | aligned columns |
@@ -396,7 +396,7 @@ gone, so this list only shrinks.
396396 - **`style/content.ts`, search results** — search results, the inbox's ask cards, the client list and the claim link: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.claim-link-box` `.forges-sent` `.live-toggle` `.needs-you` `.no-hits`
397397 - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals`
398398 - **`pages/flow-panel.ts`** — the pipeline's outflow sparklines, on /flow and /boards: `.flow-bar` `.flow-out`
399 - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap`
399 - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap`
400400 - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs`
401401
402402 ---
src/web/pages/map.ts +49 −68
@@ -57,7 +57,7 @@ import {
5757 import { liveRegionsJs } from "../client.ts";
5858 import { html, type Html } from "../html.ts";
5959 import { page } from "../layout.ts";
60 import { avatar, icon } from "../ui.ts";
60 import { avatar, badge, icon, listCard, row } from "../ui.ts";
6161 import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts";
6262 import {
6363 parkedTwice,
@@ -878,12 +878,12 @@ export function countWords(words: readonly Word[]): Record<Word, number> {
878878
879879 function countsLine(counts: Record<Word, number>): Html {
880880 const always = new Set<Word>(["ready", "working", "blocked", "done"]);
881 return html`<p class="map-counts">
881 return html`<span class="map-counts">
882882 ${WORDS.filter((w) => always.has(w) || counts[w] > 0).map(
883883 (w) =>
884884 html`<span><i data-state="${w}"></i>${w} <b>${counts[w]}</b></span>`,
885885 )}
886 </p>`;
886 </span>`;
887887 }
888888
889889 function rankedList(
@@ -1123,32 +1123,17 @@ export const mapCss = /* css */ `
11231123 .map-pillars ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 14px; }
11241124 .map-empty { display: grid; justify-items: center; gap: 8px; padding: 40px 16px; border: 1px dashed var(--border); border-radius: var(--radius); color: var(--muted-fg); text-align: center; }
11251125
1126 /* ---- lanes ---- */
1127 .map-lanes { display: grid; gap: 14px; margin-top: 16px; }
1128 .map-lane { border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); box-shadow: var(--shadow-1); }
1129 .map-lane-head { display: flex; align-items: center; gap: 8px 14px; flex-wrap: wrap; padding: 10px 14px; cursor: pointer; list-style: none; }
1130 .map-lane-head::-webkit-details-marker { display: none; }
1131 .map-lane-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius); }
1132 .map-lane-caret { display: inline-flex; color: var(--faint); transition: transform 160ms var(--ease); }
1133 .map-lane[open] > .map-lane-head .map-lane-caret { transform: rotate(90deg); }
1134 .map-lane-name { font-weight: 620; font-size: 15px; letter-spacing: -0.01em; }
1135 .map-lane-name .muted { font-weight: 500; }
1136 .map-lane-word { font-size: 12.5px; color: var(--muted-fg); padding: 1px 8px; border: 1px solid var(--border); border-radius: 999px; line-height: 18px; }
1137 .map-lane-word[data-autonomy="autonomous"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); }
1138 .map-lane-crew { display: inline-flex; align-items: center; gap: 4px; }
1139 .map-lane-crew .avatar { box-shadow: 0 0 0 2px var(--card); }
1140 .map-lane-head .map-counts { margin: 0 0 0 auto; }
1141 .map-lane[data-idle="1"] > .map-lane-head .map-lane-name { color: var(--muted-fg); font-weight: 560; }
1142 .map-lane > .map-canvas, .map-lane > .map-empty { border: 0; border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); }
1143 @media (max-width: 600px) {
1144 .map-lane-head { gap: 6px 10px; }
1145 .map-lane-head .map-counts { margin-left: 0; flex-basis: 100%; }
1146 }
1126 /* ---- lanes: §5 list rows that fold ---- */
1127 #lanes { margin-top: 16px; }
1128 #lanes .row-meta .map-counts { margin: 0; }
1129 #lanes li[data-autonomy="autonomous"] .row-title .badge { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); }
1130 #lanes li[data-idle] .row-title { color: var(--muted-fg); }
1131 #lanes details > .map-canvas, #lanes details > .map-empty { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
11471132 @media (prefers-reduced-motion: reduce) {
1148 .map-card, .map-title, .map-lane-caret { transition: none; }
1133 .map-card, .map-title { transition: none; }
11491134 }
11501135 @media (forced-colors: active) {
1151 .map-tool:focus-visible, .map-lane-head:focus-visible { outline: 2px solid Highlight; }
1136 .map-tool:focus-visible { outline: 2px solid Highlight; }
11521137 .map-node:focus-visible .map-card { stroke: Highlight; stroke-width: 3; }
11531138 }
11541139 `;
@@ -1294,12 +1279,12 @@ export const mapViewJs = /* js */ `
12941279
12951280 document.addEventListener('toggle', function (e) {
12961281 var d = e.target;
1297 if (!d || !d.classList || !d.classList.contains('map-lane') || !d.id) return;
1282 if (!d || !d.id || d.id.indexOf('lane-') !== 0) return;
12981283 folds[d.id] = d.open;
12991284 if (d.open) all(d).forEach(function (c) { setup(c, false); });
13001285 }, true);
13011286 function restore(root) {
1302 Array.prototype.forEach.call(root.querySelectorAll('.map-lane'), function (d) { if (d.id in folds) d.open = folds[d.id]; });
1287 Array.prototype.forEach.call(root.querySelectorAll('details[id^="lane-"]'), function (d) { if (d.id in folds) d.open = folds[d.id]; });
13031288 if (root.classList && root.classList.contains('map-canvas')) setup(root, false);
13041289 all(root).forEach(function (c) { setup(c, false); });
13051290 }
@@ -1654,51 +1639,47 @@ export function globalLanes(
16541639 );
16551640 }
16561641
1657 /** A lane: the repository's name, its autonomy mode, who is on it, and its map. */
1642 /** A lane: one folding list row — the caret, the repository's name and its
1643 * autonomy mode, who is on it, its counts — over its map. */
16581644 export function laneHtml(lane: Lane, width: number): Html {
16591645 const { repo } = lane;
1660 const id = `lane-${repo.ownerName}-${repo.name}`;
1661 return html`<details
1662 class="map-lane"
1663 id="${id}"
1664 ${lane.idle ? html`data-idle="1"` : html`open`}
1665 >
1666 <summary class="map-lane-head">
1667 <span class="map-lane-caret">${icon("chevronRight")}</span>
1668 <span class="map-lane-name"
1669 ><a href="${repoBase(repo)}/map"
1670 ><span class="muted">${repo.ownerName}/</span>${repo.name}</a
1671 ></span
1646 return row({
1647 lead: icon("chevronRight"),
1648 title: html`<a href="${repoBase(repo)}/map"
1649 ><span class="muted">${repo.ownerName}/</span>${repo.name}</a
16721650 >
1673 <span class="map-lane-word" data-autonomy="${repo.autonomy}"
1674 >${repo.autonomy}</span
1675 >
1676 ${lane.workers.length === 0
1677 ? null
1678 : html`<span
1679 class="map-lane-crew"
1680 title="Working: ${lane.workers.join(", ")}"
1681 >${lane.workers.slice(0, 5).map((w) => avatar(w))}${lane.workers
1682 .length > 5
1651 ${badge(repo.autonomy, { variant: "outline" })}`,
1652 meta: countsLine(lane.counts),
1653 trail:
1654 lane.workers.length === 0
1655 ? html``
1656 : html`<span title="Working: ${lane.workers.join(", ")}"
1657 >${lane.workers.slice(0, 5).map((w) => html`${avatar(w)} `)}${lane
1658 .workers.length > 5
16831659 ? html`<span class="muted small"
16841660 >+${lane.workers.length - 5}</span
16851661 >`
16861662 : null}</span
1687 >`}
1688 ${countsLine(lane.counts)}
1689 </summary>
1690 ${drawMap(
1691 repo,
1692 lane.graph,
1693 lane.placed,
1694 emphasisFor(lane.ready, lane.critical, lane.marks),
1695 {
1696 id: `map-canvas-${repo.ownerName}-${repo.name}`,
1697 minWidth: width,
1698 cap: 0.6,
1699 },
1700 )}
1701 </details>`;
1663 >`,
1664 data: lane.idle
1665 ? { autonomy: repo.autonomy, idle: "1" }
1666 : { autonomy: repo.autonomy },
1667 fold: {
1668 id: `lane-${repo.ownerName}-${repo.name}`,
1669 open: !lane.idle,
1670 body: drawMap(
1671 repo,
1672 lane.graph,
1673 lane.placed,
1674 emphasisFor(lane.ready, lane.critical, lane.marks),
1675 {
1676 id: `map-canvas-${repo.ownerName}-${repo.name}`,
1677 minWidth: width,
1678 cap: 0.6,
1679 },
1680 ),
1681 },
1682 });
17021683 }
17031684
17041685 /** GET /map — every app the viewer can see, one lane each. */
@@ -1718,13 +1699,13 @@ export function globalMapPage(ctx: Ctx): Response {
17181699 const body = html`${pageHead("Map", {
17191700 sub: "Every app you can see, laid out by what waits for what. The top edge of each lane is ready now; a ringed card is where the crew is.",
17201701 })}
1721 <div id="map-lanes" class="map-lanes" data-live="${LIVE_KINDS}">
1702 <div id="lanes" data-live="${LIVE_KINDS}">
17221703 ${lanes.length === 0
17231704 ? html`<p class="muted">
17241705 Nothing to map: no open issues anywhere you can see, and nobody
17251706 working.
17261707 </p>`
1727 : lanes.map((lane) => laneHtml(lane, width))}
1708 : listCard(lanes.map((lane) => laneHtml(lane, width)))}
17281709 </div>`;
17291710 const response = page({
17301711 ctx,
src/web/style/components.ts +4
@@ -372,6 +372,10 @@ a.row:hover .row-title { color: var(--accent); }
372372 .row[data-state="closed"] .row-lead { color: var(--danger); }
373373 .row[data-state="merged"] .row-lead { color: var(--merged); }
374374 .row[data-state="draft"] .row-lead { color: var(--muted-fg); }
375 /* A row that folds is a <summary>: its lead is the caret, turned when open. */
376 summary.row { cursor: pointer; list-style: none; }
377 summary.row:hover { background: var(--muted); }
378 details[open] > summary.row > .row-lead { transform: rotate(90deg); }
375379 /* A count with its glyph: comments on a row, members on a team. */
376380 .count {
377381 display: inline-flex; align-items: center; gap: 4px;
src/web/ui.ts +11 −2
@@ -834,10 +834,14 @@ export interface RowOptions {
834834 data?: Readonly<Record<string, string>>;
835835 /** The row is the one chosen (`aria-current="true"`). */
836836 current?: boolean;
837 /** The row folds: it is the `<summary>` of a `<details>` holding `body`,
838 * open when `open` says so. Its `lead` is the caret. */
839 fold?: { id: string; open: boolean; body: Html };
837840 }
838841
839842 /** One list row. `<a>` when it navigates, `<div>` when it holds its own
840 * controls — the hover affordance follows automatically. */
843 * controls, `<summary>` when it folds — the hover affordance follows
844 * automatically. */
841845 export function row(o: RowOptions): Html {
842846 const inner = html`${o.lead
843847 ? html`<span class="row-lead">${o.lead}</span>`
@@ -860,7 +864,12 @@ export function row(o: RowOptions): Html {
860864 .filter(([key]) => /^[a-z][a-z-]*$/.test(key))
861865 .map(([key, value]) => html` data-${raw(key)}="${value}"`);
862866 return html`<li${data}${o.current ? raw(' aria-current="true"') : null}>
863 ${o.href !== undefined
867 ${o.fold !== undefined
868 ? html`<details id="${o.fold.id}" ${o.fold.open ? raw("open") : null}>
869 <summary ${attrs}>${inner}</summary>
870 ${o.fold.body}
871 </details>`
872 : o.href !== undefined
864873 ? html`<a href="${o.href}" ${attrs}>${inner}</a>`
865874 : html`<div ${attrs}>${inner}</div>`}
866875 </li>`;
test/map-cache.test.ts +1 −1
@@ -76,7 +76,7 @@ async function boot(shape: Shape): Promise<{ f: Fixture; servers: Servers }> {
7676 const get = async (servers: Servers, path: string, cookie: string): Promise<string> =>
7777 (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
7878
79 const lanesIn = (page: string): number => page.split('class="map-lane"').length - 1;
79 const lanesIn = (page: string): number => page.split('<details id="lane-').length - 1;
8080
8181 describe("/map on a many-lane fixture, warm", () => {
8282 let servers: Servers;
test/map.test.ts +11 −4
@@ -392,23 +392,30 @@ describe("the map, per app and across the forge", () => {
392392 ).text();
393393 expect(page).toContain("<h1>Map</h1>");
394394 expect(page).toMatch(
395 /<div[^>]*id="map-lanes"[^>]*data-live="run\.queued,run\.started,run\.finished,issue\.\*,pull\.\*"/,
395 /<div[^>]*id="lanes"[^>]*data-live="run\.queued,run\.started,run\.finished,issue\.\*,pull\.\*"/,
396396 );
397 // A lane is a §5 list row that folds: its <li> and its <details>.
397398 const lane = (id: string) =>
398 page.match(new RegExp(`<details[^>]*id="${id}"[^>]*>`))?.[0] ?? "";
399 page.match(new RegExp(`<li[^>]*>\\s*<details[^>]*id="${id}"[^>]*>`))?.[0] ??
400 "";
399401 // engine has the crew on it: open, with who is working in its header.
400402 expect(lane("lane-alice-engine")).toMatch(/\sopen\s*>/);
401403 expect(lane("lane-alice-engine")).not.toContain("data-idle");
402404 expect(page).toContain('href="/alice/engine/map"');
403405 expect(page).toContain("Extract renderer");
404406 expect(page).toMatch(
405 /class="map-lane-crew"[^>]*>[\s\S]*?class="avatar"[^>]*>s</,
407 /title="Working: s[^"]*"[^>]*>[\s\S]*?class="avatar"[^>]*>s</,
406408 );
407409 expect(page).toContain('data-autonomy="off"');
408410 // dormant has nothing ready and nobody working: it folds to its header.
409411 expect(lane("lane-alice-dormant")).toContain('data-idle="1"');
410412 expect(lane("lane-alice-dormant")).not.toMatch(/\sopen\s*>/);
411413 expect(page).toContain('<i data-state="parked"></i>parked <b>1</b>');
414 // The lanes are §5's list and rows, not a page-local component (#3651).
415 expect(page).toMatch(/id="lanes"[^>]*>\s*<div\s+class="card"[^>]*>\s*<ul class="list">/);
416 expect(page).toMatch(/<details[^>]*id="lane-alice-engine"[^>]*>\s*<summary\s+class="row"/);
417 for (const gone of ["map-lane", "map-lane-caret", "map-lane-crew", "map-lane-head", "map-lane-name", "map-lane-word", "map-lanes"])
418 expect(page).not.toContain(gone);
412419 // Every lane is drawn at the widest lane's width: one scale, one grid.
413420 const widths = [
414421 ...page.matchAll(/class="map-canvas"[^>]*data-w="(\d+)"/g),
@@ -442,7 +449,7 @@ describe("the map, per app and across the forge", () => {
442449 await fetch(`${base}/map`, { headers: { cookie: await signIn("carol") } })
443450 ).text();
444451 expect(page).toContain("Nothing to map");
445 expect(page).not.toContain('class="map-lane"');
452 expect(page).not.toContain('id="lane-');
446453 expect(page).not.toContain("No open issues to map");
447454
448455 const anonymous = await fetch(`${base}/map`, { redirect: "manual" });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs