Public: anyone can read this; the crew can change it.
The studio is the default front door #96
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/28-the-studio-is-the-default-front-door → main 1 commit fork
9 files
changed +94
−42
README.md +3 −1
| @@ -268,7 +268,9 @@ mechanical rather than advisory. | ||
| 268 | 268 | what "ready now" means to a person and to the crew. |
| 269 | 269 | |
| 270 | 270 | `docs/STUDIO.md` is the forge with apps as the unit: tell, working, look, |
| 271 | shipped, asks you — and the switch to dev. | |
| 271 | shipped, asks you. It is the front door — a browser with no `forge_mode` | |
| 272 | cookie lands there — and the switch to dev, in the top bar or `?mode=dev` | |
| 273 | on any URL, is one click either way. | |
| 272 | 274 | |
| 273 | 275 | ## Develop |
| 274 | 276 |
docs/STUDIO.md +5 −3
| @@ -161,8 +161,9 @@ change's size, unlike the page it links to. | ||
| 161 | 161 | `/` in the studio: what asks you across every app you can land, what is on |
| 162 | 162 | you (issues and pulls a person assigned to you), and your apps — each with |
| 163 | 163 | whether it is live, what the crew last did there, and a way in. **New app** |
| 164 | is the same flow as ever. `/?mode=dev` is the repositories list without | |
| 165 | switching the browser. | |
| 164 | is the same flow as ever. `/?mode=dev` is the repositories list, and — like | |
| 165 | the toggle in the top bar — it switches this browser to dev from then on; | |
| 166 | `/?mode=studio` switches it back, the same way. | |
| 166 | 167 | |
| 167 | 168 | The home keeps itself current. *Asks you*, *your apps*, *happening now* and |
| 168 | 169 | *on you* are each a live region (`data-live`, `src/web/client.ts`): a deploy |
| @@ -178,7 +179,8 @@ Signed out, `/` in the studio is a stranger's front door, not the dev | ||
| 178 | 179 | pitch: a shelf of the public apps actually serving — name, one line of |
| 179 | 180 | description, live state, and production's own picture, linking straight to |
| 180 | 181 | the app. Sign in and Create an account stay one click away; `/?mode=dev` |
| 181 | still renders the dev landing unchanged. A forge with no public apps yet — | |
| 182 | still renders the dev landing, and switches this browser to dev, same as | |
| 183 | the toggle. A forge with no public apps yet — | |
| 182 | 184 | most people's first visit — falls back to that same dev landing rather |
| 183 | 185 | than showing an empty shelf. |
| 184 | 186 |
src/http/server.ts +34 −4
| @@ -84,7 +84,7 @@ import { | ||
| 84 | 84 | shipSubmit, |
| 85 | 85 | tellSubmit, |
| 86 | 86 | } from "../web/pages/studio.ts"; |
| 87 | import { DEFAULT_MODE, isMode } from "../web/mode.ts"; | |
| 87 | import { DEFAULT_MODE, isMode, modeCookie, type Mode } from "../web/mode.ts"; | |
| 88 | 88 | import { dependencySubmit } from "../web/pages/thread.ts"; |
| 89 | 89 | import { |
| 90 | 90 | assigneeSubmit, |
| @@ -810,6 +810,26 @@ function withRateHeaders( | ||
| 810 | 810 | return response; |
| 811 | 811 | } |
| 812 | 812 | |
| 813 | /** | |
| 814 | * `?mode=studio` or `?mode=dev` on any URL sets the cookie, exactly like the | |
| 815 | * toggle's POST — a link someone is handed must work as a switch by itself, | |
| 816 | * not just for the one page it lands on. | |
| 817 | */ | |
| 818 | function withModeCookie( | |
| 819 | response: Response, | |
| 820 | mode: Mode | undefined, | |
| 821 | secure: boolean, | |
| 822 | ): Response { | |
| 823 | if (mode === undefined) return response; | |
| 824 | try { | |
| 825 | response.headers.append("set-cookie", modeCookie(mode, secure)); | |
| 826 | } catch { | |
| 827 | // Same tradeoff as the rate headers: a frozen header set should not | |
| 828 | // fail the request. | |
| 829 | } | |
| 830 | return response; | |
| 831 | } | |
| 832 | ||
| 813 | 833 | const REPLAYABLE = new Set(["POST", "PUT", "PATCH", "DELETE"]); |
| 814 | 834 | |
| 815 | 835 | /** |
| @@ -1092,7 +1112,14 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1092 | 1112 | } |
| 1093 | 1113 | |
| 1094 | 1114 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; |
| 1095 | const modeCookie = cookieValue(request, "forge_mode") ?? ""; | |
| 1115 | const modeCookieValue = cookieValue(request, "forge_mode") ?? ""; | |
| 1116 | // `?mode=` on the URL is the escape hatch: a plain link a person is | |
| 1117 | // handed switches the browser by itself, the same way the toggle's | |
| 1118 | // POST does, so it must work in both directions with no form. | |
| 1119 | const modeParam = url.searchParams.get("mode"); | |
| 1120 | const modeOverride = isMode(modeParam) ? modeParam : undefined; | |
| 1121 | const mode = | |
| 1122 | modeOverride ?? (isMode(modeCookieValue) ? modeCookieValue : DEFAULT_MODE); | |
| 1096 | 1123 | const invoke = (into: Request): Response | Promise<Response> => |
| 1097 | 1124 | matched.handler({ |
| 1098 | 1125 | app, |
| @@ -1101,14 +1128,17 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1101 | 1128 | params: matched.params, |
| 1102 | 1129 | auth, |
| 1103 | 1130 | theme: isThemeName(themeCookie) ? themeCookie : undefined, |
| 1104 | mode: isMode(modeCookie) ? modeCookie : DEFAULT_MODE, | |
| 1131 | mode, | |
| 1105 | 1132 | }); |
| 1106 | 1133 | |
| 1107 | 1134 | const response = |
| 1108 | 1135 | credential === undefined |
| 1109 | 1136 | ? await invoke(request) |
| 1110 | 1137 | : await withIdempotency(app, request, url, credential, invoke); |
| 1111 | return withRateHeaders(response, verdict); | |
| 1138 | return withRateHeaders( | |
| 1139 | withModeCookie(response, modeOverride, url.protocol === "https:"), | |
| 1140 | verdict, | |
| 1141 | ); | |
| 1112 | 1142 | }, |
| 1113 | 1143 | error(error) { |
| 1114 | 1144 | app.log.error("unhandled request error", { error: String(error) }); |
src/web/mode.ts +3 −3
| @@ -3,8 +3,8 @@ | ||
| 3 | 3 | * code, commits, branches, runs. Studio makes apps the unit: a person tells |
| 4 | 4 | * the crew, looks at what it made, and decides when it asks. The choice is |
| 5 | 5 | * a cookie on this browser, like the theme; the URLs are the same in both, |
| 6 | * so a link works whichever way its reader sees. Dev is the default until | |
| 7 | * the studio has earned the front door. | |
| 6 | * so a link works whichever way its reader sees. Studio is the front door; | |
| 7 | * `?mode=dev` on any URL is the way out, one click, no form. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | export type Mode = "studio" | "dev"; |
| @@ -12,7 +12,7 @@ export type Mode = "studio" | "dev"; | ||
| 12 | 12 | export const isMode = (value: unknown): value is Mode => |
| 13 | 13 | value === "studio" || value === "dev"; |
| 14 | 14 | |
| 15 | export const DEFAULT_MODE: Mode = "dev"; | |
| 15 | export const DEFAULT_MODE: Mode = "studio"; | |
| 16 | 16 | |
| 17 | 17 | export function modeCookie(mode: Mode | undefined, secure: boolean): string { |
| 18 | 18 | const flags = `Path=/; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`; |
src/web/pages/issues.ts +3 −4
| @@ -472,10 +472,9 @@ export async function issuePage(ctx: Ctx): Promise<Response> { | ||
| 472 | 472 | const { repo, issue } = found; |
| 473 | 473 | // In the studio a tell is one sentence someone said, not dev's labels and |
| 474 | 474 | // dependency form — the same rule `pullPage` follows for a change. |
| 475 | // `?mode=dev` is the one-off way to dev's page without switching the | |
| 476 | // browser, same as there. | |
| 477 | const studio = | |
| 478 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 475 | // `?mode=dev` on the URL is the escape hatch to dev's page, resolved | |
| 476 | // once for `ctx.mode`. | |
| 477 | const studio = ctx.mode === "studio"; | |
| 479 | 478 | if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined) |
| 480 | 479 | return await studioTell(ctx, repo, issue); |
| 481 | 480 | const base = repoBase(repo); |
src/web/pages/pull.ts +3 −4
| @@ -668,10 +668,9 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 668 | 668 | if (found instanceof Response) return found; |
| 669 | 669 | const { repo, pull } = found; |
| 670 | 670 | // In the studio a change is judged by what it does, not its diff — the |
| 671 | // same rule `repoHome` follows for an app's own page. `?mode=dev` is the | |
| 672 | // one-off way to the diff without switching the browser, same as home. | |
| 673 | const studio = | |
| 674 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 671 | // same rule `repoHome` follows for an app's own page. `?mode=dev` on the | |
| 672 | // URL is the escape hatch to the diff, resolved once for `ctx.mode`. | |
| 673 | const studio = ctx.mode === "studio"; | |
| 675 | 674 | if (studio) { |
| 676 | 675 | const record = getAppByRepo(ctx.app.db, repo.id); |
| 677 | 676 | if (record !== undefined) return await studioPull(ctx, repo, pull, record); |
src/web/pages/runs.ts +3 −4
| @@ -341,10 +341,9 @@ export async function runPage(ctx: Ctx): Promise<Response> { | ||
| 341 | 341 | const { run, lines, truncated } = found.value; |
| 342 | 342 | // In the studio, working is a run: who is on it, the tell it came from, |
| 343 | 343 | // what it has cost, and the box to steer it — the same rule `repoHome` |
| 344 | // and `pullPage` follow. `?mode=dev` is the one-off way to the transcript | |
| 345 | // dev already renders, without switching the browser. | |
| 346 | const studio = | |
| 347 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 344 | // and `pullPage` follow. `?mode=dev` on the URL is the escape hatch to | |
| 345 | // the transcript dev already renders, resolved once for `ctx.mode`. | |
| 346 | const studio = ctx.mode === "studio"; | |
| 348 | 347 | if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined) |
| 349 | 348 | return await studioRun(ctx, repo, run, lines, truncated); |
| 350 | 349 | const base = repoBase(repo); |
src/web/pages/site.ts +3 −4
| @@ -772,10 +772,9 @@ function dashboard(ctx: Ctx, user: User): Html { | ||
| 772 | 772 | export async function home(ctx: Ctx): Promise<Response> { |
| 773 | 773 | const user = ctx.auth.user; |
| 774 | 774 | // Studio by default: apps, the crew, what asks you. `?mode=dev` on the |
| 775 | // home is the one-off way in without switching the browser — for a | |
| 776 | // stranger as much as for someone signed in. | |
| 777 | const studio = | |
| 778 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 775 | // home is the escape hatch — for a stranger as much as for someone | |
| 776 | // signed in — resolved once for `ctx.mode`. | |
| 777 | const studio = ctx.mode === "studio"; | |
| 779 | 778 | if (user !== undefined && studio) return await studioHome(ctx, user); |
| 780 | 779 | return page({ |
| 781 | 780 | ctx, |
test/studio.test.ts +37 −15
| @@ -140,24 +140,46 @@ describe("the studio", () => { | ||
| 140 | 140 | await rm(dataDir, { recursive: true, force: true }); |
| 141 | 141 | }); |
| 142 | 142 | |
| 143 | test("home is the studio with the cookie and the repositories without; the switch is a cookie", async () => { | |
| 144 | const plain = await ( | |
| 145 | await fetch(`${base}/`, { headers: { cookie } }) | |
| 143 | test("home is the studio by default with no cookie at all; ?mode= is a link that switches this browser, both ways", async () => { | |
| 144 | const plainResponse = await fetch(`${base}/`, { headers: { cookie } }); | |
| 145 | const plain = await plainResponse.text(); | |
| 146 | expect(plain).toContain("Your apps"); | |
| 147 | expect(plain).toContain("notes"); | |
| 148 | expect(plain).not.toContain("<h1>Your repositories</h1>"); | |
| 149 | expect(plainResponse.headers.get("set-cookie")).toBeNull(); | |
| 150 | ||
| 151 | // `?mode=studio` is a no-op switch here, but it still sets the cookie — | |
| 152 | // the same link works from a dev-cookied browser too. | |
| 153 | const toStudio = await fetch(`${base}/?mode=studio`, { headers: { cookie } }); | |
| 154 | expect(toStudio.headers.get("set-cookie")).toContain("forge_mode=studio"); | |
| 155 | ||
| 156 | const devResponse = await fetch(`${base}/?mode=dev`, { headers: { cookie } }); | |
| 157 | const dev = await devResponse.text(); | |
| 158 | expect(dev).toContain("<h1>Your repositories</h1>"); | |
| 159 | expect(dev).not.toContain("Your apps"); | |
| 160 | expect(devResponse.headers.get("set-cookie")).toContain("forge_mode=dev"); | |
| 161 | ||
| 162 | // `?mode=dev` still wins for this browser even once studio's cookie is set. | |
| 163 | const stillDev = await ( | |
| 164 | await fetch(`${base}/?mode=dev`, { | |
| 165 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 166 | }) | |
| 146 | 167 | ).text(); |
| 147 | expect(plain).not.toContain("<h1>Studio</h1>"); | |
| 148 | const home = await (await get("/")).text(); | |
| 149 | expect(home).toContain("Studio"); | |
| 150 | expect(home).toContain("notes"); | |
| 151 | expect(home).toContain("New app"); | |
| 152 | const dev = await (await get("/?mode=dev")).text(); | |
| 153 | expect(dev).not.toContain("<h1>Studio</h1>"); | |
| 154 | const flipped = await post("/mode", { mode: "dev", back: "/" }); | |
| 155 | expect(flipped.status).toBe(303); | |
| 156 | expect(flipped.headers.get("set-cookie")).toContain("forge_mode=dev"); | |
| 168 | expect(stillDev).toContain("<h1>Your repositories</h1>"); | |
| 169 | ||
| 170 | // And the link's switch stuck: this browser stays in dev with no query. | |
| 171 | const devCookie = (devResponse.headers.get("set-cookie") ?? "").split( | |
| 172 | ";", | |
| 173 | )[0]!; | |
| 157 | 174 | const asDev = await ( |
| 158 | await get("/", { cookie: `${cookie}; forge_mode=dev` }) | |
| 175 | await fetch(`${base}/`, { headers: { cookie: `${cookie}; ${devCookie}` } }) | |
| 159 | 176 | ).text(); |
| 160 | expect(asDev).not.toContain("<h1>Studio</h1>"); | |
| 177 | expect(asDev).toContain("<h1>Your repositories</h1>"); | |
| 178 | ||
| 179 | // The toggle's own POST still flips it back, cookie and all. | |
| 180 | const flipped = await post("/mode", { mode: "studio", back: "/" }); | |
| 181 | expect(flipped.status).toBe(303); | |
| 182 | expect(flipped.headers.get("set-cookie")).toContain("forge_mode=studio"); | |
| 161 | 183 | const bad = await post("/mode", { mode: "neither", back: "/" }); |
| 162 | 184 | expect(bad.headers.get("set-cookie")).toContain("forge_mode=;"); |
| 163 | 185 | }); |
new above
offline