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

The studio is the default front door #96

Merged opened this

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

Merged

scout/forge:scout/28-the-studio-is-the-default-front-door → main 1 commit fork

  • Merged. scout/28-the-studio-is-the-default-front-door was merged into main as 0d4bc85.

Review

Sign in to review this change.
9 files changed +94 −42
README.md +3 −1
@@ -268,7 +268,9 @@ mechanical rather than advisory.
268268 what "ready now" means to a person and to the crew.
269269
270270 `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.
272274
273275 ## Develop
274276
docs/STUDIO.md +5 −3
@@ -161,8 +161,9 @@ change's size, unlike the page it links to.
161161 `/` in the studio: what asks you across every app you can land, what is on
162162 you (issues and pulls a person assigned to you), and your apps — each with
163163 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.
166167
167168 The home keeps itself current. *Asks you*, *your apps*, *happening now* and
168169 *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
178179 pitch: a shelf of the public apps actually serving — name, one line of
179180 description, live state, and production's own picture, linking straight to
180181 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 —
182184 most people's first visit — falls back to that same dev landing rather
183185 than showing an empty shelf.
184186
src/http/server.ts +34 −4
@@ -84,7 +84,7 @@ import {
8484 shipSubmit,
8585 tellSubmit,
8686 } 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";
8888 import { dependencySubmit } from "../web/pages/thread.ts";
8989 import {
9090 assigneeSubmit,
@@ -810,6 +810,26 @@ function withRateHeaders(
810810 return response;
811811 }
812812
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
813833 const REPLAYABLE = new Set(["POST", "PUT", "PATCH", "DELETE"]);
814834
815835 /**
@@ -1092,7 +1112,14 @@ export async function serve(app: App): Promise<Servers> {
10921112 }
10931113
10941114 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);
10961123 const invoke = (into: Request): Response | Promise<Response> =>
10971124 matched.handler({
10981125 app,
@@ -1101,14 +1128,17 @@ export async function serve(app: App): Promise<Servers> {
11011128 params: matched.params,
11021129 auth,
11031130 theme: isThemeName(themeCookie) ? themeCookie : undefined,
1104 mode: isMode(modeCookie) ? modeCookie : DEFAULT_MODE,
1131 mode,
11051132 });
11061133
11071134 const response =
11081135 credential === undefined
11091136 ? await invoke(request)
11101137 : 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 );
11121142 },
11131143 error(error) {
11141144 app.log.error("unhandled request error", { error: String(error) });
src/web/mode.ts +3 −3
@@ -3,8 +3,8 @@
33 * code, commits, branches, runs. Studio makes apps the unit: a person tells
44 * the crew, looks at what it made, and decides when it asks. The choice is
55 * 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.
88 */
99
1010 export type Mode = "studio" | "dev";
@@ -12,7 +12,7 @@ export type Mode = "studio" | "dev";
1212 export const isMode = (value: unknown): value is Mode =>
1313 value === "studio" || value === "dev";
1414
15 export const DEFAULT_MODE: Mode = "dev";
15 export const DEFAULT_MODE: Mode = "studio";
1616
1717 export function modeCookie(mode: Mode | undefined, secure: boolean): string {
1818 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> {
472472 const { repo, issue } = found;
473473 // In the studio a tell is one sentence someone said, not dev's labels and
474474 // 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";
479478 if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined)
480479 return await studioTell(ctx, repo, issue);
481480 const base = repoBase(repo);
src/web/pages/pull.ts +3 −4
@@ -668,10 +668,9 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
668668 if (found instanceof Response) return found;
669669 const { repo, pull } = found;
670670 // 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";
675674 if (studio) {
676675 const record = getAppByRepo(ctx.app.db, repo.id);
677676 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> {
341341 const { run, lines, truncated } = found.value;
342342 // In the studio, working is a run: who is on it, the tell it came from,
343343 // 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";
348347 if (studio && getAppByRepo(ctx.app.db, repo.id) !== undefined)
349348 return await studioRun(ctx, repo, run, lines, truncated);
350349 const base = repoBase(repo);
src/web/pages/site.ts +3 −4
@@ -772,10 +772,9 @@ function dashboard(ctx: Ctx, user: User): Html {
772772 export async function home(ctx: Ctx): Promise<Response> {
773773 const user = ctx.auth.user;
774774 // 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";
779778 if (user !== undefined && studio) return await studioHome(ctx, user);
780779 return page({
781780 ctx,
test/studio.test.ts +37 −15
@@ -140,24 +140,46 @@ describe("the studio", () => {
140140 await rm(dataDir, { recursive: true, force: true });
141141 });
142142
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 })
146167 ).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]!;
157174 const asDev = await (
158 await get("/", { cookie: `${cookie}; forge_mode=dev` })
175 await fetch(`${base}/`, { headers: { cookie: `${cookie}; ${devCookie}` } })
159176 ).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");
161183 const bad = await post("/mode", { mode: "neither", back: "/" });
162184 expect(bad.headers.get("set-cookie")).toContain("forge_mode=;");
163185 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs