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

The home feed's cards draw §5's card, row and btn, and the .fcard-* classes leave feed.ts's markup #4279

Merged opened this

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

Merged

scout/forge:scout/4267-the-home-feed-s-cards-draw-5-s-card-row → main 1 commit fork

  • Merged. scout/4267-the-home-feed-s-cards-draw-5-s-card-row was merged into main as bcac905.

Review

Sign in to review this change.
3 files changed +65 −9
src/web/studio/feed.ts +12 −8
@@ -67,18 +67,18 @@ export function feedCard(
6767 const steer = item.kind === "run" || item.kind === "cost";
6868 const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null;
6969 const head = html`${avatar(item.actor ?? item.app)}
70 <strong class="fcard-who">${item.actor ?? item.app}</strong
70 <strong>${item.actor ?? item.app}</strong
7171 >${nameMark(item.actor === null ? undefined : marks.get(item.actor))}
7272 <span class="muted small">${item.app} · ${timeAgo(item.at)}</span>
7373 <span class="spacer"></span>
7474 <span class="badge">${KIND_LABEL[item.kind]}</span>`;
75 const body = html`<p class="fcard-reason"><a href="${item.link}" data-feed-open>${item.what}</a></p>
76 ${saysMore(item) ? html`<p class="fcard-why small muted">${item.reason}</p>` : null}`;
75 const body = html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p>
76 ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`;
7777 const foot = disclosure(
7878 `${target ? "Steer" : "Tell"} · more`,
79 html`<form method="post" action="${target ?? `/${item.app}/tell`}" class="fcard-form">
79 html`<form method="post" action="${target ?? `/${item.app}/tell`}" class="stack">
8080 <textarea class="input" name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea>
81 ${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })}
81 <span class="bar">${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })}</span>
8282 </form>
8383 <span class="bar">
8484 <button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="dismissed" title="Dismiss (x)">Dismiss</button>
@@ -102,18 +102,22 @@ const THOUGHTS_SHOWN = 3;
102102 /** A thought stays at the top for a day, then leaves the feed (it is still kept). */
103103 const THOUGHT_AGE_MS = 24 * 60 * 60 * 1000;
104104
105 /** Text with its line breaks kept: each line escaped, a `<br>` between. */
106 const lines = (text: string): Html[] =>
107 text.split(/\r?\n/).map((line, i) => (i === 0 ? html`${line}` : html`<br />${line}`));
108
105109 /**
106110 * A thought as the owner's post: their own words, verbatim (escaped here,
107111 * once; line breaks kept by CSS), marked "received". It has no controls.
108112 */
109113 export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html {
110114 const head = html`${avatar(who)}
111 <strong class="fcard-who">${who}</strong>
115 <strong>${who}</strong>
112116 <span class="muted small">${timeAgo(thought.receivedAt)}</span>
113117 <span class="spacer"></span>
114118 <span class="badge">Received</span>`;
115119 return card(
116 html`<p class="fcard-reason fcard-text">${thought.text}</p>
120 html`<p class="break">${lines(thought.text)}</p>
117121 ${placement ? replyUnder(placement) : ""}`,
118122 { head, attrs: html`data-kind="thought" data-thought="${thought.id}"` },
119123 );
@@ -126,7 +130,7 @@ export function replyUnder(placement: Placement): Html {
126130 const repo = /^#(\d+)$/.test(part) ? where.get(Number(part.slice(1))) : undefined;
127131 return repo === undefined ? part : html`<a href="/${repo}/issues/${part.slice(1)}">${part}</a>`;
128132 });
129 return html`<p class="fcard-reply small" data-placement="${placement.kind}"><span class="muted">Planner · ${placement.kind}</span> ${parts}</p>`;
133 return html`<p class="small break" data-placement="${placement.kind}"><span class="muted">Planner · ${placement.kind}</span> ${parts}</p>`;
130134 }
131135
132136 /** The longest tell the composer takes (its textarea's `maxlength`). */
test/feed-card-markup.test.ts added +52
@@ -0,0 +1,52 @@
1 /** The home feed's cards are drawn from §5's parts: no `.fcard-*` class is left in the markup (#4267). */
2 import { describe, expect, test } from "bun:test";
3 import type { Placement } from "../src/core/placements.ts";
4 import type { Thought } from "../src/core/thoughts.ts";
5 import type { FeedItem } from "../src/feed/rank.ts";
6 import { feedCard, thoughtCard } from "../src/web/studio/feed.ts";
7
8 const item = (over: Partial<FeedItem> = {}): FeedItem =>
9 ({
10 id: "e1",
11 kind: "run",
12 app: "alice/notes",
13 actor: "scribe",
14 at: new Date().toISOString(),
15 what: "Sorted <b>the</b> list",
16 reason: "because the crew said so",
17 link: "/alice/notes/runs/r1",
18 ...over,
19 }) as FeedItem;
20
21 describe("feed card markup", () => {
22 test("a post keeps its words, link and forms and none of the old names", () => {
23 const out = feedCard(item()).text;
24 expect(out).not.toMatch(/fcard-/);
25 expect(out).toContain("scribe");
26 expect(out).toContain("because the crew said so");
27 expect(out).toContain('href="/alice/notes/runs/r1"');
28 expect(out).toContain("Sorted &lt;b&gt;the&lt;/b&gt; list");
29 expect(out).toContain('action="/alice/notes/runs/r1/steer"');
30 expect(out).toContain('name="text"');
31 expect(out).toContain('maxlength="2000"');
32 expect(out).toContain("Dismiss");
33 expect(out).toContain('class="small muted break"');
34 });
35
36 test("a tell form posts to the app's own tell", () => {
37 const out = feedCard(item({ kind: "pull", link: "/alice/notes/pulls/1" })).text;
38 expect(out).toContain('action="/alice/notes/tell"');
39 expect(out).not.toMatch(/fcard-/);
40 });
41
42 test("a thought keeps its line breaks and its reply wraps", () => {
43 const thought = { id: "t1", text: "one\ntwo <i>", receivedAt: new Date().toISOString() } as Thought;
44 const placement = { kind: "placed", reply: "Made #7", placed: [{ number: 7, repo: "alice/notes" }] } as unknown as Placement;
45 const out = thoughtCard(thought, "alice", placement).text;
46 expect(out).not.toMatch(/fcard-/);
47 expect(out).toContain("one<br />two &lt;i&gt;");
48 expect(out).toContain('href="/alice/notes/issues/7"');
49 expect(out).toContain('data-placement="placed"');
50 expect(out).toContain("break");
51 });
52 });
test/home-walk.test.ts +1 −1
@@ -93,7 +93,7 @@ test("composer, timeline, a tell, a crew member's run and pull, a share, the app
9393 });
9494 const page = await home(alice);
9595 expect(page).toContain("Sorted the seed list");
96 expect(page).toMatch(/fcard-who">scribe<\/strong\s*>\s*<span[^>]*data-mark="crew"/);
96 expect(page).toMatch(/<strong>scribe<\/strong\s*>\s*<span[^>]*data-mark="crew"/);
9797 expect(page).toContain("opened a pull request");
9898
9999 // A share reaches another person as a post that says who shared it.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs