Public: anyone can read this; the crew can change it.
A new forge's link page says in one line what the recipient will see first, before they open it #11446
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11433-a-new-forge-s-link-page-says-in-one-line → main 4 commits fork 4 threads · 4 unresolved
docs/features/first-screen-line.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # The line about a sent forge's first screen (#11433) | |
| 2 | ||
| 3 | On `/forges` and in the terminal's "Forges you sent", a forge not yet claimed carries one line: the apps chosen (or "the apps you pick"), the crew working, and the three next steps, in the first-login card's own words. | |
| 4 | ||
| 5 | - One source: `STEP_TITLES` and `firstScreenLine` in `src/core/next-steps.ts`; the card and the line both read them. | |
| 6 | - Shown only while unclaimed (`firstScreenOf` in `src/spawn/clients.ts`); once claimed the recipient has seen it. | |
| 7 | - Page: a `data-first-screen` child row in `forgeRow` (`src/web/pages/forges.ts`), in the first paint. Terminal: `forgeLines` (`src/tui/screens/forges.ts`). | |
| 8 | - Tests: `test/first-screen-line.test.ts`. |
src/core/next-steps.ts +18 −3
| @@ -24,6 +24,21 @@ export interface NextStep { | ||
| 24 | 24 | readonly done: boolean; |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | const STEP_KEYS: readonly StepKey[] = ["everyday", "apps", "tell"]; | |
| 28 | ||
| 29 | /** The three steps' words, the one source for the first-login card and the client list's line about it. */ | |
| 30 | export const STEP_TITLES: Record<StepKey, string> = { | |
| 31 | everyday: "Make your everyday account", | |
| 32 | apps: "Open your apps", | |
| 33 | tell: "Tell the crew something", | |
| 34 | }; | |
| 35 | ||
| 36 | /** What the recipient of a sent forge sees first, in one line: the apps chosen, that the crew is working, the three steps. */ | |
| 37 | export function firstScreenLine(apps: readonly string[]): string { | |
| 38 | const chosen = apps.length === 0 ? "the apps you pick" : apps.join(", "); | |
| 39 | return `They will see ${chosen}, their crew working, and three next steps: ${STEP_KEYS.map((k) => STEP_TITLES[k]).join("; ")}.`; | |
| 40 | } | |
outdated
STEP_KEYS is declared after firstScreenLine uses it. Move it above STEP_TITLES, or derive the order from Object.keys(STEP_TITLES). |
||
| 41 | ||
| 27 | 42 | const OPENED_APP = "first_step_opened_app"; |
| 28 | 43 | |
| 29 | 44 | /** The admin whose forge this is, as seen by `user`: themselves, or the admin of their everyday account. */ |
| @@ -61,15 +76,15 @@ export async function nextStepsFor( | ||
| 61 | 76 | return [ |
| 62 | 77 | { |
| 63 | 78 | key: "everyday", |
| 64 | title: "Make your everyday account", | |
| 79 | title: STEP_TITLES.everyday, | |
| 65 | 80 | done: daily !== undefined || keptAdmin(app.db, owner), |
| 66 | 81 | }, |
| 67 | 82 | { |
| 68 | 83 | key: "apps", |
| 69 | title: "Open your apps", | |
| 84 | title: STEP_TITLES.apps, | |
| 70 | 85 | done: getUserSetting(app.db, owner.id, OPENED_APP) !== undefined, |
| 71 | 86 | }, |
| 72 | { key: "tell", title: "Tell the crew something", done: (told?.n ?? 0) > 0 }, | |
| 87 | { key: "tell", title: STEP_TITLES.tell, done: (told?.n ?? 0) > 0 }, | |
| 73 | 88 | ]; |
| 74 | 89 | } |
| 75 | 90 |
src/spawn/clients.ts +6
| @@ -21,6 +21,7 @@ import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./c | ||
| 21 | 21 | import { lastHeard, type Beat } from "./heartbeat.ts"; |
| 22 | 22 | import { reachOf, reachWords, type ChildReach } from "./reach.ts"; |
| 23 | 23 | import type { Size } from "./sizes.ts"; |
| 24 | import { firstScreenLine } from "../core/next-steps.ts"; | |
| 24 | 25 | import { forgeUse, type ForgeUse } from "./usage.ts"; |
| 25 | 26 | |
| 26 | 27 | /** What a spawned forge says about itself to anyone: nothing more. */ |
| @@ -357,6 +358,11 @@ export function claimState(r: ClientRow): keyof typeof CLAIM_WORDS { | ||
| 357 | 358 | return r.claimed === null ? "unknown" : r.claimed ? "yes" : "no"; |
| 358 | 359 | } |
| 359 | 360 | |
| 361 | /** What the recipient will see first, while they have not claimed it; null once they have (the page and the terminal share it). */ | |
| 362 | export function firstScreenOf(r: ClientRow): string | null { | |
| 363 | return claimState(r) === "no" ? firstScreenLine(r.apps) : null; | |
| 364 | } | |
| 365 | ||
| 360 | 366 | /** "3 apps running", or which one is not and why; null when none are run here. */ |
| 361 | 367 | export function runningWords(r: ClientRow): string | null { |
| 362 | 368 | const unreachable = reachWords(r.reach); |
src/tui/screens/forges.ts +8 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Ctx, Screen } from "../app/app.ts"; |
| 8 | 8 | import { binding } from "../app/keys.ts"; |
| 9 | import { ATTENTION_WORDS, CLAIM_WORDS, claimState, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 9 | import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | 10 | import { useLine } from "../../spawn/usage.ts"; |
| 11 | 11 | import { timeAgo } from "../../web/ui.ts"; |
| 12 | 12 | import { card } from "../ui/widgets.ts"; |
| @@ -29,7 +29,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 29 | 29 | [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`] |
| 30 | 30 | .filter((m): m is string => m !== null) |
| 31 | 31 | .join(" · "), |
| 32 | ], ...addressLines(r)]); | |
| 32 | ], ...firstScreenLines(r), ...addressLines(r)]); | |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | /** "link open", "link ended (undo with u)": what the sender can do about the link, as /forges shows it. */ |
| @@ -38,6 +38,12 @@ function linkWords(link: LinkInfo | null): string | null { | ||
| 38 | 38 | return link.state === "open" ? "link open" : undoable(link) ? "link ended, undo with u" : "link ended"; |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | /** Under a forge not yet claimed: what its person will see first, as /forges says it. */ | |
| 42 | function firstScreenLines(r: ClientRow): [string, string][] { | |
| 43 | const line = firstScreenOf(r); | |
| 44 | return line === null ? [] : [["", ` ${line}`]]; | |
| 45 | } | |
| 46 | ||
| 41 | 47 | /** Under a forge that is running, not reachable: each public address and what it answered (#7959), as /forges lists them. */ |
| 42 | 48 | function addressLines(r: ClientRow): [string, string][] { |
| 43 | 49 | if (r.reach === null || r.reach.ok) return []; |
src/web/pages/forges.ts +12 −13
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | import { ATTENTION_WORDS, CLAIM_WORDS, claimState, clientRows, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, clientRows, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 12 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| @@ -72,19 +72,18 @@ export function forgeRow(r: ClientRow, now: number): Html { | ||
| 72 | 72 | // Enter opens the row's first link, the forge's name. |
| 73 | 73 | nav: true, |
| 74 | 74 | data: { forge: r.name, claimed: state }, |
| 75 | ...(r.reach === null | |
| 76 | ? {} | |
| 77 | : { | |
| 78 | // One row per public address; the word in the trail, not a colour, says which failed. | |
| 79 | children: r.reach.proofs.map((p) => | |
| 80 | row({ | |
| 81 | title: html`<a href="${p.url}">${p.url.replace(/^https?:\/\//, "")}</a>`, | |
| 82 | meta: html`${p.says}`, | |
| 83 | trail: badge(p.ok ? "reachable" : "not reachable", p.ok ? { tone: "success" as const } : { tone: "attn" as const }), | |
| 84 | data: { proof: p.ok ? "ok" : "failed" }, | |
| 85 | }), | |
| 86 | ), | |
| 75 | children: [ | |
| 76 | ...(firstScreenOf(r) === null ? [] : [row({ title: html`${firstScreenOf(r)}`, data: { "first-screen": "" } })]), | |
| 77 | ...(r.reach?.proofs ?? []).map((p) => | |
outdated
Principle 1 (nothing explains itself twice): this sentence repeats on every row, claimed or not. Render it only for unclaimed forges, where the recipient has not opened it yet. Also add the same line to the terminal screen so both doors say it. |
||
| 78 | // One row per public address; the word in the trail, not a colour, says which failed. | |
| 79 | row({ | |
| 80 | title: html`<a href="${p.url}">${p.url.replace(/^https?:\/\//, "")}</a>`, | |
| 81 | meta: html`${p.says}`, | |
| 82 | trail: badge(p.ok ? "reachable" : "not reachable", p.ok ? { tone: "success" as const } : { tone: "attn" as const }), | |
| 83 | data: { proof: p.ok ? "ok" : "failed" }, | |
| 87 | 84 | }), |
| 85 | ), | |
| 86 | ], | |
| 88 | 87 | trail: html`${r.attention === null |
| 89 | 88 | ? null |
| 90 | 89 | : html`<span data-attention="${r.attention}">${badge(ATTENTION_WORDS[r.attention], { tone: "attn" })}</span>`}${badge(CLAIM_WORDS[state], { |
test/first-screen-line.test.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** The client list's line about a sent forge is built from the first-login card's own words (#11433). */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { getUserByName } from "../src/core/users.ts"; | |
| 4 | import { claimedForge } from "./helpers/claimed-forge.ts"; | |
| 5 | import { STEP_TITLES, firstScreenLine, nextStepsFor } from "../src/core/next-steps.ts"; | |
| 6 | import { forgeRow } from "../src/web/pages/forges.ts"; | |
| 7 | import { forgeLines } from "../src/tui/screens/forges.ts"; | |
| 8 | import type { ClientRow } from "../src/spawn/clients.ts"; | |
| 9 | import { quietLogs } from "./helpers/globals.ts"; | |
| 10 | ||
| 11 | quietLogs(); | |
| 12 | ||
| 13 | const row = (over: Partial<ClientRow>) => | |
| 14 | ({ | |
| 15 | name: "maria", url: "https://maria.test", for: "Maria", apps: ["bakery"], sentAt: null, made: true, | |
| 16 | claimed: false, lastActivityAt: null, running: null, reach: null, link: null, attention: null, | |
| 17 | stage: null, spendTodayUsd: null, ceilingUsd: null, size: "small", use: { kind: "shared" }, | |
| 18 | disk: { own: false, path: "/d" }, lastHeartbeatAt: null, | |
| 19 | nextStep: { key: "claim", words: "claim", lastTellAt: null }, | |
| 20 | ...over, | |
| 21 | }) as unknown as ClientRow; | |
| 22 | ||
| 23 | describe("first-screen line", () => { | |
outdated
Box 3 asks that the row's text equal the first-login text. Assert |
||
| 24 | test("its step titles are exactly the ones nextStepsFor gives the first-login card", async () => { | |
| 25 | const forge = await claimedForge("forge-first-screen-"); | |
| 26 | try { | |
| 27 | const admin = getUserByName(forge.child.db, "maria")!; | |
| 28 | const steps = await nextStepsFor(forge.child, admin); | |
| 29 | expect(steps).toBeDefined(); | |
| 30 | const titles = steps!.map((s) => s.title); | |
| 31 | expect(titles).toEqual(Object.values(STEP_TITLES)); | |
| 32 | const line = firstScreenLine(["bakery"]); | |
| 33 | for (const t of titles) expect(line).toContain(t); | |
| 34 | expect(line.endsWith(`three next steps: ${titles.join("; ")}.`)).toBe(true); | |
| 35 | } finally { | |
| 36 | await forge.stop(); | |
| 37 | } | |
| 38 | }, 60_000); | |
| 39 | ||
| 40 | test("the row's text carries the first-login line, escaped, and nothing else is said for it", () => { | |
| 41 | const out = forgeRow(row({ apps: ["<b>x</b>", "menu"] }), Date.now()).text; | |
| 42 | const line = firstScreenLine(["<b>x</b>", "menu"]); | |
| 43 | expect(line).toBe( | |
| 44 | "They will see <b>x</b>, menu, their crew working, and three next steps: Make your everyday account; Open your apps; Tell the crew something.", | |
| 45 | ); | |
| 46 | expect(out).toContain(line.replace("<b>x</b>", "<b>x</b>")); | |
| 47 | expect(out).not.toContain("<b>x</b>"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("with no apps chosen it says so plainly", () => { | |
| 51 | expect(firstScreenLine([])).toContain("the apps you pick"); | |
| 52 | expect(forgeRow(row({ apps: [] }), Date.now()).text).toContain("the apps you pick"); | |
| 53 | }); | |
| 54 | ||
| 55 | test("a claimed row omits it, on the page and in the terminal", () => { | |
| 56 | const claimed = row({ claimed: true }); | |
| 57 | expect(forgeRow(claimed, Date.now()).text).not.toContain("data-first-screen"); | |
| 58 | expect(forgeLines([claimed]).map((l) => l[1]).join("\n")).not.toContain("three next steps"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the terminal says the same line for an unclaimed forge", () => { | |
| 62 | expect(forgeLines([row({})]).map((l) => l[1].trim())).toContain(firstScreenLine(["bakery"])); | |
| 63 | }); | |
| 64 | }); |
test/tui-forges.test.ts +3
| @@ -7,6 +7,7 @@ import { createApp, type App as Forge } from "../src/app.ts"; | ||
| 7 | 7 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 8 | 8 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 9 | 9 | import { fitLines, forgeLines } from "../src/tui/screens/forges.ts"; |
| 10 | import { firstScreenLine } from "../src/core/next-steps.ts"; | |
| 10 | 11 | import { runningWords, type ClientRow } from "../src/spawn/clients.ts"; |
| 11 | 12 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| 12 | 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -152,6 +153,7 @@ describe("forges you sent in the terminal", () => { | ||
| 152 | 153 | const lines = forgeLines([row({ reach })]); |
| 153 | 154 | expect(lines[0]![1]).toContain("Running, not reachable: deal answered 404, not by the app"); |
| 154 | 155 | expect(lines.slice(1).map(([, t]) => t.trim())).toEqual([ |
| 156 | firstScreenLine([]), | |
| 155 | 157 | "reachable maria--forge.test/api/v1/forge: answered 200", |
| 156 | 158 | "NOT REACHABLE deal--maria--forge.test/: answered 404, not by the app", |
| 157 | 159 | ]); |
| @@ -185,6 +187,7 @@ describe("forges you sent in the terminal", () => { | ||
| 185 | 187 | const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] }; |
| 186 | 188 | expect(forgeLines([row({ running: up })])).toEqual([ |
| 187 | 189 | ["maria", "asleep · for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet · small · shared · shared disk · no heartbeat yet"], |
| 190 | ["", ` ${firstScreenLine([])}`], | |
| 188 | 191 | ]); |
| 189 | 192 | expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([ |
| 190 | 193 | ["maria", "asleep · Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · shared disk · no heartbeat yet"], |
'They will see your apps' is wrong for an empty list. Say 'the apps you pick' (plain words, principle 8).