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

Posts that arrive live while you read wait behind one "N new posts" button at the top of the home timeline #11633

Merged opened this

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

Merged

scout/forge:scout/6694-posts-that-arrive-live-while-you-read-wa → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/6694-posts-that-arrive-live-while-you-read-wa was merged into main as d96b14f.

Review

Sign in to review this change.
5 files changed +146 −4
docs/design/areas/feed.md +1
@@ -5,3 +5,4 @@
55 - Day divider — no new class: a §5 `.muted.small` line with `data-day-divider="<label>"`, drawn by `drawRegion` in `src/web/studio/feed.ts` before the first post of each UTC day (labels from `day_divider.ts`). It marks a change of day between neighbours in the order the feed already has; it never reorders. It is `role="separator"`, and hiding a card (dismiss, mute) moves it to the next card showing that day or hides it (`keepDay`). The "Older posts" link and `data-live-src` carry `day=` so a later page does not repeat the divider.
66 - Folded burst lines — no new class: under a folded post (`planned N issues in owner/name`) `feedCard` draws the §5 `ul.list` of `row()`s, one linked row per title and a last unlinked row `and N more`.
77 - `.like` — the Like form under a post, drawn by `likeBar` in `src/web/studio/feed.ts`: an inline form around one §5 ghost `.btn` (`aria-pressed` is the only state signal; the label is always "Like"). Its count `[data-likes]` is `inline-block` at `min-width: 3ch`, right-aligned with tabular numerals, so going from 9 to 10 or pressing moves nothing. Styled in `src/web/studio/feed_style.ts`.
8 - `.new-posts` — the "N new posts" button's slot above the home timeline, drawn by `feedNote` in `src/web/studio/feed.ts`: a `role="status"` `aria-live="polite"` paragraph, sticky under the topbar, zero-high while its button is `hidden`, so nothing shifts when it appears. The button is a §5 primary small `.btn` at 44 px; its text is its accessible name and carries the count. Pressing it swaps the held feed, scrolls to the top instantly and moves focus to `#live-feed`. Styled in `src/web/studio/feed_style.ts`.
scripts/unreached-allow.json +1 −2
@@ -2,6 +2,5 @@
22 "src/perf/page_budget.ts",
33 "src/perf/page_budget_read.ts",
44 "src/spawn/pod.ts",
5 "src/tui/ui/qr.ts",
6 "src/web/studio/held_posts.ts"
5 "src/tui/ui/qr.ts"
76 ]
src/web/studio/feed.ts +33 −2
@@ -26,12 +26,13 @@ import { getRepo, getRepoById } from "../../core/repos.ts";
2626 import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts";
2727 import { mayFetchLook, pictured } from "../pages/looks.ts";
2828 import { dividers } from "./day_divider.ts";
29 import { html, type Html } from "../html.ts";
29 import { html, raw, type Html } from "../html.ts";
3030 import { DISMISS_KEY } from "../script_keys.ts";
3131 import { fragment } from "../layout.ts";
3232 import { draftJs } from "../draft.ts";
3333 import { outboxJs } from "../outbox.ts";
3434 import { avatar, btn, card, disclosure, empty, hintAttr, nameMark, row, stateBadge, time } from "../ui.ts";
35 import { heldPostsJs } from "./held_posts.ts";
3536 import { stepLink } from "./step_link.ts";
3637 import { writableAppsOf } from "./asks.ts";
3738
@@ -270,7 +271,7 @@ export function composer(ctx: Ctx, user: User): Html {
270271 * are waiting in the outbox. The script fills it; `hidden` until then.
271272 */
272273 export function feedNote(): Html {
273 return html`<p
274 return html`<p id="new-posts" class="new-posts" role="status" aria-live="polite">${raw(String(btn("New posts", { type: "button", variant: "primary", size: "sm" })).replace("<button", "<button hidden"))}</p><p
outdated

Principle: usable on a phone/keyboard. This slot is above the timeline, but the hold only fires when the reader is scrolled past the top, so the button is off-screen when it appears. Make it sticky (top of viewport) so it is seen and reachable. Also add role="status" aria-live="polite" so the count is announced; hiding/showing a bare <p> is silent to a screen reader. Also give the button an accessible name that does not change from 'New posts' to count only visually.

274275 id="feed-note"
275276 class="feed-note small muted"
276277 role="status"
@@ -425,7 +426,9 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
425426 id="live-feed"
426427 class="feed"
427428 aria-label="Feed"
429 tabindex="-1"
428430 data-live="${FEED_LIVE}"
431 data-live-hold
429432 data-live-src="/live/feed${liveQuery === "" ? "" : `?${liveQuery}`}"
430433 >
431434 ${chipsBar(user, ctx, items)}
@@ -484,6 +487,7 @@ export const feedJs = /* js */ `
484487 ${outboxJs}
485488 ${draftJs}
486489 ${dayKeepJs}
490 ${heldPostsJs}
487491 (function () {
488492 var feed = document.getElementById('live-feed');
489493 if (!feed || typeof fetch === 'undefined') return;
@@ -650,6 +654,33 @@ ${dayKeepJs}
650654 box.focus();
651655 outbox.flush().then(function (r) { if (r.refused && draft) draft.refused(text); });
652656 });
657 // Posts that arrived while the reader was inside the feed wait behind one
658 // button; each later hold replaces the last, so the count is against the screen.
659 var slot = document.getElementById('new-posts');
660 var slotBtn = slot && slot.querySelector('button');
661 var held = null;
662 function ids(root) {
663 return Array.prototype.map.call(root.querySelectorAll('[data-item]'), function (c) { return c.getAttribute('data-item'); });
664 }
665 feed.addEventListener('forge:held', function (e) {
666 if (!slot || !slotBtn || !e.detail || typeof e.detail.html !== 'string') return;
667 var holder = document.createElement('template');
668 holder.innerHTML = e.detail.html;
669 var n = newPostIds(ids(feed), ids(holder.content)).length;
670 if (n < 1) { held = null; slotBtn.hidden = true; return; }
671 held = e.detail.html;
672 slotBtn.textContent = newPostsLabel(n);
673 slotBtn.hidden = false;
674 });
outdated

After the swap, focus stays on the now-hidden button and is lost (keyboard users land on <body>). Move focus to the feed region or its first new card after swapping, and scrollTo respects no reduced-motion concerns only because it is instant — keep it instant.

675 if (slotBtn) slotBtn.addEventListener('click', function () {
676 if (held === null) return;
677 feed.innerHTML = held;
678 held = null;
679 slotBtn.hidden = true;
680 feed.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true }));
681 window.scrollTo(0, 0);
682 if (feed.focus) feed.focus({ preventScroll: true });
683 });
653684 document.addEventListener('keydown', function (e) {
654685 if (e.key !== '${DISMISS_KEY}' || e.metaKey || e.ctrlKey || e.altKey) return;
655686 var a = document.activeElement;
src/web/studio/feed_style.ts +4
@@ -4,6 +4,10 @@ export const feedCss = /* css */ `
44 /* A timeline of the design system's cards; the card's own margin spaces them. */
55 .feed { display: grid; gap: 0; }
66 .feed > .card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
7 /* The held-posts button sticks under the topbar so it is in sight while the reader is down the feed; the paragraph is zero-high until the button shows. */
8 .new-posts { position: sticky; top: calc(var(--topbar-h) + 8px); z-index: 9; margin: 0; display: flex; justify-content: center; pointer-events: none; }
9 .new-posts .btn { pointer-events: auto; min-height: 44px; box-shadow: var(--shadow-2); }
10 .new-posts .btn[hidden] { display: none; }
711 .feed > .card[hidden] { display: none; }
812 /* The Like form sits in the card's flow; its count has a fixed width so a press moves nothing. */
913 .feed .like { display: inline-block; margin: 0; }
test/feed-new-posts.test.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * Posts that arrive while the reader is inside the feed wait behind one
3 * button (#6694): the region holds, the slot is hidden until then, and the
4 * listener counts against what is on screen.
5 */
6 import { 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 } from "../src/app.ts";
11 import { createSession } from "../src/core/sessions.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve } from "../src/http/server.ts";
14 import { freshHome } from "./helpers/home-fresh.ts";
15 import { feedJs, feedNote } from "../src/web/studio/feed.ts";
16 import { heldPostsJs } from "../src/web/studio/held_posts.ts";
outdated

This reads feed.ts as text and regex-matches it; it would pass with the attribute in a comment. Render the region (drawRegion/home page) and assert the served HTML carries data-live-hold on #live-feed.

17
18 // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 type Any = any;
20
21 describe("new posts, server side", () => {
22 test("the served home carries a held, focusable #live-feed and the polite slot", async () => {
23 const dataDir = await mkdtemp(join(tmpdir(), "forge-new-posts-"));
24 const app = createApp({ dataDir, port: 0 });
25 const servers = await serve(app);
26 try {
27 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "root", allowReserved: true, email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
28 if (!r.ok) throw new Error(r.error);
29 const cookie = `forge_session=${createSession(app.db, r.value.id).token}`;
30 const page = await freshHome(servers.url, cookie, "/feed");
31 expect(page).toMatch(/<section[^>]*id="live-feed"[^>]*data-live-hold/s);
32 expect(page).toMatch(/<section[^>]*id="live-feed"[^>]*tabindex="-1"/s);
33 expect(page).toMatch(/<p id="new-posts"[^>]*role="status"[^>]*aria-live="polite">\s*<button hidden/);
34 } finally {
35 servers.stop();
36 app.close();
37 await rm(dataDir, { recursive: true, force: true });
38 }
39 });
40 test("the slot is a polite status with its button hidden", () => {
41 const slot = String(feedNote());
42 expect(slot).toMatch(/<p id="new-posts"[^>]*role="status"[^>]*aria-live="polite">\s*<button hidden/);
43 });
44 test("the page's script carries heldPostsJs", () => {
45 expect(feedJs).toContain(heldPostsJs);
46 });
47 });
48
49 function drive(shown: string[]) {
50 const listeners: Record<string, (e: Any) => void> = {};
51 const clicks: (() => void)[] = [];
52 const events: string[] = [];
53 const scrolls: unknown[][] = [];
54 const cards = (ids: string[]) => ids.map((id) => ({ getAttribute: () => id }));
55 const focused: unknown[] = [];
56 const feed: Any = {
57 focus: (o: unknown) => focused.push(o),
58 innerHTML: shown.join(","),
59 addEventListener: (t: string, f: (e: Any) => void) => { listeners[t] = f; },
60 querySelectorAll: () => cards(shown),
61 dispatchEvent: (e: Any) => events.push(e.type),
62 };
63 const button: Any = { hidden: true, textContent: "New posts", addEventListener: (_t: string, f: () => void) => clicks.push(f) };
64 const slot: Any = { querySelector: () => button };
65 const els: Record<string, Any> = { "live-feed": feed, "new-posts": slot };
66 const doc: Any = {
67 getElementById: (id: string) => els[id] ?? null,
68 createElement: () => {
69 const t: Any = { content: { querySelectorAll: () => cards(String(t.innerHTML) === "" ? [] : String(t.innerHTML).split(",")) } };
70 return t;
71 },
72 addEventListener() {},
73 };
74 const win: Any = { addEventListener() {}, scrollTo: (...a: unknown[]) => scrolls.push(a) };
75 class CE { constructor(public type: string, init: Any = {}) { Object.assign(this, init); } }
76 new Function("document", "window", "fetch", "CustomEvent", "location", "navigator", "setTimeout", feedJs)(
77 doc, win, async () => ({ ok: true }), CE, { pathname: "/" }, {}, () => 1,
78 );
79 return { focused, feed, slot, button, listeners, clicks, events, scrolls };
80 }
81
82 describe("new posts, in the browser", () => {
83 test("three new posts make one button; pressing swaps once and scrolls up", () => {
84 const d = drive(["a", "b"]);
85 expect(d.button.hidden).toBe(true);
86 d.listeners["forge:held"]!({ detail: { html: "x,y,z,a,b" } });
87 expect(d.button.hidden).toBe(false);
88 expect(d.button.textContent).toBe("3 new posts");
89 d.clicks[0]!();
90 expect(d.feed.innerHTML).toBe("x,y,z,a,b");
91 expect(d.events).toEqual(["forge:swapped"]);
92 expect(d.scrolls).toEqual([[0, 0]]);
93 expect(d.focused).toEqual([{ preventScroll: true }]);
94 expect(d.button.hidden).toBe(true);
95 d.clicks[0]!();
96 expect(d.events).toEqual(["forge:swapped"]);
97 });
98 test("a later hold replaces the held markup", () => {
99 const d = drive(["a"]);
100 d.listeners["forge:held"]!({ detail: { html: "x,a" } });
101 expect(d.button.textContent).toBe("1 new post");
102 d.listeners["forge:held"]!({ detail: { html: "y,x,a" } });
103 expect(d.button.textContent).toBe("2 new posts");
104 d.clicks[0]!();
105 expect(d.feed.innerHTML).toBe("y,x,a");
106 });
107 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs