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

Merged opened this

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

Merged

scout/forge:scout/11433-a-new-forge-s-link-page-says-in-one-line → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/11433-a-new-forge-s-link-page-says-in-one-line was merged into main as bc0ee8d.

Review

Sign in to review this change.
7 files changed +119 −18
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 {
2424 readonly done: boolean;
2525 }
2626
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. */
outdated

'They will see your apps' is wrong for an empty list. Say 'the apps you pick' (plain words, principle 8).

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
2742 const OPENED_APP = "first_step_opened_app";
2843
2944 /** 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(
6176 return [
6277 {
6378 key: "everyday",
64 title: "Make your everyday account",
79 title: STEP_TITLES.everyday,
6580 done: daily !== undefined || keptAdmin(app.db, owner),
6681 },
6782 {
6883 key: "apps",
69 title: "Open your apps",
84 title: STEP_TITLES.apps,
7085 done: getUserSetting(app.db, owner.id, OPENED_APP) !== undefined,
7186 },
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 },
7388 ];
7489 }
7590
src/spawn/clients.ts +6
@@ -21,6 +21,7 @@ import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./c
2121 import { lastHeard, type Beat } from "./heartbeat.ts";
2222 import { reachOf, reachWords, type ChildReach } from "./reach.ts";
2323 import type { Size } from "./sizes.ts";
24 import { firstScreenLine } from "../core/next-steps.ts";
2425 import { forgeUse, type ForgeUse } from "./usage.ts";
2526
2627 /** What a spawned forge says about itself to anyone: nothing more. */
@@ -357,6 +358,11 @@ export function claimState(r: ClientRow): keyof typeof CLAIM_WORDS {
357358 return r.claimed === null ? "unknown" : r.claimed ? "yes" : "no";
358359 }
359360
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
360366 /** "3 apps running", or which one is not and why; null when none are run here. */
361367 export function runningWords(r: ClientRow): string | null {
362368 const unreachable = reachWords(r.reach);
src/tui/screens/forges.ts +8 −2
@@ -6,7 +6,7 @@
66
77 import type { Ctx, Screen } from "../app/app.ts";
88 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";
1010 import { useLine } from "../../spawn/usage.ts";
1111 import { timeAgo } from "../../web/ui.ts";
1212 import { card } from "../ui/widgets.ts";
@@ -29,7 +29,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
2929 [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)}`]
3030 .filter((m): m is string => m !== null)
3131 .join(" · "),
32 ], ...addressLines(r)]);
32 ], ...firstScreenLines(r), ...addressLines(r)]);
3333 }
3434
3535 /** "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 {
3838 return link.state === "open" ? "link open" : undoable(link) ? "link ended, undo with u" : "link ended";
3939 }
4040
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
4147 /** Under a forge that is running, not reachable: each public address and what it answered (#7959), as /forges lists them. */
4248 function addressLines(r: ClientRow): [string, string][] {
4349 if (r.reach === null || r.reach.ok) return [];
src/web/pages/forges.ts +12 −13
@@ -7,7 +7,7 @@
77 */
88
99 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";
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
@@ -72,19 +72,18 @@ export function forgeRow(r: ClientRow, now: number): Html {
7272 // Enter opens the row's first link, the forge's name.
7373 nav: true,
7474 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" },
8784 }),
85 ),
86 ],
8887 trail: html`${r.attention === null
8988 ? null
9089 : 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 out contains firstScreenLine(apps) escaped, and that the titles equal those nextStepsFor returns. Also test the empty-apps case and that a claimed row omits the line.

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>", "&lt;b&gt;x&lt;/b&gt;"));
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";
77 import { createUser, getUserByName } from "../src/core/users.ts";
88 import { createForgeApp } from "../src/tui/forge.ts";
99 import { fitLines, forgeLines } from "../src/tui/screens/forges.ts";
10 import { firstScreenLine } from "../src/core/next-steps.ts";
1011 import { runningWords, type ClientRow } from "../src/spawn/clients.ts";
1112 import type { TuiIdentity } from "../src/tui/session.ts";
1213 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -152,6 +153,7 @@ describe("forges you sent in the terminal", () => {
152153 const lines = forgeLines([row({ reach })]);
153154 expect(lines[0]![1]).toContain("Running, not reachable: deal answered 404, not by the app");
154155 expect(lines.slice(1).map(([, t]) => t.trim())).toEqual([
156 firstScreenLine([]),
155157 "reachable maria--forge.test/api/v1/forge: answered 200",
156158 "NOT REACHABLE deal--maria--forge.test/: answered 404, not by the app",
157159 ]);
@@ -185,6 +187,7 @@ describe("forges you sent in the terminal", () => {
185187 const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] };
186188 expect(forgeLines([row({ running: up })])).toEqual([
187189 ["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([])}`],
188191 ]);
189192 expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([
190193 ["maria", "asleep · Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · shared disk · no heartbeat yet"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs