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

Merged opened this

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

Merged

scout/forge:scout/11237-the-anonymous-home-page-offers-start-my → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/11237-the-anonymous-home-page-offers-start-my was merged into main as bcf55f9.

Review

Sign in to review this change.
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
3939 - `.door-press-row` / `.door-press` — the presses, side by side from 720px; each names the pack, what it holds, and links to `/packs/:name`.
4040 - `.door-press-actions` — a press's actions; its empty `data-slot="host"` is where a later "host it for me" sits.
4141 - `.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 {
788788 <h1>Software for what you're building.<br /><span class="muted">Yours to keep.</span></h1>
789789 <div class="door-under">
790790 <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">
outdated

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.

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 field allows.

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>`)}
796800 <div class="door-agent">
797801 <p>
798802 Have an AI assistant? Give it this:
src/web/style/content.ts +2
@@ -260,6 +260,8 @@ form.inline { display: inline; margin-left: auto; }
260260 .door-under { display: grid; gap: 56px; margin-top: 28px; }
261261 .door-lead { display: flex; flex-direction: column; align-items: flex-start; gap: 24px; min-width: 0; }
262262 .door-lead .bar { gap: 8px; flex-wrap: wrap; }
263 .door-start { width: 100%; max-width: 28rem; }
264 .door-start .btn { width: 100%; }
263265 .door-agent { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 520px; font-size: 13px; color: var(--muted-fg); }
264266 .door-agent a { margin-left: 8px; color: var(--fg); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--border-strong); }
265267 /* 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs