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

A stranger's front door is the studio: the apps, as they look #46

Merged opened this

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

Merged

scout/forge:scout/42-a-stranger-s-front-door-is-the-studio-th → main 1 commit fork

  • Merged. scout/42-a-stranger-s-front-door-is-the-studio-th was merged into main as 2397ede.

Review

Sign in to review this change.
4 files changed +252 −10
docs/STUDIO.md +8
@@ -91,6 +91,14 @@ whether it is live, what the crew last did there, and a way in. **New app**
9191 is the same flow as ever. `/?mode=dev` is the repositories list without
9292 switching the browser.
9393
94 Signed out, `/` in the studio is a stranger's front door, not the dev
95 pitch: a shelf of the public apps actually serving — name, one line of
96 description, live state, and production's own picture, linking straight to
97 the app. Sign in and Create an account stay one click away; `/?mode=dev`
98 still renders the dev landing unchanged. A forge with no public apps yet —
99 most people's first visit — falls back to that same dev landing rather
100 than showing an empty shelf.
101
94102 ## What the studio never does
95103
96104 It never says _shipped_ before production redeployed, never hides that a
src/web/pages/site.ts +112 −9
@@ -10,6 +10,8 @@
1010
1111 import { createRepo } from "../../actions/repos.ts";
1212 import { signUp } from "../../actions/users.ts";
13 import { appsRuntime } from "../../apps/runtime.ts";
14 import { appsForRepos } from "../../core/apps.ts";
1315 import { listEventsVisibleTo, type ForgeEvent } from "../../core/events.ts";
1416 import {
1517 getIssuesByRef,
@@ -17,6 +19,7 @@ import {
1719 listOpenAgentPullsVisibleTo,
1820 type Issue,
1921 } from "../../core/issues.ts";
22 import { LOOK_SIZES, looksForApps } from "../../core/looks.ts";
2023 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2124 import { listPins, pinnedRepoIds } from "../../core/pins.ts";
2225 import {
@@ -39,10 +42,17 @@ import { crossOrigin, type Ctx } from "../../http/context.ts";
3942 import { studioHome } from "./studio.ts";
4043 import { edgeLoginUrl } from "../../http/edge.ts";
4144 import { sshCommand } from "../../ssh/command.ts";
42 import { hostOf, notFoundPage, pageHead, signInFirst } from "../chrome.ts";
45 import {
46 hostOf,
47 notFoundPage,
48 pageHead,
49 repoBase,
50 signInFirst,
51 } from "../chrome.ts";
4352 import { field, readForm } from "../form.ts";
4453 import { html, when, type Html } from "../html.ts";
4554 import { icon, type IconName } from "../icons.ts";
55 import { lookUrl } from "./looks.ts";
4656 import {
4757 eventActor,
4858 eventVerb,
@@ -54,6 +64,7 @@ import {
5464 btn,
5565 card,
5666 empty,
67 envStateBadge,
5768 field as formField,
5869 iconBtn,
5970 listCard,
@@ -509,6 +520,96 @@ function orgsCard(ctx: Ctx, user: User): Html {
509520 );
510521 }
511522
523 /**
524 * The studio's front door for a stranger: the public apps actually
525 * serving, as a shelf of pictures, name and one clamped line — not the dev
526 * pitch, which a signed-out reader reaches through `?mode=dev` (unchanged,
527 * `landing` below) exactly as a signed-in one reaches it through the mode
528 * switch. A forge with nothing public up yet — most people's first visit —
529 * falls back to that same dev landing rather than showing an empty shelf.
530 *
531 * Two grouped reads carry the whole shelf (the apps, then their looks), and
532 * a repository's runtime state is a map lookup (`AppsRuntime.status`), not
533 * a process call — so this never gets slower as the forge grows apps, the
534 * one page anonymous traffic actually hits.
535 */
536 function studioLanding(ctx: Ctx): Html {
537 const db = ctx.app.db;
538 const publicRepos = listReposVisibleTo(db, undefined, { limit: 24 });
539 const records = appsForRepos(
540 db,
541 publicRepos.map((repo) => repo.id),
542 );
543 const appRepos = publicRepos.filter((repo) => records.has(repo.id));
544 if (appRepos.length === 0) return landing(ctx);
545
546 const runtime = appsRuntime(ctx.app);
547 const looks = looksForApps(
548 db,
549 appRepos.map((repo) => records.get(repo.id)!.id),
550 );
551 const tiles = appRepos.map((repo) => {
552 const record = records.get(repo.id)!;
553 const production = (
554 runtime === undefined ? [] : runtime.status(record)
555 ).find((env) => env.env === "production");
556 const look = looks.find(
557 (l) =>
558 l.appId === record.id &&
559 l.env === "production" &&
560 l.kind === "desktop" &&
561 l.error === null,
562 );
563 const base = repoBase(repo);
564 return html`<a class="app-tile" href="${base}">
565 ${look
566 ? html`<img
567 class="app-tile-look"
568 src="${lookUrl(base, "production", look.commitOid, "desktop")}"
569 width="${LOOK_SIZES.desktop.width}"
570 height="${LOOK_SIZES.desktop.height}"
571 alt=""
572 loading="lazy"
573 />`
574 : html`<span class="app-tile-blank" aria-hidden="true"
575 >${icon("rocket")}</span
576 >`}
577 <span class="app-tile-body">
578 <span class="app-tile-name">${repo.name}</span>
579 ${repo.description !== ""
580 ? html`<span class="app-tile-desc truncate"
581 >${repo.description}</span
582 >`
583 : null}
584 ${envStateBadge(production?.state ?? "stopped")}
585 </span>
586 </a>`;
587 });
588
589 return html`<section class="hero">
590 <h1>Apps, live.</h1>
591 <p>
592 A software forge that builds and serves its own apps — these are
593 running here right now.
594 </p>
595 <div class="bar">
596 ${ctx.app.config.edgeToken === undefined
597 ? html`${btn("Create an account", {
598 href: "/signup",
599 variant: "primary",
600 size: "lg",
601 })}
602 ${btn("Sign in", { href: "/signin", size: "lg" })}`
603 : btn("Sign in", {
604 href: edgeLoginUrl("/"),
605 variant: "primary",
606 size: "lg",
607 })}
608 </div>
609 </section>
610 <div class="mt app-shelf">${tiles}</div>`;
611 }
612
512613 function landing(ctx: Ctx): Html {
513614 const publicRepos = listReposVisibleTo(ctx.app.db, undefined, { limit: 8 });
514615 return html`<section class="hero">
@@ -671,17 +772,19 @@ function dashboard(ctx: Ctx, user: User): Html {
671772 export async function home(ctx: Ctx): Promise<Response> {
672773 const user = ctx.auth.user;
673774 // Studio by default: apps, the crew, what asks you. `?mode=dev` on the
674 // home is the one-off way in without switching the browser.
675 if (
676 user !== undefined &&
677 ctx.mode === "studio" &&
678 ctx.url.searchParams.get("mode") !== "dev"
679 )
680 return await studioHome(ctx, user);
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";
779 if (user !== undefined && studio) return await studioHome(ctx, user);
681780 return page({
682781 ctx,
683782 title: "forge",
684 body: user ? dashboard(ctx, user) : landing(ctx),
783 body: user
784 ? dashboard(ctx, user)
785 : studio
786 ? studioLanding(ctx)
787 : landing(ctx),
685788 ...(user === undefined ? {} : { scriptJs: liveClientJs }),
686789 });
687790 }
src/web/style/content.ts +19
@@ -269,6 +269,25 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
269269 .hero-points .card-body { font-size: 13px; color: var(--muted-fg); }
270270 .hero-points strong { display: block; color: var(--fg); font-size: 14px; margin-bottom: 3px; }
271271
272 /* The studio's front door: the public apps that are serving, as pictures. */
273 .app-shelf {
274 display: grid; gap: 16px; max-width: 1040px; margin-inline: auto;
275 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
276 }
277 .app-tile {
278 display: block; border: 1px solid var(--border); border-radius: var(--radius);
279 overflow: hidden; background: var(--card); color: inherit; text-decoration: none;
280 }
281 .app-tile:hover { border-color: var(--accent); }
282 .app-tile-look { display: block; width: 100%; height: auto; background: var(--muted); }
283 .app-tile-blank {
284 display: flex; align-items: center; justify-content: center;
285 aspect-ratio: 1280 / 800; background: var(--muted); color: var(--muted-fg);
286 }
287 .app-tile-body { display: grid; gap: 6px; padding: 10px 12px; }
288 .app-tile-name { font-weight: 600; font-size: 14px; }
289 .app-tile-desc { color: var(--muted-fg); font-size: 12.5px; }
290
272291 /* Every page grid, in one rule: a grid item's automatic minimum size is its
273292 *content*, so a column with a long unbreakable line pushes the page wider
274293 than the phone rather than letting its own ellipsis do the work. A zero
test/studio.test.ts +113 −1
@@ -3,15 +3,18 @@
33 * URLs answer in both modes; the cookie decides which face they show.
44 */
55 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdirSync, writeFileSync } from "node:fs";
67 import { mkdtemp, rm } from "node:fs/promises";
78 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { dirname, join } from "node:path";
910 import { createApp, type App } from "../src/app.ts";
1011 import { enableApp } from "../src/actions/apps.ts";
1112 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1213 import { openPull } from "../src/actions/pulls.ts";
1314 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { getAppByRepo } from "../src/core/apps.ts";
1416 import { getIssue, listIssues } from "../src/core/issues.ts";
17 import { recordLook } from "../src/core/looks.ts";
1518 import { getPull, type Pull } from "../src/core/pulls.ts";
1619 import type { Repo } from "../src/core/repos.ts";
1720 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
@@ -21,6 +24,12 @@ import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
2124 import { serve, type Servers } from "../src/http/server.ts";
2225 import { pendingLeans } from "../src/web/pages/studio.ts";
2326
27 /** One transparent pixel, dropped where a look would sit. */
28 const PNG = Buffer.from(
29 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
30 "base64",
31 );
32
2433 describe("the studio", () => {
2534 test("the asks script posts to the form's action attribute: a control named action shadows form.action", async () => {
2635 const { asksJs } = await import("../src/web/client.ts");
@@ -376,6 +385,109 @@ describe("the studio", () => {
376385 expect(after).not.toContain('class="studio-ask-pick"');
377386 expect(after).not.toContain('action="/studio/asks"');
378387 });
388
389 test("signed out in studio, / leads with the public apps that are serving — their look, not a private one's", async () => {
390 // A second app, public, with a picture of its production. "notes" stays
391 // private, so its presence (or absence) is the negative case below.
392 const madePublic = await createRepo(app, alice, {
393 name: "beacon",
394 visibility: "public",
395 description: "a lighthouse in the fog",
396 });
397 if (!madePublic.ok) throw new Error(String(madePublic.error));
398 const beacon = madePublic.value;
399 const seededPublic = await commitToBranch(app, alice, beacon, {
400 message: "Start",
401 files: [
402 {
403 path: "package.json",
404 content: '{"name":"beacon","scripts":{"start":"bun run server.ts"}}\n',
405 },
406 {
407 path: "server.ts",
408 content:
409 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
410 },
411 ],
412 });
413 if (!seededPublic.ok) throw new Error(JSON.stringify(seededPublic.error));
414 const enabledPublic = await enableApp(app, alice, beacon, {
415 slug: "beacon",
416 });
417 if (!enabledPublic.ok) throw new Error(String(enabledPublic.error));
418
419 // A look for each app's production, so the picture route has something
420 // real to answer for and refuse.
421 const dropLook = (appId: string, env: "production", commit: string) => {
422 const rel = join("looks", appId, `${env}-desktop.png`);
423 const abs = join(app.config.dataDir, rel);
424 mkdirSync(dirname(abs), { recursive: true });
425 writeFileSync(abs, PNG);
426 return recordLook(app.db, {
427 appId,
428 env,
429 commitOid: commit,
430 kind: "desktop",
431 path: rel,
432 });
433 };
434 const beaconApp = getAppByRepo(app.db, beacon.id)!;
435 const notesApp = getAppByRepo(app.db, repo.id)!;
436 dropLook(beaconApp.id, "production", seededPublic.value.commitOid);
437 dropLook(notesApp.id, "production", "0".repeat(40));
438
439 // Signed out, with the studio cookie: the public app's name, its look,
440 // and Sign in / Create an account — never the private one.
441 const anon = await (
442 await fetch(`${base}/`, { headers: { cookie: "forge_mode=studio" } })
443 ).text();
444 expect(anon).toContain("beacon");
445 expect(anon).toContain("a lighthouse in the fog");
446 expect(anon).toContain(`/looks/production/${seededPublic.value.commitOid}/desktop.png`);
447 expect(anon).toContain('href="/signin"');
448 expect(anon).toContain('href="/signup"');
449 expect(anon).not.toContain(">notes<");
450 expect(anon).not.toContain("Your code, your machine");
451
452 // The private app's picture 404s for a stranger — asserted, not assumed.
453 const privateLook = await fetch(
454 `${base}/alice/notes/looks/production/${"0".repeat(40)}/desktop.png`,
455 { headers: { cookie: "forge_mode=studio" } },
456 );
457 expect(privateLook.status).toBe(404);
458 // The public one's does not.
459 const publicLook = await fetch(
460 `${base}/alice/beacon/looks/production/${seededPublic.value.commitOid}/desktop.png`,
461 { headers: { cookie: "forge_mode=studio" } },
462 );
463 expect(publicLook.status).toBe(200);
464
465 // `?mode=dev` still renders the dev landing, unchanged, for a stranger.
466 const dev = await (
467 await fetch(`${base}/?mode=dev`, {
468 headers: { cookie: "forge_mode=studio" },
469 })
470 ).text();
471 expect(dev).toContain("Your code, your machine");
472 });
473
474 test("a forge with no public apps falls back to the dev landing, signed out in studio", async () => {
475 const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-empty-"));
476 const app2 = createApp({ dataDir: dataDir2, port: 0 });
477 const servers2 = await serve(app2);
478 try {
479 const body = await (
480 await fetch(`${servers2.url}/`, {
481 headers: { cookie: "forge_mode=studio" },
482 })
483 ).text();
484 expect(body).toContain("Your code, your machine");
485 } finally {
486 servers2.stop();
487 app2.close();
488 await rm(dataDir2, { recursive: true, force: true });
489 }
490 });
379491 });
380492
381493 describe("the studio home does not wait on the decider", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs