Public: anyone can read this; the crew can change it.
The owner's /notes page speaks the home page's language #9743
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9731-the-owner-s-notes-page-speaks-the-home-p → main 3 commits fork 2 threads · 2 unresolved
3 files
changed +29
−17
docs/design/areas/notes.md +10
| @@ -7,3 +7,13 @@ | ||
| 7 | 7 | A note is whatever a person typed, so inside `[data-notes]` (an attribute, not a class) a row's title and meta wrap and |
| 8 | 8 | long words break anywhere, in place of the list's one-line ellipsis; the row aligns to its top so the badge and the |
| 9 | 9 | overrule button sit by the first line. No fixed width; the page does not scroll sideways. No new class. |
| 10 | ||
| 11 | ## Rows, not boxes (#9731) | |
| 12 | ||
| 13 | The notes are a `listCard(rows, { extra: "bare" })`: rows on the page's one background with no frame (the shared | |
| 14 | `.card.bare`, docs/design/areas/lists.md). A row's state is the words in its one `.note-line` ("Planned · 2 issues", | |
| 15 | "Shipped in version …"), so the trailing badge is gone. The empty state is one muted line, not a card. The action, | |
| 16 | `keepThought`, and the overrule button do not change. No new class. | |
| 17 | ||
| 18 | The amber tone that building and in-review notes once carried is gone on purpose: the words are the state, and the page | |
| 19 | has no other colour for them. The 390 px rule above still holds, now without the badge. |
src/web/pages/notes.ts +4 −17
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { overruleNote } from "../../actions/note_overrule.ts"; |
| 12 | 12 | import { keepThought, THOUGHT_MAX } from "../../actions/thought.ts"; |
| 13 | import { noteLine, type NotePhase } from "../../core/note_line.ts"; | |
| 13 | import { noteLine } from "../../core/note_line.ts"; | |
| 14 | 14 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 15 | 15 | import { publicForgeRepo } from "../../core/forge_repo.ts"; |
| 16 | 16 | import { thoughtStream } from "../../core/thought_stream.ts"; |
| @@ -20,24 +20,12 @@ import { signInFirst } from "../chrome.ts"; | ||
| 20 | 20 | import { readForm } from "../form.ts"; |
| 21 | 21 | import { html } from "../html.ts"; |
| 22 | 22 | import { flashTo, page } from "../layout.ts"; |
| 23 | import { badge, btn, empty, listCard, row, time, type Tone } from "../ui.ts"; | |
| 23 | import { btn, listCard, row, time } from "../ui.ts"; | |
| 24 | 24 | import { cut } from "../../actions/ask_state_words.ts"; |
| 25 | 25 | |
| 26 | 26 | const HERE = "/notes"; |
| 27 | 27 | const SHOWN = 100; |
| 28 | 28 | |
| 29 | const TONE: Record<NotePhase, Tone> = { | |
| 30 | waiting: "default", | |
| 31 | declined: "default", | |
| 32 | duplicate: "default", | |
| 33 | snoozed: "default", | |
| 34 | planned: "default", | |
| 35 | building: "attn", | |
| 36 | review: "attn", | |
| 37 | shipped: "success", | |
| 38 | superseded: "default", | |
| 39 | }; | |
| 40 | ||
| 41 | 29 | /** Static code, never built from user text. */ |
| 42 | 30 | export const notesJs = ` |
| 43 | 31 | const form = document.querySelector("form.note-box"); |
| @@ -82,7 +70,6 @@ export async function notesView(ctx: Ctx): Promise<Response> { | ||
| 82 | 70 | meta: html`<span class="note-line">${time(n.receivedAt)} · ${line.text}${line.links.length === 0 ? null : " · "}${line.links.map( |
| 83 | 71 | (l) => html`<a href="${l.href}">${l.label}</a>`, |
| 84 | 72 | )}</span>`, |
| 85 | trail: badge(line.phase === "review" ? "In review" : line.phase[0]!.toUpperCase() + line.phase.slice(1), { tone: TONE[line.phase] }), | |
| 86 | 73 | ...(line.overrule === null |
| 87 | 74 | ? {} |
| 88 | 75 | : { |
| @@ -99,8 +86,8 @@ export async function notesView(ctx: Ctx): Promise<Response> { | ||
| 99 | 86 | <div class="bar"><span class="muted small">Enter saves · Shift+Enter for a new line</span><span class="spacer"></span>${btn("Save", { variant: "primary" })}</div> |
| 100 | 87 | </form> |
| 101 | 88 | ${rows.length === 0 |
| 102 | ? empty({ icon: "comment", title: "No notes yet", note: "Write an idea above. The crew plans it, builds it, and this page says where it is." }) | |
| 103 | : listCard(rows)}</div>`; | |
| 89 | ? html`<p class="muted small">No notes yet. Write one above.</p>` | |
outdated
Principle 1 and 8: the empty line is two sentences that explain the page twice. Say one thing, e.g. "No notes yet. Write one above." Also this branch (empty vs bare list) has no test; add one. |
||
| 90 | : listCard(rows, { extra: "bare" })}</div>`; | |
| 104 | 91 | return timePhase(ctx.request, "render", () => |
| 105 | 92 | page({ ctx, title: "Notes", body, styleCss: NOTES_CSS, scriptJs: notesJs, description: "Write an idea; watch it become shipped work." }), |
| 106 | 93 | ); |
test/notes.test.ts +15
| @@ -92,6 +92,7 @@ describe("notes", () => { | ||
| 92 | 92 | let servers: Servers; |
| 93 | 93 | let alice: User; |
| 94 | 94 | let bob: User; |
| 95 | let carol: User; | |
| 95 | 96 | let repo: Repo; |
| 96 | 97 | const fresh = () => getRepoById(app.db, repo.id)!; |
| 97 | 98 | const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`; |
| @@ -106,6 +107,7 @@ describe("notes", () => { | ||
| 106 | 107 | }; |
| 107 | 108 | alice = await mk("alice", true); |
| 108 | 109 | bob = await mk("bob"); |
| 110 | carol = await mk("carol"); | |
| 109 | 111 | const r = await createRepo(app, alice, { name: "forge", visibility: "private" }); |
| 110 | 112 | if (!r.ok) throw new Error(String(r.error)); |
| 111 | 113 | repo = r.value; |
| @@ -143,6 +145,19 @@ describe("notes", () => { | ||
| 143 | 145 | expect(await (await get(bob)).text()).not.toContain("first idea"); |
| 144 | 146 | }); |
| 145 | 147 | |
| 148 | test("notes are bare rows with the state in the line and no badge; none is one muted line (#9731)", async () => { | |
| 149 | await post(bob, "an idea for rows"); | |
| 150 | const flat = (r: string) => r.slice(r.indexOf("<main"), r.indexOf("</main>")).replace(/\s+/g, " "); | |
| 151 | const list = flat(await (await get(bob)).text()); | |
| 152 | expect(list).toContain('<div class="card bare"'); | |
| 153 | expect(list).not.toContain("badge"); | |
| 154 | expect(list).toMatch(/<span class="note-line">.*Waiting for the planner/); | |
| 155 | const empty = flat(await (await get(carol)).text()); | |
| 156 | expect(empty).toContain('<p class="muted small">No notes yet. Write one above.</p>'); | |
| 157 | expect(empty).not.toContain("bare"); | |
| 158 | expect(empty).not.toContain('class="empty'); | |
| 159 | }); | |
| 160 | ||
| 146 | 161 | test("refuses empty, oversized and cross-origin writes", async () => { |
| 147 | 162 | const before = thoughtStream(app.db, alice).length; |
| 148 | 163 | expect((await post(alice, " ")).status).toBe(303); |
Removing the badge changes what a person sees (state is now only inside the meta line). Add a test that fails without it: the row has no badge and
.note-lineholds the state words.