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

Merged opened this

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

Merged

mason/forge:mason/9572-one-press-on-the-home-page-opens-a-pack → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/9572-one-press-on-the-home-page-opens-a-pack was merged into main as 2ceaec7.

Review

Sign in to review this change.
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
3535 - `.door-pulse-quiet` — a day with no finished work.
3636 - `.door-org` — one calm line for organizations.
3737 - `.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.
3941 - `.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
44
55 Two ship in `packs/`: **games** (2048, hextris, react-tetris) and **business** (memos, kanboard), each with a crew.
66
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`.
88 - **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.
99 - **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`).
1010 - 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";
1212 import { deployCommands, BRINGS_WORDS, STRANGER_WORDS } from "../../packs/words.ts";
1313 import { html } from "../html.ts";
1414 import { page } from "../layout.ts";
15 import { btn, card, listCard, row } from "../ui.ts";
15 import { btn, card, copyField, listCard, row } from "../ui.ts";
1616
1717 const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`;
1818
@@ -63,9 +63,13 @@ export function packPage(ctx: Ctx): Response {
6363 { head: html`<h2>What it brings</h2>` },
6464 )}
6565 ${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",
outdated

Hand-rolled <pre>: use copyField from ui.ts (or extend it) instead of a new .pack-command block.

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 ),
6973 { head: html`<h2>Deploy it on your own machine</h2>` },
7074 )}
7175 ${card(
src/web/studio/home.ts +30 −3
@@ -17,6 +17,7 @@ import {
1717 type FrontPage,
1818 type FrontPicksJson,
1919 } from "../../actions/front.ts";
20 import { catalog } from "../../packs/catalog.ts";
2021 import { appsRuntime, type EnvState } from "../../apps/runtime.ts";
2122 import { signupClosed } from "../../actions/users.ts";
2223 import { getSignup } from "../../settings/signup.ts";
@@ -663,6 +664,34 @@ function doorPulse(days: FrontPage["pulse"]["days"]): Html | null {
663664
664665 const FORGE_REPO = "https://forge.open-platform.sh/trevato/forge";
665666
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
666695 /** The page the prompt and the Open in ChatGPT, Claude and Grok links send an assistant to. */
667696 const AGENTS = "/agents";
668697
@@ -829,9 +858,7 @@ bun run src/main.ts</code></pre>
829858 <p class="muted small">Or visit this forge from your terminal: your key is your identity.</p>
830859 </div>`,
831860 )}
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()}
835862 </div>
836863 </section>
837864 <p class="door-org">
src/web/style/content.ts +8 −1
@@ -268,7 +268,13 @@ form.inline { display: inline; margin-left: auto; }
268268 .door-points dd { max-width: 58ch; }
269269 .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; }
270270 .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; }
272278 @media (max-width: 559px) {
273279 .door-wide { display: none; }
274280 }
@@ -276,6 +282,7 @@ form.inline { display: inline; margin-left: auto; }
276282 .door-shelf { grid-template-columns: repeat(4, minmax(0, 1fr)); }
277283 .door-points { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px; }
278284 .door-steps { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px; }
285 .door-press-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
279286 .door-split { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 48px; }
280287 }
281288 @media (min-width: 1000px) {
src/web/ui.ts +33 −15
@@ -248,22 +248,40 @@ export function copyField(
248248 value: string,
249249 label: string,
250250 copyLabel: string,
251 o: { lines?: number } = {},
251252 ): 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 >`;
267285 }
268286
269287 // ---- badges, labels, pills, dots ----
test/packs.test.ts +26
@@ -109,4 +109,30 @@ describe("pages", () => {
109109 expect(body).toContain("gabrielecirulli/2048");
110110 expect((await fetch(`${servers.url}/packs/nothing`)).status).toBe(404);
111111 });
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 });
112138 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs