Public: anyone can read this; the crew can change it.
The anonymous home page offers "Start my forge": one labelled name field and one button #11268
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11237-the-anonymous-home-page-offers-start-my → main 4 commits fork 3 threads · 3 unresolved
5 files
changed +77
−5
docs/design/areas/front.md +1
| @@ -39,3 +39,4 @@ The page a visitor reads first runs past §3's working scale on purpose, in `cla | ||
| 39 | 39 | - `.door-press-row` / `.door-press` — the presses, side by side from 720px; each names the pack, what it holds, and links to `/packs/:name`. |
| 40 | 40 | - `.door-press-actions` — a press's actions; its empty `data-slot="host"` is where a later "host it for me" sits. |
| 41 | 41 | - `.door-wide` — a visitor's top-bar link that a phone leaves out, so the bar never overflows. |
| 42 | - `.door-start` — the hero's one form: a labelled forge-name field and "Start my forge", full width on a phone, posting to `/forges/new`. |
docs/features/start-my-forge.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # Start my forge | |
| 2 | ||
| 3 | A signed-out visitor starts their own forge from the home page, with no account first. | |
| 4 | ||
| 5 | 1. Open `/`. The first view has one sentence, a "Forge name" field and the button "Start my forge". | |
| 6 | 2. Type a name (lowercase letters, digits and dashes) and press Enter or the button. | |
| 7 | 3. The form posts to `/forges/new`, the same door `/agents` uses: the free plan and the visitor rate limits apply, and a bad name comes back worded by the door. There is no second spawn path. | |
| 8 | ||
| 9 | The field is a slug, so it turns off autocomplete and capitalisation on phones. The form is in `src/web/studio/home.ts`; its test is `test/home-start-forge.test.ts`. | |
| 10 | ||
| 11 | In the terminal, a guest's "Get your own forge" command prints the plans and the web address to start from; signed in, "New forge" spawns one. |
src/web/studio/home.ts +9 −5
| @@ -788,11 +788,15 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 788 | 788 | <h1>Software for what you're building.<br /><span class="muted">Yours to keep.</span></h1> |
| 789 | 789 | <div class="door-under"> |
| 790 | 790 | <div class="door-lead"> |
| 791 | <p class="door-support">Say what you need. An AI crew builds it, for a business or a game studio.</p> | |
| 792 | <div class="bar"> | |
| 793 | ${btn("Get your own forge", { href: "#yours", variant: "primary", size: "lg" })} | |
| 794 | ${when(made.length > 0, () => btn("See what it built", { href: "#made", size: "lg" }))} | |
| 795 | </div> | |
| 791 | <p class="door-support">Say what you need and an AI crew builds it in your own forge, free to try.</p> | |
| 792 | <form method="post" action="/forges/new" class="door-start" id="start-forge"> | |
| 793 | <div class="field"> | |
| 794 | <label for="start-name">Forge name</label> | |
| 795 | <input class="input" id="start-name" name="name" type="text" required autocomplete="off" autocapitalize="none" spellcheck="false" /> | |
outdated
Principles 1/8: the hint 'Lowercase letters, digits and dashes.' is a note; the door words the error for a person. Drop it. Also set autocapitalize=none and autocomplete=off for a slug on phones, if |
||
| 796 | </div> | |
outdated
Principle 5: a primary button followed, at line 799, by another primary 'Get your own forge' puts two accents in the first view for the same action. Keep this one; change or remove the other. |
||
| 797 | ${btn("Start my forge", { variant: "primary", size: "lg" })} | |
| 798 | </form> | |
| 799 | ${when(made.length > 0, () => html`<div class="bar">${btn("See what it built", { href: "#made", size: "lg" })}</div>`)} | |
| 796 | 800 | <div class="door-agent"> |
| 797 | 801 | <p> |
| 798 | 802 | Have an AI assistant? Give it this: |
src/web/style/content.ts +2
| @@ -260,6 +260,8 @@ form.inline { display: inline; margin-left: auto; } | ||
| 260 | 260 | .door-under { display: grid; gap: 56px; margin-top: 28px; } |
| 261 | 261 | .door-lead { display: flex; flex-direction: column; align-items: flex-start; gap: 24px; min-width: 0; } |
| 262 | 262 | .door-lead .bar { gap: 8px; flex-wrap: wrap; } |
| 263 | .door-start { width: 100%; max-width: 28rem; } | |
| 264 | .door-start .btn { width: 100%; } | |
| 263 | 265 | .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); } |
| 264 | 266 | .door-agent a { margin-left: 8px; color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); } |
| 265 | 267 | /* On the input's own tinted ground the muted color falls short of AA; the words are the thing to copy. */ |
test/home-start-forge.test.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** The anonymous home offers one way in: a labelled name field and "Start my forge" (#11237). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | ||
| 9 | let dataDir: string; | |
| 10 | let app: App; | |
| 11 | let servers: Servers; | |
| 12 | beforeAll(async () => { | |
| 13 | dataDir = await mkdtemp(join(tmpdir(), "forge-start-")); | |
| 14 | app = createApp({ dataDir, port: 0 }); | |
| 15 | servers = await serve(app); | |
| 16 | }); | |
| 17 | afterAll(async () => { | |
| 18 | await servers.stop?.(); | |
| 19 | app.close(); | |
| 20 | await rm(dataDir, { recursive: true, force: true }); | |
| 21 | }); | |
| 22 | ||
| 23 | test("a visitor with no cookies sees the start form on /", async () => { | |
| 24 | const body = await (await fetch(`${servers.url}/`)).text(); | |
| 25 | const form = body.match(/<form[^>]*id="start-forge"[\s\S]*?<\/form>/)?.[0] ?? ""; | |
| 26 | expect(form).toContain('action="/forges/new"'); | |
| 27 | expect(form).toMatch(/method="post"/i); | |
| 28 | expect(form).toMatch(/<label[^>]*for="start-name"/); | |
| 29 | expect(form).toContain('id="start-name"'); | |
| 30 | expect(form).toContain("required"); | |
| 31 | expect(form).toContain("Start my forge"); | |
| 32 | expect(body).toContain("free to try"); | |
| 33 | expect(form).toContain('autocapitalize="none"'); | |
| 34 | expect(form).toContain('autocomplete="off"'); | |
| 35 | expect(form).not.toContain("class=\"hint\""); | |
| 36 | expect(body.match(/Start my forge/g)).toHaveLength(1); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the first view has one primary button, and the first sentence is one", async () => { | |
| 40 | const body = await (await fetch(`${servers.url}/`)).text(); | |
| 41 | const hero = body.slice(body.indexOf('class="door-hero"'), body.indexOf('id="made"')); | |
| 42 | expect(hero.match(/data-variant="primary"/g)).toHaveLength(1); | |
| 43 | expect(hero.match(/class="door-support"/g)).toHaveLength(1); | |
| 44 | }); | |
| 45 | ||
| 46 | test("the form's POST without a same-origin header is refused by the door", async () => { | |
| 47 | const res = await fetch(`${servers.url}/forges/new`, { | |
| 48 | method: "POST", | |
| 49 | headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" }, | |
| 50 | body: "name=sneaky", | |
| 51 | redirect: "manual", | |
| 52 | }); | |
| 53 | expect([400, 403]).toContain(res.status); | |
| 54 | }); |
Principle 1: this adds a second support sentence under 'Say what you need...'. One confident sentence only; fold 'free to try' into the first or remove the first.