Public: anyone can read this; the crew can change it.
Contraction: fold landing/site into the studio home's empty state #436
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/396-contraction-fold-landing-site-into-the-s → main 2 commits fork
8 files
changed +285
−207
docs/STUDIO.md +11 −8
| @@ -354,14 +354,17 @@ everything the viewer may see) and works like this: | ||
| 354 | 354 | Run logs and the crew's transcript stream line by line over their own |
| 355 | 355 | `?follow=1` answers; they are the streamed part of a page, not a region. |
| 356 | 356 | |
| 357 | Signed out, `/` in the studio is a stranger's front door, not the dev | |
| 358 | pitch: a shelf of the public apps actually serving — name, one line of | |
| 359 | description, live state, and production's own picture, linking straight to | |
| 360 | the app. Sign in and Create an account stay one click away; `/?mode=dev` | |
| 361 | still renders the dev landing, and switches this browser to dev, same as | |
| 362 | the toggle. A forge with no public apps yet — | |
| 363 | most people's first visit — falls back to that same dev landing rather | |
| 364 | than showing an empty shelf. | |
| 357 | Signed out, `/` is a stranger's front door in either mode — the studio's | |
| 358 | door, never a git pitch (`studioDoor` in `studio/home.ts`). It is a shelf of | |
| 359 | the public apps a signed-out reader can see, the live ones first — name, one | |
| 360 | line of description, live state, and production's own picture, linking | |
| 361 | straight to the app — read from the apps themselves (`listPublicApps`), so | |
| 362 | no number of plain repositories crowds an app off it. Sign in and Create an | |
| 363 | account stay one click away, with the forge's ssh command beneath them when | |
| 364 | the terminal is on. A forge with nothing public up yet — most people's first | |
| 365 | visit — shows the same door with an empty shelf that says so. `/?mode=dev` | |
| 366 | answers a stranger with the same door; the repositories are a signed-in | |
| 367 | person's dev home. | |
| 365 | 368 | |
| 366 | 369 | ## The board |
| 367 | 370 |
src/core/apps.ts +16
| @@ -139,6 +139,22 @@ export function listApps(db: Database): AppRecord[] { | ||
| 139 | 139 | return db.query<AppRow, []>("SELECT * FROM app ORDER BY id").all().map(toApp); |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | /** | |
| 143 | * The apps whose repository is public — what any reader, signed in or not, | |
| 144 | * may see — newest first, at most `limit`. One query over the apps | |
| 145 | * themselves, so a forge with many plain repositories never crowds an app | |
| 146 | * out of the window. | |
| 147 | */ | |
| 148 | export function listPublicApps(db: Database, limit: number): AppRecord[] { | |
| 149 | return db | |
| 150 | .query<AppRow, [number]>( | |
| 151 | `SELECT app.* FROM app JOIN repo ON repo.id = app.repo_id | |
| 152 | WHERE repo.visibility = 'public' ORDER BY app.id DESC LIMIT ?1`, | |
| 153 | ) | |
| 154 | .all(limit) | |
| 155 | .map(toApp); | |
| 156 | } | |
| 157 | ||
| 142 | 158 | /** The apps among these repositories, keyed by repo id — one query. */ |
| 143 | 159 | export function appsForRepos( |
| 144 | 160 | db: Database, |
src/web/pages/site.ts +9 −171
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The site pages: the landing page and the signed-in dashboard, sign in and | |
| 2 | * The site pages: the home's dispatch and the signed-in dashboard, sign in and | |
| 3 | 3 | * sign up, creating a repository, search, and the confirmation step every |
| 4 | 4 | * destructive action passes through. Plain forms, zero JavaScript required, |
| 5 | 5 | * fast everywhere — a phone in the redwoods is the reference device. |
| @@ -10,8 +10,6 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { createRepo } from "../../actions/repos.ts"; |
| 12 | 12 | import { signUp } from "../../actions/users.ts"; |
| 13 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 14 | import { appsForRepos } from "../../core/apps.ts"; | |
| 15 | 13 | import { listEventsVisibleTo, type ForgeEvent } from "../../core/events.ts"; |
| 16 | 14 | import { |
| 17 | 15 | getIssuesByRef, |
| @@ -19,7 +17,6 @@ import { | ||
| 19 | 17 | listOpenAgentPullsVisibleTo, |
| 20 | 18 | type Issue, |
| 21 | 19 | } from "../../core/issues.ts"; |
| 22 | import { DEFAULT_PAGE, LOOK_SIZES, looksForApps } from "../../core/looks.ts"; | |
| 23 | 20 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 24 | 21 | import { listPins, pinnedRepoIds } from "../../core/pins.ts"; |
| 25 | 22 | import { |
| @@ -40,21 +37,19 @@ import { | ||
| 40 | 37 | import { isThemeName } from "../../design/themes.ts"; |
| 41 | 38 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 42 | 39 | import { askCountFor, askSentence } from "../studio/asks.ts"; |
| 43 | import { studioHome } from "../studio/home.ts"; | |
| 40 | import { studioDoor, studioHome } from "../studio/home.ts"; | |
| 44 | 41 | import { edgeLoginUrl } from "../../http/edge.ts"; |
| 45 | 42 | import { sshCommand } from "../../ssh/command.ts"; |
| 46 | 43 | import { |
| 47 | 44 | hostOf, |
| 48 | 45 | notFoundPage, |
| 49 | 46 | pageHead, |
| 50 | repoBase, | |
| 51 | 47 | signInFirst, |
| 52 | 48 | } from "../chrome.ts"; |
| 53 | 49 | import { RateLimiter } from "../../core/ratelimit.ts"; |
| 54 | 50 | import { field, readForm } from "../form.ts"; |
| 55 | 51 | import { html, when, type Html } from "../html.ts"; |
| 56 | 52 | import { icon, type IconName } from "../icons.ts"; |
| 57 | import { lookUrl, pictured } from "./looks.ts"; | |
| 58 | 53 | import { |
| 59 | 54 | eventActor, |
| 60 | 55 | eventVerb, |
| @@ -66,7 +61,6 @@ import { | ||
| 66 | 61 | btn, |
| 67 | 62 | card, |
| 68 | 63 | empty, |
| 69 | envStateBadge, | |
| 70 | 64 | field as formField, |
| 71 | 65 | iconBtn, |
| 72 | 66 | listCard, |
| @@ -485,158 +479,6 @@ function orgsCard(ctx: Ctx, user: User): Html { | ||
| 485 | 479 | ); |
| 486 | 480 | } |
| 487 | 481 | |
| 488 | /** | |
| 489 | * The studio's front door for a stranger: the public apps actually | |
| 490 | * serving, as a shelf of pictures, name and one clamped line — not the dev | |
| 491 | * pitch, which a signed-out reader reaches through `?mode=dev` (unchanged, | |
| 492 | * `landing` below) exactly as a signed-in one reaches it through the mode | |
| 493 | * switch. A forge with nothing public up yet — most people's first visit — | |
| 494 | * falls back to that same dev landing rather than showing an empty shelf. | |
| 495 | * | |
| 496 | * Two grouped reads carry the whole shelf (the apps, then their looks), and | |
| 497 | * a repository's runtime state is a map lookup (`AppsRuntime.status`), not | |
| 498 | * a process call — so this never gets slower as the forge grows apps, the | |
| 499 | * one page anonymous traffic actually hits. | |
| 500 | */ | |
| 501 | function studioLanding(ctx: Ctx): Html { | |
| 502 | const db = ctx.app.db; | |
| 503 | const publicRepos = listReposVisibleTo(db, undefined, { limit: 24 }); | |
| 504 | const records = appsForRepos( | |
| 505 | db, | |
| 506 | publicRepos.map((repo) => repo.id), | |
| 507 | ); | |
| 508 | const appRepos = publicRepos.filter((repo) => records.has(repo.id)); | |
| 509 | if (appRepos.length === 0) return landing(ctx); | |
| 510 | ||
| 511 | const runtime = appsRuntime(ctx.app); | |
| 512 | const looks = looksForApps( | |
| 513 | db, | |
| 514 | appRepos.map((repo) => records.get(repo.id)!.id), | |
| 515 | ); | |
| 516 | const tiles = appRepos.map((repo) => { | |
| 517 | const record = records.get(repo.id)!; | |
| 518 | const production = ( | |
| 519 | runtime === undefined ? [] : runtime.status(record) | |
| 520 | ).find((env) => env.env === "production"); | |
| 521 | const ofApp = looks.filter( | |
| 522 | (l) => | |
| 523 | l.appId === record.id && | |
| 524 | l.env === "production" && | |
| 525 | l.page === DEFAULT_PAGE, | |
| 526 | ); | |
| 527 | const look = pictured(ofApp, "thumb") ?? pictured(ofApp, "desktop"); | |
| 528 | const base = repoBase(repo); | |
| 529 | return html`<a class="app-tile" href="${base}"> | |
| 530 | ${look | |
| 531 | ? html`<img | |
| 532 | class="app-tile-look" | |
| 533 | src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, look.kind)}" | |
| 534 | width="${LOOK_SIZES[look.kind].width}" | |
| 535 | height="${LOOK_SIZES[look.kind].height}" | |
| 536 | alt="" | |
| 537 | loading="lazy" | |
| 538 | />` | |
| 539 | : html`<span class="app-tile-blank" aria-hidden="true" | |
| 540 | >${icon("rocket")}</span | |
| 541 | >`} | |
| 542 | <span class="app-tile-body"> | |
| 543 | <span class="app-tile-name">${repo.name}</span> | |
| 544 | ${repo.description !== "" | |
| 545 | ? html`<span class="app-tile-desc truncate" | |
| 546 | >${repo.description}</span | |
| 547 | >` | |
| 548 | : null} | |
| 549 | ${envStateBadge(production?.state ?? "stopped")} | |
| 550 | </span> | |
| 551 | </a>`; | |
| 552 | }); | |
| 553 | ||
| 554 | return html`<section class="hero"> | |
| 555 | <h1>Apps, live.</h1> | |
| 556 | <p> | |
| 557 | A software forge that builds and serves its own apps — these are | |
| 558 | running here right now. | |
| 559 | </p> | |
| 560 | <div class="bar"> | |
| 561 | ${ctx.app.config.edgeToken === undefined | |
| 562 | ? html`${btn("Create an account", { | |
| 563 | href: "/signup", | |
| 564 | variant: "primary", | |
| 565 | size: "lg", | |
| 566 | })} | |
| 567 | ${btn("Sign in", { href: "/signin", size: "lg" })}` | |
| 568 | : btn("Sign in", { | |
| 569 | href: edgeLoginUrl("/"), | |
| 570 | variant: "primary", | |
| 571 | size: "lg", | |
| 572 | })} | |
| 573 | </div> | |
| 574 | </section> | |
| 575 | <div class="mt app-shelf">${tiles}</div>`; | |
| 576 | } | |
| 577 | ||
| 578 | function landing(ctx: Ctx): Html { | |
| 579 | const publicRepos = listReposVisibleTo(ctx.app.db, undefined, { limit: 8 }); | |
| 580 | return html`<section class="hero"> | |
| 581 | <h1>Your code, your machine.</h1> | |
| 582 | <p> | |
| 583 | A software forge you run yourself: real git over HTTPS, issues, pull | |
| 584 | requests with review, webhooks and a JSON API — in one process, with | |
| 585 | zero runtime dependencies. | |
| 586 | </p> | |
| 587 | <div class="bar"> | |
| 588 | ${ctx.app.config.edgeToken === undefined | |
| 589 | ? html`${btn("Create an account", { | |
| 590 | href: "/signup", | |
| 591 | variant: "primary", | |
| 592 | size: "lg", | |
| 593 | })} | |
| 594 | ${btn("Sign in", { href: "/signin", size: "lg" })}` | |
| 595 | : btn("Sign in", { | |
| 596 | href: edgeLoginUrl("/"), | |
| 597 | variant: "primary", | |
| 598 | size: "lg", | |
| 599 | })} | |
| 600 | </div> | |
| 601 | ${when( | |
| 602 | sshCommand(ctx.app), | |
| 603 | (ssh) => | |
| 604 | html`<p class="hero-terminal"> | |
| 605 | <code class="mono">${ssh}</code> | |
| 606 | <span class="muted" | |
| 607 | >the whole forge in your terminal — your key is your | |
| 608 | identity</span | |
| 609 | > | |
| 610 | </p>`, | |
| 611 | )} | |
| 612 | <div class="hero-points"> | |
| 613 | ${card( | |
| 614 | html`<strong>Real git, and a real terminal</strong>Clone, fetch and | |
| 615 | push over HTTPS or SSH; the same port opens the forge as a | |
| 616 | keyboard-driven app. Branch protection is enforced by the | |
| 617 | pre-receive hook, not by convention.`, | |
| 618 | )} | |
| 619 | ${card( | |
| 620 | html`<strong>Issues and review</strong>Threads, labels, and pull | |
| 621 | requests with word-level diffs and comments anchored to a line.`, | |
| 622 | )} | |
| 623 | ${card( | |
| 624 | html`<strong>Built for automation</strong>Every mutation emits a | |
| 625 | durable event. Webhooks are signed and retried; the API mirrors the | |
| 626 | web.`, | |
| 627 | )} | |
| 628 | </div> | |
| 629 | </section> | |
| 630 | ${when( | |
| 631 | publicRepos.length > 0, | |
| 632 | () => | |
| 633 | html`<div class="mt"> | |
| 634 | <h2 class="mb">Public repositories</h2> | |
| 635 | ${listCard(publicRepos.map((repo) => repoRow(repo)))} | |
| 636 | </div>`, | |
| 637 | )}`; | |
| 638 | } | |
| 639 | ||
| 640 | 482 | /** The terminal is a front door, not a footnote: the command, on the dashboard. */ |
| 641 | 483 | function terminalCard(ctx: Ctx): Html { |
| 642 | 484 | const ssh = sshCommand(ctx.app); |
| @@ -736,20 +578,16 @@ function dashboard(ctx: Ctx, user: User): Html { | ||
| 736 | 578 | |
| 737 | 579 | export async function home(ctx: Ctx): Promise<Response> { |
| 738 | 580 | const user = ctx.auth.user; |
| 739 | // Studio by default: apps, the crew, what asks you. `?mode=dev` on the | |
| 740 | // home is the escape hatch — for a stranger as much as for someone | |
| 741 | // signed in — resolved once for `ctx.mode`. | |
| 742 | const studio = ctx.mode === "studio"; | |
| 743 | if (user !== undefined && studio) return await studioHome(ctx, user); | |
| 581 | // A stranger gets the studio's door in either mode. Signed in, studio is | |
| 582 | // the default: apps, the crew, what asks you; `?mode=dev` on the home is | |
| 583 | // the escape hatch to the repositories, resolved once for `ctx.mode`. | |
| 584 | if (user === undefined) return studioDoor(ctx); | |
| 585 | if (ctx.mode === "studio") return await studioHome(ctx, user); | |
| 744 | 586 | return page({ |
| 745 | 587 | ctx, |
| 746 | 588 | title: "forge", |
| 747 | body: user | |
| 748 | ? dashboard(ctx, user) | |
| 749 | : studio | |
| 750 | ? studioLanding(ctx) | |
| 751 | : landing(ctx), | |
| 752 | ...(user === undefined ? {} : { scriptJs: liveClientJs }), | |
| 589 | body: dashboard(ctx, user), | |
| 590 | scriptJs: liveClientJs, | |
| 753 | 591 | }); |
| 754 | 592 | } |
| 755 | 593 |
src/web/studio/home.ts +134 −9
| @@ -4,31 +4,32 @@ | ||
| 4 | 4 | * beside it what is happening right now across all of them, and what a |
| 5 | 5 | * person put on you. Built from the same rows and cards as the rest of the |
| 6 | 6 | * forge, from grouped reads only, so the page does not get slower as the |
| 7 | * apps grow. | |
| 7 | * apps grow. A stranger gets the door instead: the public apps, live first. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appsRuntime, type EnvState } from "../../apps/runtime.ts"; | |
| 10 | 11 | import { crewForRepos } from "../../core/agents.ts"; |
| 11 | import type { Repo } from "../../core/repos.ts"; | |
| 12 | import { listPublicApps } from "../../core/apps.ts"; | |
| 13 | import { DEFAULT_PAGE, LOOK_SIZES, looksForApps } from "../../core/looks.ts"; | |
| 14 | import { getReposByIds, type Repo } from "../../core/repos.ts"; | |
| 12 | 15 | import { type User } from "../../core/users.ts"; |
| 13 | 16 | import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts"; |
| 14 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 18 | import { edgeLoginUrl } from "../../http/edge.ts"; | |
| 19 | import { sshCommand } from "../../ssh/command.ts"; | |
| 20 | import { repoBase } from "../chrome.ts"; | |
| 15 | 21 | import { liveRegionsJs } from "../client.ts"; |
| 16 | 22 | import { html, when, type Html } from "../html.ts"; |
| 17 | 23 | import { fragment, page } from "../layout.ts"; |
| 18 | 24 | import { liveClientJs } from "../live.ts"; |
| 19 | import { btn, empty, icon, listCard, row } from "../ui.ts"; | |
| 25 | import { btn, empty, envStateBadge, icon, listCard } from "../ui.ts"; | |
| 20 | 26 | import { onYouCard } from "../pages/site.ts"; |
| 27 | import { lookUrl, pictured } from "../pages/looks.ts"; | |
| 21 | 28 | import { writableAppsOf } from "./asks.ts"; |
| 22 | 29 | import { productionFactsForRepos, shelfRows } from "./shelf.ts"; |
| 23 | 30 | import { servingHistory } from "./words.ts"; |
| 24 | 31 | import { feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 25 | 32 | |
| 26 | /** | |
| 27 | * The home in the studio: your apps as a shelf of | |
| 28 | * rows — and beside it what is happening right now across all of them, and | |
| 29 | * what a person put on you. Built from the same rows and cards as the rest | |
| 30 | * of the forge, so it reads as the forge. | |
| 31 | */ | |
| 32 | 33 | /** The events that can change a row on the shelf. */ |
| 33 | 34 | const APPS_LIVE = |
| 34 | 35 | "app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged"; |
| @@ -163,3 +164,127 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 163 | 164 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}`, |
| 164 | 165 | }); |
| 165 | 166 | } |
| 167 | ||
| 168 | /** How many public apps the door reads, and how many tiles it shows. */ | |
| 169 | const DOOR_READ = 200; | |
| 170 | const DOOR_TILES = 24; | |
| 171 | ||
| 172 | /** Live first: what answers now, then what wakes on a visit, then the rest. */ | |
| 173 | const LIVENESS: Record<EnvState, number> = { | |
| 174 | running: 0, | |
| 175 | asleep: 1, | |
| 176 | deploying: 2, | |
| 177 | failed: 3, | |
| 178 | stopped: 4, | |
| 179 | }; | |
| 180 | ||
| 181 | /** | |
| 182 | * The studio's door for a stranger: the public apps as a shelf of | |
| 183 | * pictures, name and one clamped line, live first — and when nothing | |
| 184 | * public is up yet, an empty shelf that says so, never a git pitch. A | |
| 185 | * signed-out reader gets this whatever the mode: dev has no home of its | |
| 186 | * own for someone with nothing to show them. | |
| 187 | * | |
| 188 | * Three grouped reads carry the whole shelf (the apps, their repositories, | |
| 189 | * their looks), and a repository's runtime state is a map lookup | |
| 190 | * (`AppsRuntime.status`), not a process call — so this never gets slower | |
| 191 | * as the forge grows apps, the one page anonymous traffic actually hits. | |
| 192 | */ | |
| 193 | export function studioDoor(ctx: Ctx): Response { | |
| 194 | const db = ctx.app.db; | |
| 195 | const records = listPublicApps(db, DOOR_READ); | |
| 196 | const repos = getReposByIds( | |
| 197 | db, | |
| 198 | records.map((r) => r.repoId), | |
| 199 | ); | |
| 200 | const runtime = appsRuntime(ctx.app); | |
| 201 | const apps = records.flatMap((record) => { | |
| 202 | const repo = repos.get(record.repoId); | |
| 203 | if (repo === undefined) return []; | |
| 204 | const production = ( | |
| 205 | runtime === undefined ? [] : runtime.status(record) | |
| 206 | ).find((env) => env.env === "production"); | |
| 207 | return [{ record, repo, state: production?.state ?? "stopped" }]; | |
| 208 | }); | |
| 209 | // `sort` is stable: newest first within each liveness. | |
| 210 | const shown = apps | |
| 211 | .sort((a, b) => LIVENESS[a.state] - LIVENESS[b.state]) | |
| 212 | .slice(0, DOOR_TILES); | |
| 213 | const looks = looksForApps( | |
| 214 | db, | |
| 215 | shown.map((a) => a.record.id), | |
| 216 | ); | |
| 217 | const tiles = shown.map(({ record, repo, state }) => { | |
| 218 | const ofApp = looks.filter( | |
| 219 | (l) => | |
| 220 | l.appId === record.id && | |
| 221 | l.env === "production" && | |
| 222 | l.page === DEFAULT_PAGE, | |
| 223 | ); | |
| 224 | const look = pictured(ofApp, "thumb") ?? pictured(ofApp, "desktop"); | |
| 225 | const base = repoBase(repo); | |
| 226 | return html`<a class="app-tile" href="${base}"> | |
| 227 | ${look | |
| 228 | ? html`<img | |
| 229 | class="app-tile-look" | |
| 230 | src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, look.kind)}" | |
| 231 | width="${LOOK_SIZES[look.kind].width}" | |
| 232 | height="${LOOK_SIZES[look.kind].height}" | |
| 233 | alt="" | |
| 234 | loading="lazy" | |
| 235 | />` | |
| 236 | : html`<span class="app-tile-blank" aria-hidden="true" | |
| 237 | >${icon("rocket")}</span | |
| 238 | >`} | |
| 239 | <span class="app-tile-body"> | |
| 240 | <span class="app-tile-name">${repo.name}</span> | |
| 241 | ${repo.description !== "" | |
| 242 | ? html`<span class="app-tile-desc truncate" | |
| 243 | >${repo.description}</span | |
| 244 | >` | |
| 245 | : null} | |
| 246 | ${envStateBadge(state)} | |
| 247 | </span> | |
| 248 | </a>`; | |
| 249 | }); | |
| 250 | ||
| 251 | const body = html`<section class="hero"> | |
| 252 | <h1>Apps, live.</h1> | |
| 253 | <p>A software forge that builds and serves its own apps.</p> | |
| 254 | <div class="bar"> | |
| 255 | ${ctx.app.config.edgeToken === undefined | |
| 256 | ? html`${btn("Create an account", { | |
| 257 | href: "/signup", | |
| 258 | variant: "primary", | |
| 259 | size: "lg", | |
| 260 | })} | |
| 261 | ${btn("Sign in", { href: "/signin", size: "lg" })}` | |
| 262 | : btn("Sign in", { | |
| 263 | href: edgeLoginUrl("/"), | |
| 264 | variant: "primary", | |
| 265 | size: "lg", | |
| 266 | })} | |
| 267 | </div> | |
| 268 | ${when( | |
| 269 | sshCommand(ctx.app), | |
| 270 | (ssh) => | |
| 271 | html`<p class="hero-terminal"> | |
| 272 | <code class="mono">${ssh}</code> | |
| 273 | <span class="muted" | |
| 274 | >the whole forge in your terminal — your key is your | |
| 275 | identity</span | |
| 276 | > | |
| 277 | </p>`, | |
| 278 | )} | |
| 279 | </section> | |
| 280 | ${tiles.length > 0 | |
| 281 | ? html`<div class="mt app-shelf">${tiles}</div>` | |
| 282 | : html`<div class="mt"> | |
| 283 | ${empty({ | |
| 284 | icon: "rocket", | |
| 285 | title: "Nothing public is up yet", | |
| 286 | note: "Apps made public here show up on this shelf once they run.", | |
| 287 | })} | |
| 288 | </div>`}`; | |
| 289 | return page({ ctx, title: "forge", body }); | |
| 290 | } |
src/web/style/components.ts +1 −1
| @@ -236,7 +236,7 @@ a.sha:hover { color: var(--accent); } | ||
| 236 | 236 | /* Cards stacked in normal flow space themselves. Inside a container that |
| 237 | 237 | already manages its own spacing with gap, that margin would double it. */ |
| 238 | 238 | .card + .card { margin-top: 12px; } |
| 239 | :is(.stack, .hero-points, .dash, .split, .timeline) > .card + .card { margin-top: 0; } | |
| 239 | :is(.stack, .dash, .split, .timeline) > .card + .card { margin-top: 0; } | |
| 240 | 240 | .card-head { |
| 241 | 241 | display: flex; align-items: center; gap: 8px; flex-wrap: wrap; |
| 242 | 242 | padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--muted); |
src/web/style/content.ts +2 −8
| @@ -340,14 +340,8 @@ form.inline { display: inline; margin-left: auto; } | ||
| 340 | 340 | .hero-terminal .mono { font-size: 15px; } |
| 341 | 341 | .terminal-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; } |
| 342 | 342 | .hero .bar { justify-content: center; } |
| 343 | .hero-points { | |
| 344 | display: grid; gap: 12px; margin-top: 40px; text-align: left; | |
| 345 | grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| 346 | } | |
| 347 | .hero-points .card-body { font-size: 13px; color: var(--muted-fg); } | |
| 348 | .hero-points strong { display: block; color: var(--fg); font-size: 14px; margin-bottom: 3px; } | |
| 349 | 343 | |
| 350 | /* The studio's front door: the public apps that are serving, as pictures. */ | |
| 344 | /* The studio's front door: the public apps, live first, as pictures. */ | |
| 351 | 345 | .app-shelf { |
| 352 | 346 | display: grid; gap: 16px; max-width: 1040px; margin-inline: auto; |
| 353 | 347 | grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); |
| @@ -370,7 +364,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 370 | 364 | *content*, so a column with a long unbreakable line pushes the page wider |
| 371 | 365 | than the phone rather than letting its own ellipsis do the work. A zero |
| 372 | 366 | min-width is what makes truncation inside a grid actually truncate. */ |
| 373 | .dash > *, .split > *, .repo-layout > *, .hero-points > * { min-width: 0; } | |
| 367 | .dash > *, .split > *, .repo-layout > * { min-width: 0; } | |
| 374 | 368 | |
| 375 | 369 | /* The generic two-column page: content, then a sidebar that sticks. */ |
| 376 | 370 | .split { display: grid; gap: 16px; margin-top: 16px; align-items: start; } |
src/web/ui.ts +1 −1
| @@ -544,7 +544,7 @@ export function labelCss(labels: Iterable<LabelLike | string>): string { | ||
| 544 | 544 | * a column of rows lines up. |
| 545 | 545 | * |
| 546 | 546 | * This is the only place a count gets drawn, which is what makes a repository |
| 547 | * row on the dashboard, a profile, the landing page, the search results and | |
| 547 | * row on the dashboard, a profile, the search results and | |
| 548 | 548 | * the origin picker all say the same thing in the same shape. |
| 549 | 549 | */ |
| 550 | 550 | export function counts( |
test/studio.test.ts +111 −9
| @@ -1355,13 +1355,16 @@ describe("the studio", () => { | ||
| 1355 | 1355 | ); |
| 1356 | 1356 | expect(publicLook.status).toBe(200); |
| 1357 | 1357 | |
| 1358 | // `?mode=dev` still renders the dev landing, unchanged, for a stranger. | |
| 1358 | // `?mode=dev` answers a stranger with the same door: the dev landing | |
| 1359 | // folded into it (#396). | |
| 1359 | 1360 | const dev = await ( |
| 1360 | 1361 | await fetch(`${base}/?mode=dev`, { |
| 1361 | 1362 | headers: { cookie: "forge_mode=studio" }, |
| 1362 | 1363 | }) |
| 1363 | 1364 | ).text(); |
| 1364 | expect(dev).toContain("Your code, your machine"); | |
| 1365 | expect(dev).toContain("<h1>Apps, live.</h1>"); | |
| 1366 | expect(dev).toContain("a lighthouse in the fog"); | |
| 1367 | expect(dev).not.toContain("<h1>Your code, your machine.</h1>"); | |
| 1365 | 1368 | }); |
| 1366 | 1369 | |
| 1367 | 1370 | test("the front door's tile is the thumb, not the full desktop picture, when both exist (#287)", async () => { |
| @@ -1563,17 +1566,116 @@ describe("the studio", () => { | ||
| 1563 | 1566 | expect(aliceApps).toContain('href="/alice/lighthouse"'); |
| 1564 | 1567 | }); |
| 1565 | 1568 | |
| 1566 | test("a forge with no public apps falls back to the dev landing, signed out in studio", async () => { | |
| 1569 | test("a forge with no public apps opens on the studio's door with an empty shelf, never dev's git pitch — at every old landing URL (#396)", async () => { | |
| 1567 | 1570 | const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-empty-")); |
| 1568 | 1571 | const app2 = createApp({ dataDir: dataDir2, port: 0 }); |
| 1569 | 1572 | const servers2 = await serve(app2); |
| 1570 | 1573 | try { |
| 1571 | const body = await ( | |
| 1572 | await fetch(`${servers2.url}/`, { | |
| 1573 | headers: { cookie: "forge_mode=studio" }, | |
| 1574 | }) | |
| 1575 | ).text(); | |
| 1576 | expect(body).toContain("Your code, your machine"); | |
| 1574 | // A public repository that is not an app is not on the shelf. | |
| 1575 | const owner = await createUser(app2.db, { | |
| 1576 | username: "olga", | |
| 1577 | email: "[email protected]", | |
| 1578 | password: "a-good-passphrase", | |
| 1579 | }); | |
| 1580 | if (!owner.ok) throw new Error(owner.error); | |
| 1581 | const plain = await createRepo(app2, owner.value, { | |
| 1582 | name: "just-code", | |
| 1583 | visibility: "public", | |
| 1584 | }); | |
| 1585 | if (!plain.ok) throw new Error(String(plain.error)); | |
| 1586 | // The landing's old addresses: the default, each mode's cookie, and | |
| 1587 | // the `?mode=dev` escape hatch. | |
| 1588 | for (const [path, cookie] of [ | |
| 1589 | ["/", ""], | |
| 1590 | ["/", "forge_mode=studio"], | |
| 1591 | ["/", "forge_mode=dev"], | |
| 1592 | ["/?mode=dev", ""], | |
| 1593 | ["/?mode=studio", "forge_mode=dev"], | |
| 1594 | ] as const) { | |
| 1595 | const response = await fetch(`${servers2.url}${path}`, { | |
| 1596 | headers: { cookie }, | |
| 1597 | redirect: "manual", | |
| 1598 | }); | |
| 1599 | expect(response.status).toBe(200); | |
| 1600 | const body = await response.text(); | |
| 1601 | expect(body).toContain("<h1>Apps, live.</h1>"); | |
| 1602 | expect(body).toContain("Nothing public is up yet"); | |
| 1603 | expect(body).toContain('href="/signup"'); | |
| 1604 | expect(body).toContain('href="/signin"'); | |
| 1605 | expect(body).not.toContain("<h1>Your code, your machine.</h1>"); | |
| 1606 | expect(body).not.toContain("Public repositories"); | |
| 1607 | expect(body).not.toContain(`href="/olga/just-code"`); | |
| 1608 | } | |
| 1609 | } finally { | |
| 1610 | servers2.stop(); | |
| 1611 | app2.close(); | |
| 1612 | await rm(dataDir2, { recursive: true, force: true }); | |
| 1613 | } | |
| 1614 | }); | |
| 1615 | ||
| 1616 | test("the door reads public apps, not a window of the newest repositories, and puts the live ones first (#288)", async () => { | |
| 1617 | const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-door-")); | |
| 1618 | const app2 = createApp({ dataDir: dataDir2, port: 0 }); | |
| 1619 | const servers2 = await serve(app2); | |
| 1620 | try { | |
| 1621 | const made = await createUser(app2.db, { | |
| 1622 | username: "pia", | |
| 1623 | email: "[email protected]", | |
| 1624 | password: "a-good-passphrase", | |
| 1625 | }); | |
| 1626 | if (!made.ok) throw new Error(made.error); | |
| 1627 | const pia = made.value; | |
| 1628 | const appOf = async (name: string) => { | |
| 1629 | const created = await createRepo(app2, pia, { | |
| 1630 | name, | |
| 1631 | visibility: "public", | |
| 1632 | }); | |
| 1633 | if (!created.ok) throw new Error(String(created.error)); | |
| 1634 | const enabled = await enableApp(app2, pia, created.value, { | |
| 1635 | slug: name, | |
| 1636 | }); | |
| 1637 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 1638 | return getAppByRepo(app2.db, created.value.id)!; | |
| 1639 | }; | |
| 1640 | const older = await appOf("older"); | |
| 1641 | await appOf("quiet"); | |
| 1642 | // More public plain repositories than the old window held, all newer | |
| 1643 | // than the apps. | |
| 1644 | for (let n = 0; n < 25; n++) { | |
| 1645 | const r = await createRepo(app2, pia, { | |
| 1646 | name: `plain-${n}`, | |
| 1647 | visibility: "public", | |
| 1648 | }); | |
| 1649 | if (!r.ok) throw new Error(String(r.error)); | |
| 1650 | } | |
| 1651 | // `older` is serving, `quiet` is not: live first, though older. A | |
| 1652 | // runtime registered for this forge, never started, answers with | |
| 1653 | // the stubbed states. | |
| 1654 | new AppsRuntime(app2, { | |
| 1655 | domain: "studio-door.test", | |
| 1656 | scheme: "http", | |
| 1657 | publicPort: servers2.port, | |
| 1658 | forgeUrl: servers2.url, | |
| 1659 | }); | |
| 1660 | const status = spyOn(AppsRuntime.prototype, "status").mockImplementation( | |
| 1661 | (record: AppRecord): EnvStatus[] => | |
| 1662 | [ | |
| 1663 | { | |
| 1664 | env: "production", | |
| 1665 | state: record.id === older.id ? "running" : "stopped", | |
| 1666 | } as EnvStatus, | |
| 1667 | ], | |
| 1668 | ); | |
| 1669 | try { | |
| 1670 | const body = await (await fetch(`${servers2.url}/`)).text(); | |
| 1671 | const at = (name: string) => body.indexOf(`href="/pia/${name}"`); | |
| 1672 | expect(at("older")).toBeGreaterThan(-1); | |
| 1673 | expect(at("quiet")).toBeGreaterThan(-1); | |
| 1674 | expect(at("older")).toBeLessThan(at("quiet")); | |
| 1675 | expect(body).not.toContain('href="/pia/plain-0"'); | |
| 1676 | } finally { | |
| 1677 | status.mockRestore(); | |
| 1678 | } | |
| 1577 | 1679 | } finally { |
| 1578 | 1680 | servers2.stop(); |
| 1579 | 1681 | app2.close(); |
new above
offline