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

/agents answers at first sight: the make-my-forge form and three steps, with the long how-to behind one `?` #11078

Merged opened this

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

Merged

mason/forge:mason/11068-agents-answers-at-first-sight-the-make-m → main 3 commits fork 10 threads · 10 unresolved

  • Merged. mason/11068-agents-answers-at-first-sight-the-make-m was merged into main as 4715187.

Review

Sign in to review this change.
2 files changed +81 −7
src/web/pages/agents.ts +27 −6
@@ -9,8 +9,8 @@
99 import type { Ctx } from "../../http/context.ts";
1010 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1111 import { pageHead } from "../chrome.ts";
12 import { html } from "../html.ts";
13 import { btn, field as formField } from "../ui.ts";
12 import { html, type Html } from "../html.ts";
13 import { btn, disclosure, field as formField } from "../ui.ts";
1414 import { page } from "../layout.ts";
1515 import { renderMarkdown } from "../markdown.ts";
1616 import { llmsBody } from "../standards.ts";
@@ -39,6 +39,28 @@ function withoutTitle(body: string): string {
3939 return body.replace(/^# .*\n+/, "");
4040 }
4141
42 /** The guide's first "Start here" bullet, the path with no account, as its first three sentences:
43 * the curl that makes the app, the clone and push, the claim link for the person. The page's steps
44 * are the guide's own words, so the two never drift. */
45 export function firstSteps(body: string): string[] {
46 const start = body.indexOf("## Start here");
47 if (start < 0) return [];
48 const bullet = body.slice(start).split("\n").find((l) => l.startsWith("- "));
49 if (!bullet) return [];
50 const action = bullet.slice(bullet.indexOf(": ") + 2);
51 return action.split(/\. (?=[A-Z])/).slice(0, 3).map((s) => s.trim().replace(/\.$/, ""));
52 }
53
54 /** One step's words, each `code span` drawn as code and everything escaped. */
55 function stepLine(step: string): Html {
56 return html`${step.split(/`([^`]*)`/).map((part, i) => (i % 2 === 1 ? html`<code>${part}</code>` : part))}`;
57 }
58
59 /** The whole guide as the page draws it inside the disclosure: the text less its title, each address a link. */
outdated

Principle 1: the sub names the page's purpose and then a second clause plus a link ('What this forge is, and the steps an assistant takes. Plain text: /llms.txt'). The page now has the h1, the sub, an h2 'Get a forge of your own' and the steps: say it once. Drop 'the steps an assistant takes' (the list says it).

60 export function guideHtml(body: string): Html {
outdated

Principle 1 (nothing explains itself twice): these three steps are hand-written copies of what llms.txt says, and the guide behind ? repeats them. They will drift. Derive them from the same source as the guide (e.g. the first three step lines of llmsBody) or keep one canonical wording shared by both. Also confirm the ol shows numbers at 360 px; stack may strip list markers.

61 return renderMarkdown(linkAddresses(withoutTitle(body)));
62 }
63
4264 export function agentsPage(ctx: Ctx): Response {
4365 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4466 const { body } = llmsBody(ctx);
outdated

Principle 8 and DESIGN.md components: a <summary> styled with btn classes is hand-rolled markup that the named button component does not cover, and it is a lone ? with only an aria-label. Use the disclosure pattern DESIGN.md names, or add it to docs/design/areas/ if there is none. Make the visible label say something plain, such as 'Full guide', so it can be found without the ?. Also check the summary has a visible focus ring and a 44 px tap target on a phone.

@@ -48,17 +70,16 @@ export function agentsPage(ctx: Ctx): Response {
4870 description:
outdated

Principle 1/8 and box 1: the steps read 'Build now, no account', 'Or with an account', 'Get your own forge'. These are headings, not 'each one line an assistant takes'; step 2 means nothing without its neighbour. Write three short plain-word actions (e.g. 'POST /api/v1/start', 'clone and push to main', 'give the claim link to the person'), derived from the guide or constants checked against it. Also the manual '1.' inside <li> under an <ol> with markers stripped is fake numbering: use the real list numbering via a design-system class, or a plain list; screen readers will not get a list position otherwise.

4971 "What this forge is and how an AI assistant can help you use it: build one of its public apps, report a problem, or get a forge of your own.",
outdated

Box 2 / a11y: the aria-label replaces the summary's visible text '? Full guide', so a voice-control user saying the visible words does not match the accessible name (WCAG 2.5.3). Drop the label or start it with the visible text. The issue asks for a disclosure opened by ?; keep it short and obvious.

5072 body: html`${pageHead("For AI assistants", {
51 sub: html`What this forge is, and the steps an assistant can take for you, or tell you to take. The same
52 words as plain text: <a href="/llms.txt">/llms.txt</a>.`,
73 sub: html`What this forge is. Plain text: <a href="/llms.txt">/llms.txt</a>`,
5374 })}
5475 <section class="stack" aria-labelledby="agent-forge-h">
5576 <h2 id="agent-forge-h">Get a forge of your own</h2>
56 <p class="muted small">Free within limits while hosting is not sold. It sleeps when idle and wakes on its next visit; bring your own model credential or its crew stays off.</p>
5777 <form method="post" action="/forges/new" class="stack" id="agent-forge">
5878 ${formField({ label: "Name", name: "name", required: true })}
5979 ${btn("Make my forge", { variant: "primary" })}
6080 </form>
6181 </section>
62 <article class="prose prose-page" id="agents-guide">${renderMarkdown(linkAddresses(withoutTitle(body)))}</article>`,
82 <div class="prose break"><ol id="agent-steps" aria-label="Build an app with no account">${firstSteps(body).map((step) => html`<li>${stepLine(step)}</li>`)}</ol></div>
83 ${disclosure("? Full guide", html`<article class="prose prose-page" id="agents-guide">${guideHtml(body)}</article>`, { id: "agents-more" })}`,
6384 }));
6485 }
test/agents-page.test.ts +54 −1
@@ -9,11 +9,14 @@ import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
12 import { linkAddresses } from "../src/web/pages/agents.ts";
12 import { firstSteps, guideHtml, linkAddresses } from "../src/web/pages/agents.ts";
1313 import { quietLogs } from "./helpers/globals.ts";
1414
1515 quietLogs();
1616
17 /** Words above the disclosure: the head, the form and the three steps, measured with a few words of headroom. */
18 const WORDS_CEILING = 56;
19
1720 let dataDir: string;
1821 let app: App;
1922 let servers: Servers;
@@ -57,3 +60,53 @@ test("the home page's prompt and Open-in links point at the page", async () => {
5760 test("a placeholder address is not a link", () => {
5861 expect(linkAddresses('"page": "https://..."')).toBe('"page": "https://..."');
5962 });
63
64 test("the steps are the guide's no-account path, each one line to act on", () => {
65 const steps = firstSteps("# t\n\n## Start here\n\nWords.\n\n- Build now, no account: `curl -sX POST https://f.example/api/v1/start -d '{\"name\":\"my-app\"}'` -> {user, claim}. Clone git.url (user:token@), `git push` to main: app.url is live. Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.\n- Or with an account: x.\n");
66 expect(steps).toEqual([
67 "`curl -sX POST https://f.example/api/v1/start -d '{\"name\":\"my-app\"}'` -> {user, claim}",
68 "Clone git.url (user:token@), `git push` to main: app.url is live",
69 "Give claim.url to the person who takes it over",
70 ]);
71 });
outdated

This ceiling lets one note-class element through, but the page now has none outside the disclosure, so a re-added <p class="muted small"> would pass. Set it to 0 (or to the real count) so the test fails without the change. Also match elements by class tokens on any tag, as the issue says, not only the first occurrence in a class string.

72
73 test("above the fold the page is the form and three steps; the whole guide is behind one disclosure", async () => {
outdated

80 words is loose: the real visible count outside the disclosure is much lower (the form and three one-liners), so a paragraph could be added without failing. Set the ceiling close to the real count, in the shape #10998 uses. Also add an assertion that /llms.txt is unchanged word for word, and that the guide inside the disclosure contains every llmsBody section heading.

74 const page = await (await fetch(`${servers.url}/agents`)).text();
75 const plain = await (await fetch(`${servers.url}/llms.txt`)).text();
outdated

Box 3: the ceiling is a count of one note, but the issue wants the sub to be the only prose; pin it exactly so adding a second note fails, and tie the words ceiling (45) to a tighter value near the real count (measure it; leave a few words of headroom). Also count note-class elements in the same way #10998 does for /crew and /flow: reuse its helper if there is one.

76 const main = page.slice(page.indexOf("<h1"));
77 const open = main.indexOf('<details id="agents-more"');
78 expect(open).toBeGreaterThan(0);
79 const outside = main.slice(0, open);
80 expect(main.match(/<details/g)?.length).toBe(1);
81 expect(outside).toContain('action="/forges/new"');
82 expect(outside).toContain('href="/llms.txt"');
83 // Real list numbering: an <ol> inside .prose, no hand-written "1." in the items.
84 const steps = outside.slice(outside.indexOf('<ol id="agent-steps"'));
85 expect(outside).toContain('<div class="prose break"><ol id="agent-steps"');
outdated

Box 2: 'word for word as today' is not tested. Fetching /llms.txt twice only proves determinism. Assert the guide inside the disclosure contains the llms text's key sentences (the free-within-limits / sleep / bring-your-own-credential note that the pull removed from the page, the claim and 7-day/30-day rules) or compare against llmsBody(ctx) directly, so a changed word fails.

86 const items = [...steps.matchAll(/<li>(.*?)<\/li>/gs)].map((m) => m[1]!);
87 expect(items.length).toBe(3);
88 for (const item of items) expect(item).not.toMatch(/^\s*\d+\./);
89 // Each step is the guide's own sentence: the curl, the clone and push, the claim link.
90 expect(items[0]).toContain(`<code>curl -sX POST ${servers.url}/api/v1/start`);
91 expect(items[1]).toContain("<code>git push</code> to main");
92 expect(items[2]).toContain("Give claim.url to the person");
93 // Every note-class element, any tag, counted by class token: the page head's subtitle is the only prose.
94 const notes = [...outside.matchAll(/class="([^"]*)"/g)].filter((m) => m[1]!.split(/\s+/).some((c) => ["muted", "small", "note", "hint", "why"].includes(c)));
95 expect(notes.map((m) => m[1])).toEqual(["page-head-sub muted small"]);
96 const words = outside.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean).length;
97 expect(words).toBeLessThanOrEqual(WORDS_CEILING);
98 // The summary's accessible name is its visible text: no aria-label over it.
99 expect(main).toMatch(/<summary>\? Full guide<\/summary>/);
100 // Inside is /llms.txt word for word: the same Markdown rendered, so a changed word fails.
101 const inside = main.slice(open);
102 expect(inside).toContain(guideHtml(plain).toString());
103 for (const sentence of [
104 "Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.",
105 "free within limits while hosting is not sold",
106 "It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.",
107 ]) {
108 expect(plain).toContain(sentence);
109 expect(inside).toContain(sentence);
110 }
111 expect(plain.startsWith("# forge at ")).toBe(true);
112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs