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

Design: a home feed card is the design system's card, and the fcard-head/acts/more classes leave §5d #3180

Merged opened this

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

Merged

scout/forge:scout/3166-design-a-home-feed-card-is-the-design-sy → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3166-design-a-home-feed-card-is-the-design-sy was merged into main as ec72756.

Review

Sign in to review this change.
10 files changed +89 −61
docs/DESIGN.md +1 −1
@@ -395,7 +395,7 @@ gone, so this list only shrinks.
395395 - **`pages/limb.ts`** — acting on a whole limb from its milestone: `.limb-confirm` `.limb-rows` `.limb-why`
396396 - **`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`
397397 - **`pages/milestones.ts`** — milestones and one limb opened: `.ms-actions` `.ms-bar` `.ms-count` `.ms-figures` `.ms-head` `.ms-limb` `.ms-limbs` `.ms-more` `.ms-move` `.ms-name` `.ms-planner` `.ms-rows` `.ms-section` `.ms-state` `.ms-top` `.ms-trim` `.ms-weight`
398 - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard` `.fcard-acts` `.fcard-form` `.fcard-head` `.fcard-more` `.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`
398 - **`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`
399399 - **`studio/style.ts`** — the studio's tell box, app head, word and shelf rows: `.fcard-app` `.shelf-desc` `.shelf-facts` `.shelf-meta` `.studio-ask-actions` `.studio-head` `.studio-tell` `.studio-word`
400400
401401 ---
src/web/studio/feed.ts +24 −31
@@ -19,7 +19,7 @@ import type { FeedItem } from "../../feed/rank.ts";
1919 import { html, type Html } from "../html.ts";
2020 import { fragment } from "../layout.ts";
2121 import { outboxJs } from "../outbox.ts";
22 import { avatar, btn, empty, nameMark, timeAgo } from "../ui.ts";
22 import { avatar, btn, card, empty, nameMark, timeAgo } from "../ui.ts";
2323 import { writableAppsOf } from "./asks.ts";
2424
2525 /** Events that can change what the feed says. */
@@ -65,36 +65,30 @@ export function feedCard(
6565 ): Html {
6666 const steer = item.kind === "run" || item.kind === "cost";
6767 const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null;
68 return html`<article
69 class="fcard"
70 data-nav
71 tabindex="0"
72 data-item="${item.id}"
73 data-app="${item.app}"
74 data-kind="${item.kind}"
75 >
76 <div class="fcard-head">
77 ${avatar(item.actor ?? item.app)}
68 const head = html`${avatar(item.actor ?? item.app)}
7869 <strong class="fcard-who">${item.actor ?? item.app}</strong
7970 >${nameMark(item.actor === null ? undefined : marks.get(item.actor))}
8071 <span class="fcard-app muted small">${item.app} · ${timeAgo(item.at)}</span>
8172 <span class="spacer"></span>
82 <span class="badge">${KIND_LABEL[item.kind]}</span>
83 </div>
84 <p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.what}</a></p>
85 ${saysMore(item) ? html`<p class="fcard-why small muted">${item.reason}</p>` : null}
86 <details class="fcard-more">
73 <span class="badge">${KIND_LABEL[item.kind]}</span>`;
74 const body = html`<p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.what}</a></p>
75 ${saysMore(item) ? html`<p class="fcard-why small muted">${item.reason}</p>` : null}`;
76 const foot = html`<details class="quiet-details">
8777 <summary class="small muted" aria-label="${target ? "Steer, dismiss or mute" : "Tell the crew, dismiss or mute"}">${target ? "Steer" : "Tell"} · more</summary>
8878 <form method="post" action="${target ?? `/${item.app}/tell`}" class="fcard-form">
8979 <textarea class="input" name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea>
9080 ${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })}
9181 </form>
92 <span class="fcard-acts">
82 <span class="bar">
9383 <button type="button" class="btn btn-sm btn-ghost" data-feed="dismissed" title="Dismiss (x)">Dismiss</button>
9484 ${item.kind === "ask" || item.kind === "milestone" || item.kind === "choose" ? null : html`<button type="button" class="btn btn-sm btn-ghost" data-feed="muted" title="Mute ${item.app}">Mute app</button>`}
9585 </span>
96 </details>
97 </article>`;
86 </details>`;
87 return card(body, {
88 head,
89 foot,
90 attrs: html`data-nav tabindex="0" data-item="${item.id}" data-app="${item.app}" data-kind="${item.kind}"`,
91 });
9892 }
9993
10094 /** The composer's app value for "the forge": no app, so no slash to collide with one. */
@@ -111,17 +105,16 @@ const THOUGHT_AGE_MS = 24 * 60 * 60 * 1000;
111105 * once; line breaks kept by CSS), marked "received". It has no controls.
112106 */
113107 export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html {
114 return html`<article class="fcard fcard-thought" data-kind="thought" data-thought="${thought.id}">
115 <div class="fcard-head">
116 ${avatar(who)}
108 const head = html`${avatar(who)}
117109 <strong class="fcard-who">${who}</strong>
118110 <span class="fcard-app muted small">${timeAgo(thought.receivedAt)}</span>
119111 <span class="spacer"></span>
120 <span class="badge">Received</span>
121 </div>
122 <p class="fcard-reason fcard-text">${thought.text}</p>
123 ${placement ? replyUnder(placement) : ""}
124 </article>`;
112 <span class="badge">Received</span>`;
113 return card(
114 html`<p class="fcard-reason fcard-text">${thought.text}</p>
115 ${placement ? replyUnder(placement) : ""}`,
116 { head, attrs: html`data-kind="thought" data-thought="${thought.id}"` },
117 );
125118 }
126119
127120 /** The planner's answer under a thought: one or two lines, `#N` linking to the issue it names. */
@@ -322,7 +315,7 @@ ${outboxJs}
322315 return queue('/api/v1/feed/interactions', { item: card.getAttribute('data-item'), kind: kind, app: card.getAttribute('data-app') });
323316 }
324317 function gone(card) {
325 var cards = Array.prototype.slice.call(feed.querySelectorAll('.fcard'));
318 var cards = Array.prototype.slice.call(feed.querySelectorAll('[data-item]'));
326319 var next = cards[cards.indexOf(card) + 1] || cards[cards.indexOf(card) - 1];
327320 card.hidden = true;
328321 if (next) next.focus();
@@ -377,7 +370,7 @@ ${outboxJs}
377370 var c = e.target && e.target.closest ? e.target.closest('[data-chip]') : null;
378371 if (c) { chip(c); return; }
379372 var t = e.target && e.target.closest ? e.target.closest('[data-feed],[data-feed-open]') : null;
380 var card = t && t.closest('.fcard');
373 var card = t && t.closest('[data-item]');
381374 if (!card) return;
382375 if (t.hasAttribute('data-feed-open')) { send(card, 'opened'); return; }
383376 act(card, t.getAttribute('data-feed'));
@@ -386,7 +379,7 @@ ${outboxJs}
386379 // stays an ordinary post and waits for the network.
387380 feed.addEventListener('submit', function (e) {
388381 var form = e.target;
389 var card = form.closest && form.closest('.fcard');
382 var card = form.closest && form.closest('[data-item]');
390383 var box = form.elements && form.elements.text;
391384 if (!card || !box || !/\\/tell$/.test(form.getAttribute('action') || '')) return;
392385 var text = box.value.trim();
@@ -413,7 +406,7 @@ ${outboxJs}
413406 document.addEventListener('keydown', function (e) {
414407 if (e.key !== 'x' || e.metaKey || e.ctrlKey || e.altKey) return;
415408 var a = document.activeElement;
416 if (!a || !a.matches || !a.matches('.fcard') || !feed.contains(a)) return;
409 if (!a || !a.matches || !a.matches('[data-item]') || !feed.contains(a)) return;
417410 e.preventDefault();
418411 act(a, 'dismissed');
419412 });
src/web/studio/feed_style.ts +8 −14
@@ -1,23 +1,18 @@
11 /** The feed cards' slice of the served stylesheet (no imports: `style.ts` reads this). */
22
33 export const feedCss = /* css */ `
4 /* A timeline of rows, divided, not a stack of boxes. */
5 .feed { display: grid; gap: 0; border-top: 1px solid var(--border); }
6 .fcard { border-bottom: 1px solid var(--border); padding: 12px 4px; }
7 .fcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
8 .fcard[hidden] { display: none; }
9 .fcard-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
10 .fcard-reason { margin: 8px 0 4px; font-size: 1.05em; overflow-wrap: anywhere; }
4 /* A timeline of the design system's cards; the card's own margin spaces them. */
5 .feed { display: grid; gap: 0; }
6 .feed > .card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
7 .feed > .card[hidden] { display: none; }
8 .feed .card-foot > details { flex: 1 1 100%; }
9 .fcard-reason { margin: 0 0 4px; font-size: 1.05em; overflow-wrap: anywhere; }
1110 .fcard-text { white-space: pre-wrap; }
1211 /* The planner's reply sits under the thought, indented, one or two lines. */
1312 .fcard-reply { margin: 4px 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border); overflow-wrap: anywhere; }
1413 .fcard-unblock { display: flex; align-items: center; gap: 4px 10px; flex-wrap: wrap; margin: 0; }
1514 .fcard-unblock span { flex: 1 1 16ch; min-width: 0; overflow-wrap: anywhere; }
1615 .fcard-who { font-weight: 600; }
17 /* One quiet control under the post: say something, dismiss, mute. */
18 .fcard-more summary { cursor: pointer; list-style: none; padding: 2px 0; }
19 .fcard-more summary::-webkit-details-marker { display: none; }
20 .fcard-acts { display: flex; gap: 4px; margin-top: 6px; }
2116 .fcard-why { margin: 0 0 4px; }
2217 /* The composer: the home's one primary action, on top of the timeline. */
2318 .composer { display: grid; gap: 8px; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--card, var(--bg)); }
@@ -48,8 +43,7 @@ export const feedCss = /* css */ `
4843 .fcard-form { display: grid; gap: 8px; margin-top: 6px; }
4944 .fcard-form textarea { width: 100%; min-height: 56px; resize: vertical; }
5045 @media (max-width: 600px) {

The old phone rule gave the details summary a 44px target. Add one for .feed .quiet-details > summary so the Tell/Steer toggle stays thumb-sized at 360px.

51 .fcard { padding: 12px 2px; }
52 .fcard-acts .btn { padding: 8px 10px; }
53 .fcard-more summary { padding: 10px 0; }
46 .feed .bar .btn { padding: 8px 10px; }
47 .feed .quiet-details > summary { min-height: 44px; display: flex; align-items: center; }
5448 }
5549 `;
src/web/studio/flow_card.ts +6 −9
@@ -12,24 +12,21 @@ import { readStalled, type StalledStep } from "../../flow/stalled.ts";
1212 import type { User } from "../../core/users.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { html, type Html } from "../html.ts";
15 import { btn, timeAgo } from "../ui.ts";
15 import { btn, card, timeAgo } from "../ui.ts";
1616
1717 const BUTTON: Record<StalledStep["action"], string> = { run: "Run check", rerun: "Re-run" };
1818
1919 function stalledCard(s: StalledStep): Html {
2020 const base = `/${s.ownerName}/${s.repoName}/pulls/${s.pull}`;
21 return html`<article class="fcard" data-flow-card="${s.repo}#${String(s.pull)}">
22 <div class="fcard-head">
23 <strong class="fcard-who">${s.stage}</strong>
24 <span class="fcard-app muted small">${s.ownerName}/${s.repoName} · stuck since ${timeAgo(s.since)}</span>
25 </div>
26 <p class="fcard-reason"><a href="${base}">${s.title}</a>${s.issue === null ? null : html` <span class="muted small">for #${String(s.issue)}</span>`}</p>
21 const head = html`<strong class="fcard-who">${s.stage}</strong>
22 <span class="fcard-app muted small">${s.ownerName}/${s.repoName} · stuck since ${timeAgo(s.since)}</span>`;
23 const body = html`<p class="fcard-reason"><a href="${base}">${s.title}</a>${s.issue === null ? null : html` <span class="muted small">for #${String(s.issue)}</span>`}</p>
2724 <form method="post" action="${base}/checks" class="fcard-unblock">
2825 <input type="hidden" name="next" value="/">
2926 ${btn(BUTTON[s.action], { size: "sm", icon: "play" })}
3027 <span class="muted small">${s.unblock}</span>
31 </form>
32 </article>`;
28 </form>`;
29 return card(body, { head, attrs: html`data-flow-card="${s.repo}#${String(s.pull)}"` });
3330 }
3431
3532 /** The cards, oldest stall first; nothing at all when nothing is stalled. Every one is shown: the
src/web/studio/style.ts +3 −3
@@ -62,14 +62,14 @@ const rowActions = part(/* css */ `
6262 const phone = part(/* css */ `
6363 @media (max-width: 600px) {
6464 .studio-head { flex-wrap: wrap; }
65 .fcard-head { flex-wrap: wrap; row-gap: 2px; }
66 .fcard-head .fcard-app { flex: 1 1 100%; }
65 .feed .card-head { row-gap: 2px; }
66 .feed .card-head .fcard-app { flex: 1 1 100%; }
6767 .studio-head h1 { flex: 1 1 100%; flex-wrap: wrap; overflow-wrap: anywhere; }
6868 .studio-head .env-facts { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; }
6969 .studio-head form, .studio-head form .btn { flex: 1 1 auto; }
7070 .studio-tell .bar { flex-wrap: wrap; }
7171 .studio-tell .bar .btn, .studio-ask-actions .btn { min-height: 44px; }
72 .fcard-acts .btn, .fcard-form .btn { min-height: 44px; }
72 .feed .bar .btn, .fcard-form .btn { min-height: 44px; }
7373 .studio-ask-actions { display: flex; flex-direction: column; align-items: stretch; white-space: normal; width: 100%; }
7474 .studio-word { white-space: normal; }
7575 .shelf-meta { flex-wrap: wrap; }
src/web/ui.ts +3
@@ -772,12 +772,15 @@ export interface CardOptions {
772772 pad?: boolean;
773773 id?: string;
774774 extra?: string;
775 /** Further attributes for the card itself (data hooks, tabindex), already escaped. */
776 attrs?: Html;

New option, untested. Add a test that attrs render on the card element and that the default output is unchanged.

775777 }
776778
777779 export function card(body: Html, o: CardOptions = {}): Html {
778780 return html`<div
779781 class="card${o.extra ? ` ${o.extra}` : ""}"
780782 ${o.id ? html`id="${o.id}"` : null}
783 ${o.attrs ?? null}
781784 ${o.tone && o.tone !== "default" ? html`data-tone="${o.tone}"` : null}
782785 >
783786 ${o.head ? html`<div class="card-head">${o.head}</div>` : null}
test/card-attrs.test.ts added +41
@@ -0,0 +1,41 @@
1 import { describe, expect, test } from "bun:test";
2 import { html } from "../src/web/html.ts";
3 import { card } from "../src/web/ui.ts";
4 import { feedCard } from "../src/web/studio/feed.ts";
5
6 const root = (s: string) => /^<div\b[^>]*>/.exec(s.trim())?.[0] ?? "";
7
8 describe("card() attrs", () => {
9 test("attrs land on the .card element itself", () => {
10 const out = card(html`<p>x</p>`, { attrs: html`data-item="e1" tabindex="0"` }).text;
11 const tag = root(out);
12 expect(tag).toContain('class="card"');
13 expect(tag).toContain('data-item="e1"');
14 expect(tag).toContain('tabindex="0"');
15 });
16
17 test("without attrs the output is the plain card", () => {
18 const plain = card(html`<p>x</p>`).text;
19 expect(card(html`<p>x</p>`, {}).text).toBe(plain);
20 expect(root(plain).replace(/\s+/g, " ")).toBe('<div class="card" >');
21 expect(plain).not.toContain("data-");
22 expect(plain).not.toContain("tabindex");
23 });
24
25 test("a feed card carries its hooks on the same .card element", () => {
26 const out = feedCard({
27 id: "event:e1",
28 kind: "run",
29 app: "a/b",
30 actor: null,
31 at: new Date().toISOString(),
32 link: "/a/b/runs/r",
33 what: "w",
34 reason: "r",
35 score: 1,
36 }).text;
37 const tag = root(out);
38 expect(tag).toContain('class="card"');
39 for (const a of ['data-item="event:e1"', "data-nav", 'tabindex="0"']) expect(tag).toContain(a);
40 });
41 });
test/feed-page.test.ts +1 −1
@@ -86,7 +86,7 @@ describe("the feed on the home page", () => {
8686 expect(text).toContain("alice/open");
8787 expect(text).not.toContain("<script>alert(1)</script>");
8888 expect(text).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
89 const order = [...text.matchAll(/class="fcard"[^>]*data-item="([^"]+)"/g)].map((m) => m[1]);
89 const order = [...text.matchAll(/class="card"\s+data-nav[^>]*data-item="([^"]+)"/g)].map((m) => m[1]);
9090 expect(order).toEqual(readFeed(app.db, alice).items.map((i) => i.id));
9191 });
9292
test/home-feed.test.ts +1 −1
@@ -140,7 +140,7 @@ describe("home is the feed", () => {
140140 expect(text).toContain("on “Water the tomatoes at dawn”: Mulch them first");
141141 // Every post's actions sit behind one quiet control, and "because you
142142 // own" — true of every post here — is not said again and again.
143 expect(text).toContain('class="fcard-more"');
143 expect(text).toContain('class="quiet-details"');
144144 expect(text).not.toContain("because you own alice/garden");
145145 // Your apps: a narrow column, and a tab on a phone.
146146 expect(text).toContain('id="live-apps"');
test/studio-phone.test.ts +1 −1
@@ -6,7 +6,7 @@ describe("the studio at 360px", () => {
66 const phone = studioCss.slice(studioCss.indexOf("@media (max-width: 600px) {\n .studio-head"));
77
88 test("every component the issue names has a phone rule", () => {
9 for (const sel of [".studio-head", ".studio-tell", ".fcard-head", ".studio-ask-actions", ".studio-word"]) {
9 for (const sel of [".studio-head", ".studio-tell", ".feed .card-head", ".studio-ask-actions", ".studio-word"]) {
1010 expect(phone).toContain(sel);
1111 }
1212 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs