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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11068-agents-answers-at-first-sight-the-make-m → main 3 commits fork 10 threads · 10 unresolved
src/web/pages/agents.ts +27 −6
| @@ -9,8 +9,8 @@ | ||
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | 10 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 11 | 11 | 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"; | |
| 14 | 14 | import { page } from "../layout.ts"; |
| 15 | 15 | import { renderMarkdown } from "../markdown.ts"; |
| 16 | 16 | import { llmsBody } from "../standards.ts"; |
| @@ -39,6 +39,28 @@ function withoutTitle(body: string): string { | ||
| 39 | 39 | return body.replace(/^# .*\n+/, ""); |
| 40 | 40 | } |
| 41 | 41 | |
| 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. */ | |
| 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 |
||
| 61 | return renderMarkdown(linkAddresses(withoutTitle(body))); | |
| 62 | } | |
| 63 | ||
| 42 | 64 | export function agentsPage(ctx: Ctx): Response { |
| 43 | 65 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 44 | 66 | const { body } = llmsBody(ctx); |
outdated
Principle 8 and DESIGN.md components: a |
||
| @@ -48,17 +70,16 @@ export function agentsPage(ctx: Ctx): Response { | ||
| 48 | 70 | 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. |
||
| 49 | 71 | "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 |
||
| 50 | 72 | 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>`, | |
| 53 | 74 | })} |
| 54 | 75 | <section class="stack" aria-labelledby="agent-forge-h"> |
| 55 | 76 | <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> | |
| 57 | 77 | <form method="post" action="/forges/new" class="stack" id="agent-forge"> |
| 58 | 78 | ${formField({ label: "Name", name: "name", required: true })} |
| 59 | 79 | ${btn("Make my forge", { variant: "primary" })} |
| 60 | 80 | </form> |
| 61 | 81 | </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" })}`, | |
| 63 | 84 | })); |
| 64 | 85 | } |
test/agents-page.test.ts +54 −1
| @@ -9,11 +9,14 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | 14 | |
| 15 | 15 | quietLogs(); |
| 16 | 16 | |
| 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 | ||
| 17 | 20 | let dataDir: string; |
| 18 | 21 | let app: App; |
| 19 | 22 | let servers: Servers; |
| @@ -57,3 +60,53 @@ test("the home page's prompt and Open-in links point at the page", async () => { | ||
| 57 | 60 | test("a placeholder address is not a link", () => { |
| 58 | 61 | expect(linkAddresses('"page": "https://..."')).toBe('"page": "https://..."'); |
| 59 | 62 | }); |
| 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 |
||
| 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 | }); |
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).