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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4267-the-home-feed-s-cards-draw-5-s-card-row → main 1 commit fork
3 files
changed +65
−9
src/web/studio/feed.ts +12 −8
| @@ -67,18 +67,18 @@ export function feedCard( | ||
| 67 | 67 | const steer = item.kind === "run" || item.kind === "cost"; |
| 68 | 68 | const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null; |
| 69 | 69 | 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 | |
| 71 | 71 | >${nameMark(item.actor === null ? undefined : marks.get(item.actor))} |
| 72 | 72 | <span class="muted small">${item.app} · ${timeAgo(item.at)}</span> |
| 73 | 73 | <span class="spacer"></span> |
| 74 | 74 | <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}`; | |
| 77 | 77 | const foot = disclosure( |
| 78 | 78 | `${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"> | |
| 80 | 80 | <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> | |
| 82 | 82 | </form> |
| 83 | 83 | <span class="bar"> |
| 84 | 84 | <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; | ||
| 102 | 102 | /** A thought stays at the top for a day, then leaves the feed (it is still kept). */ |
| 103 | 103 | const THOUGHT_AGE_MS = 24 * 60 * 60 * 1000; |
| 104 | 104 | |
| 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 | ||
| 105 | 109 | /** |
| 106 | 110 | * A thought as the owner's post: their own words, verbatim (escaped here, |
| 107 | 111 | * once; line breaks kept by CSS), marked "received". It has no controls. |
| 108 | 112 | */ |
| 109 | 113 | export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html { |
| 110 | 114 | const head = html`${avatar(who)} |
| 111 | <strong class="fcard-who">${who}</strong> | |
| 115 | <strong>${who}</strong> | |
| 112 | 116 | <span class="muted small">${timeAgo(thought.receivedAt)}</span> |
| 113 | 117 | <span class="spacer"></span> |
| 114 | 118 | <span class="badge">Received</span>`; |
| 115 | 119 | return card( |
| 116 | html`<p class="fcard-reason fcard-text">${thought.text}</p> | |
| 120 | html`<p class="break">${lines(thought.text)}</p> | |
| 117 | 121 | ${placement ? replyUnder(placement) : ""}`, |
| 118 | 122 | { head, attrs: html`data-kind="thought" data-thought="${thought.id}"` }, |
| 119 | 123 | ); |
| @@ -126,7 +130,7 @@ export function replyUnder(placement: Placement): Html { | ||
| 126 | 130 | const repo = /^#(\d+)$/.test(part) ? where.get(Number(part.slice(1))) : undefined; |
| 127 | 131 | return repo === undefined ? part : html`<a href="/${repo}/issues/${part.slice(1)}">${part}</a>`; |
| 128 | 132 | }); |
| 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>`; | |
| 130 | 134 | } |
| 131 | 135 | |
| 132 | 136 | /** 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 <b>the</b> 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 <i>"); | |
| 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 | ||
| 93 | 93 | }); |
| 94 | 94 | const page = await home(alice); |
| 95 | 95 | 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"/); | |
| 97 | 97 | expect(page).toContain("opened a pull request"); |
| 98 | 98 | |
| 99 | 99 | // A share reaches another person as a post that says who shared it. |
new above
offline