Public: anyone can read this; the crew can change it.
One press on the home page opens a pack: what it brings and the one command that deploys it #9604
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9572-one-press-on-the-home-page-opens-a-pack → main 3 commits fork 3 threads · 3 unresolved
7 files
changed +109
−25
docs/design/areas/front.md +3 −1
| @@ -35,5 +35,7 @@ The page a visitor reads first runs past §3's working scale on purpose, in `cla | ||
| 35 | 35 | - `.door-pulse-quiet` — a day with no finished work. |
| 36 | 36 | - `.door-org` — one calm line for organizations. |
| 37 | 37 | - `.door-code` — the commands that run a forge. |
| 38 | - `.door-packs` — ready-made forges, said as coming, with no button until they deploy. | |
| 38 | - `.door-packs` — the ready-made forges: a kicker, then one press for each pack. | |
| 39 | - `.door-press-row` / `.door-press` — the presses, side by side from 720px; each names the pack, what it holds, and links to `/packs/:name`. | |
| 40 | - `.door-press-actions` — a press's actions; its empty `data-slot="host"` is where a later "host it for me" sits. | |
| 39 | 41 | - `.door-wide` — a visitor's top-bar link that a phone leaves out, so the bar never overflows. |
docs/features/packs.md +1 −1
| @@ -4,7 +4,7 @@ A pack is a `pack.json` in a repository (`src/packs/pack.ts` holds the format, v | ||
| 4 | 4 | |
| 5 | 5 | Two ship in `packs/`: **games** (2048, hextris, react-tetris) and **business** (memos, kanboard), each with a crew. |
| 6 | 6 | |
| 7 | - **See one.** `/packs` lists them, `/packs/<name>` says what it brings, the deploy commands, and how a stranger helps build its apps (get a guest account or use their own forge; a guest's pull waits for a person, and paths touching secrets, credentials, permissions or release machinery are named on it — `docs/ROLES.md`). Reached from the user menu, the signed-out menu and the palette. `bun scripts/pack.ts show games` prints the same in a terminal, and the SSH terminal has a **Forge packs** screen (palette, `src/tui/screens/packs.ts`): the list, a pack's apps and crew, the deploy commands and what happens to a stranger's pull. The words are shared in `src/packs/words.ts`. | |
| 7 | - **See one.** `/packs` lists them, `/packs/<name>` says what it brings, the deploy commands, and how a stranger helps build its apps (get a guest account or use their own forge; a guest's pull waits for a person, and paths touching secrets, credentials, permissions or release machinery are named on it — `docs/ROLES.md`). The home page (`/`) has one press for each pack in its "Get your own forge" section, a link to `/packs/<name>` with an empty `data-slot="host"` beside it for a later hosted deploy; the pack page shows the commands in one `copyField` block (`#pack-command`, a read-only text area you select with a click; its copy button is `js-only`, so it is on the page only when script runs). Reached from the user menu, the signed-out menu and the palette. `bun scripts/pack.ts show games` prints the same in a terminal, and the SSH terminal has a **Forge packs** screen (palette, `src/tui/screens/packs.ts`): the list, a pack's apps and crew, the deploy commands and what happens to a stranger's pull. The words are shared in `src/packs/words.ts`. | |
| 8 | 8 | - **Deploy one.** Boot a forge (`bun run src/main.ts`), make an account and token, then `FORGE_URL=… FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games`. Each app is made, the source's whole history (branches and tags) is pushed in, and the crew is added. Run it again to finish a partial deploy: what exists is kept. The model credential is yours to add in Settings. |
| 9 | 9 | - **Nightly.** `scripts/pack-compat.ts` boots a clean forge per pack, deploys it with the real sources and fails if any step or app page fails (job `pack-compat`, `src/nightly/jobs.ts`). |
| 10 | 10 | - Code: `src/packs/`, `src/web/pages/packs.ts`, `scripts/pack.ts`. Tests: `test/packs.test.ts`, `test/packs-hands.test.ts` (the git credential is scoped to the forge and never sent to a pack's source; only 409 means "exists"; the terminal screen). |
src/web/pages/packs.ts +8 −4
| @@ -12,7 +12,7 @@ import { notFoundPage, offersSignup, pageHead } from "../chrome.ts"; | ||
| 12 | 12 | import { deployCommands, BRINGS_WORDS, STRANGER_WORDS } from "../../packs/words.ts"; |
| 13 | 13 | import { html } from "../html.ts"; |
| 14 | 14 | import { page } from "../layout.ts"; |
| 15 | import { btn, card, listCard, row } from "../ui.ts"; | |
| 15 | import { btn, card, copyField, listCard, row } from "../ui.ts"; | |
| 16 | 16 | |
| 17 | 17 | const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`; |
| 18 | 18 | |
| @@ -63,9 +63,13 @@ export function packPage(ctx: Ctx): Response { | ||
| 63 | 63 | { head: html`<h2>What it brings</h2>` }, |
| 64 | 64 | )} |
| 65 | 65 | ${card( |
| 66 | html`<div class="stack stack-tight"> | |
| 67 | ${deployCommands(pack, ctx.app.config.url).map((l) => html`<code class="mono break">${l}</code>`)} | |
| 68 | </div>`, | |
| 66 | copyField( | |
| 67 | "pack-command", | |
| 68 | deployCommands(pack, ctx.app.config.url).join("\n"), | |
outdated
This button is dead with JavaScript off, and the hint beside it says so. Box 2 asks for a copy that works without script. Fix: do not render a control that needs script, or make it work without. |
||
| 69 | `The command that deploys the ${pack.title} pack`, | |
| 70 | "Copy the command", | |
| 71 | { lines: deployCommands(pack, ctx.app.config.url).length }, | |
| 72 | ), | |
| 69 | 73 | { head: html`<h2>Deploy it on your own machine</h2>` }, |
| 70 | 74 | )} |
| 71 | 75 | ${card( |
src/web/studio/home.ts +30 −3
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | type FrontPage, |
| 18 | 18 | type FrontPicksJson, |
| 19 | 19 | } from "../../actions/front.ts"; |
| 20 | import { catalog } from "../../packs/catalog.ts"; | |
| 20 | 21 | import { appsRuntime, type EnvState } from "../../apps/runtime.ts"; |
| 21 | 22 | import { signupClosed } from "../../actions/users.ts"; |
| 22 | 23 | import { getSignup } from "../../settings/signup.ts"; |
| @@ -663,6 +664,34 @@ function doorPulse(days: FrontPage["pulse"]["days"]): Html | null { | ||
| 663 | 664 | |
| 664 | 665 | const FORGE_REPO = "https://forge.open-platform.sh/trevato/forge"; |
| 665 | 666 | |
| 667 | /** | |
| 668 | * One press for each shipped pack (#9572): what it is, and the way to its page, | |
| 669 | * where the command is. The `data-slot="host"` element is the named place a | |
| 670 | * later "host it for me" action sits beside "See what it brings"; it is empty | |
| 671 | * today, so there is no dead button. | |
| 672 | */ | |
| 673 | function packPresses(): Html | null { | |
| 674 | const { packs } = catalog(); | |
| 675 | if (packs.length === 0) return null; | |
| 676 | const count = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`; | |
| 677 | return html`<div class="door-packs" aria-label="Ready-made forges"> | |
| 678 | <p class="door-kick">Start from a ready-made forge</p> | |
| 679 | <div class="door-press-row"> | |
| 680 | ${packs.map( | |
| 681 | (p) => html`<article class="door-press"> | |
| 682 | <h3><a href="/packs/${p.name}">${p.title}</a></h3> | |
| 683 | <p class="muted small">${p.description}</p> | |
| 684 | <p class="muted small">${count(p.apps.length, "app")}, ${count(p.crew.length, "crew member")}</p> | |
| 685 | <div class="door-press-actions"> | |
| 686 | ${btn("See what it brings", { href: `/packs/${p.name}`, size: "sm" })} | |
| 687 | <span data-slot="host"></span> | |
| 688 | </div> | |
| 689 | </article>`, | |
| 690 | )} | |
| 691 | </div> | |
| 692 | </div>`; | |
| 693 | } | |
| 694 | ||
| 666 | 695 | /** The page the prompt and the Open in ChatGPT, Claude and Grok links send an assistant to. */ |
| 667 | 696 | const AGENTS = "/agents"; |
| 668 | 697 | |
| @@ -829,9 +858,7 @@ bun run src/main.ts</code></pre> | ||
| 829 | 858 | <p class="muted small">Or visit this forge from your terminal: your key is your identity.</p> |
| 830 | 859 | </div>`, |
| 831 | 860 | )} |
| 832 | <p class="door-packs"> | |
| 833 | <span class="door-kick">Coming</span> Ready-made forges to start from: the software a business or a game studio needs, with more over time. | |
| 834 | </p> | |
| 861 | ${packPresses()} | |
| 835 | 862 | </div> |
| 836 | 863 | </section> |
| 837 | 864 | <p class="door-org"> |
src/web/style/content.ts +8 −1
| @@ -268,7 +268,13 @@ form.inline { display: inline; margin-left: auto; } | ||
| 268 | 268 | .door-points dd { max-width: 58ch; } |
| 269 | 269 | .door-code { margin: 0; padding: 18px 20px; overflow-x: auto; border-radius: var(--radius); background: var(--muted); font-family: var(--mono); font-size: 13px; line-height: 1.7; } |
| 270 | 270 | .door-packs { margin: 8px 0 0; color: var(--muted-fg); } |
| 271 | .door-packs .door-kick { display: block; margin-bottom: 4px; } | |
| 271 | .door-packs .door-kick { display: block; margin-bottom: 8px; } | |
| 272 | .door-press-row { display: grid; gap: 12px; } | |
| 273 | .door-press { display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; border: 1px solid var(--border); border-radius: var(--radius); } | |
| 274 | .door-press h3 { margin: 0; font-size: 16px; font-weight: 500; } | |
| 275 | .door-press p { margin: 0; } | |
| 276 | .door-press-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; } | |
| 277 | .door-press-actions [data-slot]:empty { display: none; } | |
| 272 | 278 | @media (max-width: 559px) { |
| 273 | 279 | .door-wide { display: none; } |
| 274 | 280 | } |
| @@ -276,6 +282,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 276 | 282 | .door-shelf { grid-template-columns: repeat(4, minmax(0, 1fr)); } |
| 277 | 283 | .door-points { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px; } |
| 278 | 284 | .door-steps { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px; } |
| 285 | .door-press-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 279 | 286 | .door-split { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 48px; } |
| 280 | 287 | } |
| 281 | 288 | @media (min-width: 1000px) { |
src/web/ui.ts +33 −15
| @@ -248,22 +248,40 @@ export function copyField( | ||
| 248 | 248 | value: string, |
| 249 | 249 | label: string, |
| 250 | 250 | copyLabel: string, |
| 251 | o: { lines?: number } = {}, | |
| 251 | 252 | ): Html { |
| 252 | return html`<span class="bar" | |
| 253 | ><input | |
| 254 | class="input grow mono" | |
| 255 | id="${id}" | |
| 256 | type="text" | |
| 257 | readonly | |
| 258 | value="${value}" | |
| 259 | aria-label="${label}" | |
| 260 | data-size="sm" | |
| 261 | />${iconBtn("copy", copyLabel, { | |
| 262 | size: "sm", | |
| 263 | type: "button", | |
| 264 | copy: id, | |
| 265 | })}</span | |
| 266 | >`; | |
| 253 | // The button needs script, so it is only on the page when there is script; | |
| 254 | // without it the field is still there to select. | |
| 255 | const button = iconBtn("copy", copyLabel, { | |
| 256 | size: "sm", | |
| 257 | type: "button", | |
| 258 | copy: id, | |
| 259 | extra: "js-only", | |
| 260 | }); | |
| 261 | return o.lines !== undefined && o.lines > 1 | |
| 262 | ? html`<span class="stack stack-tight" | |
| 263 | ><textarea | |
| 264 | class="input mono" | |
| 265 | id="${id}" | |
| 266 | rows="${o.lines}" | |
| 267 | wrap="off" | |
| 268 | readonly | |
| 269 | aria-label="${label}" | |
| 270 | data-size="sm" | |
| 271 | >${value}</textarea | |
| 272 | >${button}</span | |
| 273 | >` | |
| 274 | : html`<span class="bar" | |
| 275 | ><input | |
| 276 | class="input grow mono" | |
| 277 | id="${id}" | |
| 278 | type="text" | |
| 279 | readonly | |
| 280 | value="${value}" | |
| 281 | aria-label="${label}" | |
| 282 | data-size="sm" | |
| 283 | />${button}</span | |
| 284 | >`; | |
| 267 | 285 | } |
| 268 | 286 | |
| 269 | 287 | // ---- badges, labels, pills, dots ---- |
test/packs.test.ts +26
| @@ -109,4 +109,30 @@ describe("pages", () => { | ||
| 109 | 109 | expect(body).toContain("gabrielecirulli/2048"); |
| 110 | 110 | expect((await fetch(`${servers.url}/packs/nothing`)).status).toBe(404); |
| 111 | 111 | }); |
| 112 | test("the home page has one press for each pack, a link in its row; following it finds the command", async () => { | |
| 113 | const { Window } = await import("happy-dom"); | |
| 114 | const dom = (h: string) => { | |
| 115 | const w = new Window(); | |
| 116 | w.document.body.innerHTML = h; | |
| 117 | return w.document; | |
| 118 | }; | |
| 119 | const home = dom(await (await fetch(`${servers.url}/`)).text()); | |
outdated
Substring checks only: assert the press is an <a> inside .door-press and that the copy control targets pack-command. Nothing here fails if the button is dead. |
||
| 120 | const presses = [...home.querySelectorAll(".door-press")]; | |
| 121 | expect(presses.length).toBe(2); | |
| 122 | for (const name of ["games", "business"]) { | |
| 123 | const press = presses.find((p) => p.querySelector(`h3 > a[href="/packs/${name}"]`) !== null); | |
| 124 | expect(press).toBeDefined(); | |
| 125 | expect(press!.querySelector(`.door-press-actions > a[href="/packs/${name}"]`)).not.toBeNull(); | |
| 126 | const slot = press!.querySelector('.door-press-actions > [data-slot="host"]'); | |
| 127 | expect(slot).not.toBeNull(); | |
| 128 | expect(slot!.children.length).toBe(0); | |
| 129 | const page = dom(await (await fetch(`${servers.url}/packs/${name}`)).text()); | |
| 130 | const field = page.getElementById("pack-command"); | |
| 131 | expect(field).not.toBeNull(); | |
| 132 | expect(field!.textContent).toContain(`scripts/pack.ts deploy ${name}`); | |
| 133 | const copy = page.querySelector('[data-copy="pack-command"]'); | |
| 134 | expect(copy).not.toBeNull(); | |
| 135 | expect(copy!.className).toContain("js-only"); | |
| 136 | } | |
| 137 | }); | |
| 112 | 138 | }); |
Hand-rolled
<pre>: usecopyFieldfrom ui.ts (or extend it) instead of a new.pack-commandblock.