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

The owner's /notes page fits a 390 px phone: no sideways scroll, a long note wraps #9392

Merged opened this

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

Merged

scout/forge:scout/9352-the-owner-s-notes-page-fits-a-390-px-pho → main 2 commits fork

  • Merged. scout/9352-the-owner-s-notes-page-fits-a-390-px-pho was merged into main as 977f6ad.

Review

Sign in to review this change.
3 files changed +117 −4
docs/design/areas/notes.md +6
@@ -1,3 +1,9 @@
11 # Notes
22
33 `/notes` (`src/web/pages/notes.ts`): the composer is `.note-box` (a textarea and a bar), and each row's one-line state is `.note-line` inside a §5 row's meta.
4
5 ## At 390 px (#9352)
6
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 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 overrule button sit by the first line. No fixed width; the page does not scroll sideways. No new class.
src/web/pages/notes.ts +7 −4
@@ -52,10 +52,13 @@ if (form) {
5252 }
5353 `;
5454
55 const css = /* css */ `
55 export const NOTES_CSS = /* css */ `
5656 .note-box { display: grid; gap: 6px; margin-bottom: 1rem; }
5757 .note-box textarea { width: 100%; min-height: 3.5rem; font-size: 16px; box-sizing: border-box; resize: vertical; }
5858 .note-line a { margin-right: 6px; }
59 [data-notes] { overflow-wrap: anywhere; }
60 [data-notes] .row-title, [data-notes] .row-meta { white-space: normal; overflow: visible; text-overflow: clip; }
61 [data-notes] .row { align-items: flex-start; }
5962 `;
6063
6164 // GET /notes
@@ -90,16 +93,16 @@ export async function notesView(ctx: Ctx): Promise<Response> {
9093 }),
9194 });
9295 });
93 const body = html`<div class="page-head"><h1>Notes</h1></div>
96 const body = html`<div data-notes><div class="page-head"><h1>Notes</h1></div>
9497 <form class="note-box" method="post" action="${HERE}">
9598 <textarea class="input" name="text" rows="2" maxlength="${THOUGHT_MAX}" required enterkeyhint="send" autofocus placeholder="Write an idea. Enter saves it." aria-label="A note"></textarea>
9699 <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>
97100 </form>
98101 ${rows.length === 0
99102 ? 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." })
100 : listCard(rows)}`;
103 : listCard(rows)}</div>`;
101104 return timePhase(ctx.request, "render", () =>
102 page({ ctx, title: "Notes", body, styleCss: css, scriptJs: notesJs, description: "Write an idea; watch it become shipped work." }),
105 page({ ctx, title: "Notes", body, styleCss: NOTES_CSS, scriptJs: notesJs, description: "Write an idea; watch it become shipped work." }),
103106 );
104107 }
105108
test/notes-fit.test.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * The owner's /notes page fits a 390 px phone (#9352): seeded long notes (an
3 * unbroken 300-character word, a long sentence, many rows) ship no fixed width
4 * over 390 px, and the long-text container wraps anywhere.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { keepThought } from "../src/actions/thought.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { findChromium } from "../src/looks/capture.ts";
16 import { NOTES_CSS } from "../src/web/pages/notes.ts";
17 import { utilities as base } from "../src/web/style/base.ts";
18 import { components } from "../src/web/style/components.ts";
19 import { content } from "../src/web/style/content.ts";
20 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
21
22 const WORD = "N".repeat(300);
23 const SENTENCE = "A long note that says a great deal so it cannot fit on one line ".repeat(5).trim();
24
25 let dir: string;
26 let app: App;
27 let servers: Servers;
28 let page: string;
29 let userId: string;
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-notes-fit-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 servers = await serve(app);
35 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
36 if (!u.ok) throw new Error("user");
37 userId = u.value.id;
38 keepThought(app, u.value, WORD, "composer");
39 keepThought(app, u.value, SENTENCE, "composer");
40 for (let i = 0; i < 40; i++) keepThought(app, u.value, `Note number ${i}`, "composer");
41 const res = await fetch(`${servers.url}/notes`, { headers: { cookie: `forge_session=${createSession(app.db, userId).token}` } });
42 expect(res.status).toBe(200);
43 page = await res.text();
44 }, 30_000);
45 afterAll(async () => {
46 servers.stop();
47 app.close();
48 await rm(dir, { recursive: true, force: true });
49 });
50
51 const styles = (h: string): string[] => h.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [];
52
53 describe("the notes page at 390 px", () => {
54 test("the long notes render, in many rows", () => {
55 expect(page).toContain(WORD.slice(0, 150));
56 expect(page).toContain(SENTENCE.slice(0, 100));
57 expect(page.match(/class="row"/g)?.length ?? 0).toBeGreaterThan(30);
58 });
59
60 test("the rows sit inside the wrapping container", () => {
61 expect(page).toContain("data-notes");
62 expect(page.indexOf("data-notes")).toBeLessThan(page.indexOf('class="list"'));
63 });
64
65 test("no inline width and no shipped rule fixes a width over 390 px", () => {
66 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i);
67 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
68 const over: string[] = [];
69 for (const css of [base, components, content, NOTES_CSS, ...styles(page)]) {
70 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
71 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
72 }
73 }
74 expect(over).toEqual([]);
75 });
76
77 test("the container wraps anywhere, and a row's title and meta may wrap", () => {
78 const css = styles(page).join("\n");
79 expect(css).toMatch(/\[data-notes\]\s*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/);
80 expect(css).toMatch(/\[data-notes\] \.row-title[^{]*\{[^}]*white-space:\s*normal/);
81 expect(css).toMatch(/\[data-notes\] \.row-meta[^{]*\{[^}]*white-space:\s*normal/);
82 });
83 });
84
85 const found = findChromium();
86 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
87 if (chromium === undefined) console.log("SKIPPING: no chromium that starts here, so the notes phone look is not measured");
88
89 describe.skipIf(chromium === undefined)("the notes page, measured", () => {
90 const measure = `(() => {
91 const vw = document.documentElement.clientWidth;
92 const overflowing = [...document.querySelectorAll("[data-notes], [data-notes] *")].filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase() + "." + String(el.className));
93 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, overflowing });
94 })()`;
95 const look = async (width: number, height: number) => {
96 const cookies = [{ name: "forge_session", value: createSession(app.db, userId).token }];
97 return JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/notes`, { width, height }, cookies, measure)) as { scrollWidth: number; viewportWidth: number; overflowing: string[] };
98 };
99 test("phone, 390x844: no sideways scroll", async () => {
100 const m = await look(390, 844);
101 expect(m.overflowing).toEqual([]);
102 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
103 }, 30_000);
104 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs