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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6694-posts-that-arrive-live-while-you-read-wa → main 4 commits fork 3 threads · 3 unresolved
docs/design/areas/feed.md +1
| @@ -5,3 +5,4 @@ | ||
| 5 | 5 | - 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. |
| 6 | 6 | - 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`. |
| 7 | 7 | - `.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 @@ | ||
| 2 | 2 | "src/perf/page_budget.ts", |
| 3 | 3 | "src/perf/page_budget_read.ts", |
| 4 | 4 | "src/spawn/pod.ts", |
| 5 | "src/tui/ui/qr.ts", | |
| 6 | "src/web/studio/held_posts.ts" | |
| 5 | "src/tui/ui/qr.ts" | |
| 7 | 6 | ] |
src/web/studio/feed.ts +33 −2
| @@ -26,12 +26,13 @@ import { getRepo, getRepoById } from "../../core/repos.ts"; | ||
| 26 | 26 | import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts"; |
| 27 | 27 | import { mayFetchLook, pictured } from "../pages/looks.ts"; |
| 28 | 28 | import { dividers } from "./day_divider.ts"; |
| 29 | import { html, type Html } from "../html.ts"; | |
| 29 | import { html, raw, type Html } from "../html.ts"; | |
| 30 | 30 | import { DISMISS_KEY } from "../script_keys.ts"; |
| 31 | 31 | import { fragment } from "../layout.ts"; |
| 32 | 32 | import { draftJs } from "../draft.ts"; |
| 33 | 33 | import { outboxJs } from "../outbox.ts"; |
| 34 | 34 | import { avatar, btn, card, disclosure, empty, hintAttr, nameMark, row, stateBadge, time } from "../ui.ts"; |
| 35 | import { heldPostsJs } from "./held_posts.ts"; | |
| 35 | 36 | import { stepLink } from "./step_link.ts"; |
| 36 | 37 | import { writableAppsOf } from "./asks.ts"; |
| 37 | 38 | |
| @@ -270,7 +271,7 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 270 | 271 | * are waiting in the outbox. The script fills it; `hidden` until then. |
| 271 | 272 | */ |
| 272 | 273 | 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 | |
| 274 | 275 | id="feed-note" |
| 275 | 276 | class="feed-note small muted" |
| 276 | 277 | role="status" |
| @@ -425,7 +426,9 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): | ||
| 425 | 426 | id="live-feed" |
| 426 | 427 | class="feed" |
| 427 | 428 | aria-label="Feed" |
| 429 | tabindex="-1" | |
| 428 | 430 | data-live="${FEED_LIVE}" |
| 431 | data-live-hold | |
| 429 | 432 | data-live-src="/live/feed${liveQuery === "" ? "" : `?${liveQuery}`}" |
| 430 | 433 | > |
| 431 | 434 | ${chipsBar(user, ctx, items)} |
| @@ -484,6 +487,7 @@ export const feedJs = /* js */ ` | ||
| 484 | 487 | ${outboxJs} |
| 485 | 488 | ${draftJs} |
| 486 | 489 | ${dayKeepJs} |
| 490 | ${heldPostsJs} | |
| 487 | 491 | (function () { |
| 488 | 492 | var feed = document.getElementById('live-feed'); |
| 489 | 493 | if (!feed || typeof fetch === 'undefined') return; |
| @@ -650,6 +654,33 @@ ${dayKeepJs} | ||
| 650 | 654 | box.focus(); |
| 651 | 655 | outbox.flush().then(function (r) { if (r.refused && draft) draft.refused(text); }); |
| 652 | 656 | }); |
| 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 | }); | |
| 653 | 684 | document.addEventListener('keydown', function (e) { |
| 654 | 685 | if (e.key !== '${DISMISS_KEY}' || e.metaKey || e.ctrlKey || e.altKey) return; |
| 655 | 686 | var a = document.activeElement; |
src/web/studio/feed_style.ts +4
| @@ -4,6 +4,10 @@ export const feedCss = /* css */ ` | ||
| 4 | 4 | /* A timeline of the design system's cards; the card's own margin spaces them. */ |
| 5 | 5 | .feed { display: grid; gap: 0; } |
| 6 | 6 | .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; } | |
| 7 | 11 | .feed > .card[hidden] { display: none; } |
| 8 | 12 | /* The Like form sits in the card's flow; its count has a fixed width so a press moves nothing. */ |
| 9 | 13 | .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 | }); |
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.