From a7a8df6f4bdb0aa1baf820c1ad540671a88fe169 Mon Sep 17 00:00:00 2001 From: Karan Date: Wed, 4 Nov 2026 13:30:30 -0500 Subject: [PATCH] One click to an app: POST /start makes it, /start/ says the one command, and the first view of a dashboard says it too - /start has one button and nothing to fill in. A form post makes the app and goes to its setup page; the script asks for JSON so it can copy the command while the click still counts. Signed in, the app goes in the account instead. - /start/: the one command, links that open Cursor, Claude Code or Claude in VS Code with a short prompt filled in (they run nothing), a status that turns to Connected when the first page load arrives, and "Save it to a free account". - GET /api/projects//status: one row for that status, instead of the whole stats on every poll. - A new app's dashboard opens with the same command; the paragraph about batches and local hosts is one click away. - init prints less after it applies; the telemetry note keeps what is sent, how to turn it off and both links. Co-Authored-By: Claude Opus 5.5 --- docs/runtime/DASHBOARD.md | 2 +- packages/runtime/CHANGELOG.md | 6 + packages/runtime/bin/lib/init.mjs | 22 +-- telemetry-worker/src/account-pages.ts | 17 +- telemetry-worker/src/auth.ts | 23 ++- telemetry-worker/src/collector.ts | 78 +++++++-- telemetry-worker/src/pages.ts | 212 +++++++++++++++++------- telemetry-worker/test/auth.test.ts | 30 ++++ telemetry-worker/test/dashboard.test.ts | 130 ++++++++++++++- telemetry-worker/test/pages.test.ts | 21 ++- telemetry-worker/test/selfhost.test.ts | 15 +- 11 files changed, 438 insertions(+), 118 deletions(-) diff --git a/docs/runtime/DASHBOARD.md b/docs/runtime/DASHBOARD.md index 9914c508..ef55f578 100644 --- a/docs/runtime/DASHBOARD.md +++ b/docs/runtime/DASHBOARD.md @@ -30,7 +30,7 @@ off ([Why does my dashboard show no data?](#why-does-my-dashboard-show-no-data)) Run `npx @genclass/runtime@beta init` while not signed in. After you approve its diff it creates both the token and the link, writes the token into what it adds, prints the link and saves it in `.genclass.local` -([INSTALL.md](INSTALL.md#how-does-init-create-a-token-and-a-dashboard)). Or click "Get a token" at +([INSTALL.md](INSTALL.md#how-does-init-create-a-token-and-a-dashboard)). Or click "Add to my app" at [genclass.dev/start](https://genclass.dev/start). A link can be added to an account later (`connect`, or "Add an existing dashboard" on your account page). diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 2f6fa3ce..bc5c0e34 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- `init` prints much less after it applies: the telemetry note is two lines (still what is sent, how to turn it off and + the link), the next steps name the overlay first, and the optional upgrades are one line pointing at `init --dry-run`, + which still lists them. + ## 0.3.0-beta.5 (2026-11-02) Security fixes from the adversarial review of the mode-raise fix (`0.3.0-beta.2`); the same four fixes are in `0.2.3` diff --git a/packages/runtime/bin/lib/init.mjs b/packages/runtime/bin/lib/init.mjs index 1d64784a..8ee7da40 100644 --- a/packages/runtime/bin/lib/init.mjs +++ b/packages/runtime/bin/lib/init.mjs @@ -345,7 +345,7 @@ export const SCAN_CMD = "npx @genclass/runtime@beta scan "; function printLittleToWatch(kind) { if (!kind) return; const why = kind === "none" ? "No fetch, XHR, WebSocket or EventSource call was found in your sources" : "Your page has one script"; - out(` ${c.bold("Note")} ${why}, so GenClass will have little to find here beyond request health (runtime.summary().requests); the measured visual checks (visual: true) and the page scan (${c.cyan(SCAN_CMD)}) apply to a mostly static site.`); + out(` ${c.bold("Note")} ${why}, so GenClass will have little to find here. For a mostly static site, try ${c.cyan(SCAN_CMD)}.`); out(); } @@ -395,13 +395,9 @@ function telemetryNotice(cfg, where) { return; } row("Telemetry", `${c.bold("on")}${cfg.telemetry ? c.gray(" (--telemetry)") : c.gray(" (the default)")}`); - out(` GenClass sends anonymous diagnostics to its maintainers: its decisions, action outcomes,`); - out(` model status and counts. No text from the page (the situation text the model read is sent`); - out(` only with telemetry: { include: { situation: true } }); never cookies or IP addresses;`); - out(` never from localhost or private hosts. Turn it off: ${c.cyan(`${CMD} init --no-telemetry`)}`); - out(` (or GenClass.init({ telemetry: false }), ?genclass=no-telemetry in the URL).`); - out(` What is sent: ${TELEMETRY_URL}`); - out(` Privacy policy: ${PRIVACY_URL}`); + out(` Anonymous counts and decisions go to GenClass's maintainers: no page text, cookies or IP`); + out(` addresses, never from localhost. Off: ${c.cyan(`${CMD} init --no-telemetry`)}`); + out(` What is sent: ${TELEMETRY_URL} · Privacy: ${PRIVACY_URL}`); } /** Where to put the self-hosted model: the folder the framework serves as-is. */ @@ -984,16 +980,14 @@ export async function init(o, ctx = {}) { const script = project.pkg?.scripts?.dev ? "dev" : project.pkg?.scripts?.start ? "start" : null; if (project.framework === "html") out(` ${sym.dot} Open your page. ${o.sync ? "GenClass starts before your other scripts." : "GenClass starts once the page is parsed, before your deferred and module scripts (--sync: before every script)."}`); else if (script) out(` ${sym.dot} Run ${c.cyan(runCmd(project.pm, script))} and open the app.`); - out(` ${sym.dot} Detections and actions appear in the browser console as ${c.cyan("[GenClass] ...")} lines.`); + if (devtools && changes.some((ch) => /devtools/.test(ch.after))) out(` ${sym.dot} An overlay at the bottom right of dev pages shows what GenClass sees (${c.bold("Alt+Shift+G")}).`); + out(` ${sym.dot} Findings also appear in the browser console as ${c.cyan("[GenClass] ...")} lines.`); if (cfg.sinks?.length) out(` ${sym.dot} They also reach ${c.cyan(cfg.sinks.join(", "))}: ${cfg.sinks.map((s) => SINK_WHAT[sinkBase(s)] ?? s).join("; ")}.`); - if (devtools && changes.some((ch) => /devtools/.test(ch.after))) out(` ${sym.dot} In development an overlay shows what GenClass sees (bottom-right, ${c.bold("Alt+Shift+G")}).`); - out(` ${sym.dot} Rule it out while debugging: add ${c.cyan("?genclass=off")} to the URL.`); - out(); - printRecommendations(recs); printMoneyAdvice(money, cfgAt); printLittleToWatch(quiet); - out(` Undo: ${c.cyan(`${CMD} remove`)}`); + if (recs.length) out(` ${c.gray(`${recs.length} optional upgrade${recs.length === 1 ? "" : "s"}:`)} ${c.cyan(`${CMD} init --dry-run`)}`); + out(` Off for one page: ${c.cyan("?genclass=off")}. Undo: ${c.cyan(`${CMD} remove`)}`); out(); return 0; } diff --git a/telemetry-worker/src/account-pages.ts b/telemetry-worker/src/account-pages.ts index 455beb95..f107c6b7 100644 --- a/telemetry-worker/src/account-pages.ts +++ b/telemetry-worker/src/account-pages.ts @@ -318,7 +318,7 @@ ${optional()} export function signupPage(nonce: string, o: AuthPageOptions): string { const inner = `

Create your GenClass account

-

Your account keeps your apps' dashboards in one place, and lets npx ${RUNTIME_PKG} login connect an app for you.

+

Keep every app's dashboard in one place. Free, no card.

${signInWays({ ...o, signup: true })}

Want extra security? Add a passkey on your account page once you are in. Use a passkey signs in to an account that already has one.

@@ -557,8 +557,8 @@ export interface AccountView { notice?: { kind: "ok" | "bad"; text: string }; /** Dashboards: open (every plan today), or locked, with the plans to see (a plan file without the dashboard). */ dashboards?: "open" | "plans"; - /** From the query: the locked dashboard's sign-in (?claim=1). */ - pick?: { claim: boolean }; + /** From the query: the locked dashboard's sign-in (?claim=1), and Save it to a free account on /start/ (?save=1). */ + pick?: { claim: boolean; save?: boolean }; /** Where to write when every passkey is lost. */ recovery?: string; /** The plan's name (the organization's badge in the header); default Free. */ @@ -818,7 +818,7 @@ ${planBlock} hash of your password if you set one (never the password), your passkeys' public keys, your Google account id and your GitHub id and login if you link them, your sign-in sessions and the browsers where your password was right, and the projects you own. Nothing else. Plan and billing · Privacy · Terms
-`; +`; const script = ` (function(){ @@ -912,6 +912,15 @@ you link them, your sign-in sessions and the browsers where your password was ri var cfg = JSON.parse(document.getElementById("acfg").textContent); // back from signing in on a locked dashboard link: return to it (its link was kept in this tab only), where the app // can be added to this account; otherwise the link field below is ready for it + // back from "Save it to a free account" on /start/: add that app (its link was kept in this tab only) and open it + if (cfg.save) { + var sv = null; + try { sv = sessionStorage.getItem("gc.save"); sessionStorage.removeItem("gc.save"); } catch (e) {} + if (sv && /^[A-Za-z0-9]{32}$/.test(sv)) { + gcPost("/api/account/projects/claim", { dashboard_url: sv }).then(function(j){ location.replace("/account/apps/" + j.project.id + "#connect"); }).catch(function(){ var l = document.getElementById("link"); if (l) { l.value = location.origin + "/dashboard/" + sv; l.focus(); } }); + return; + } + } if (cfg.claim) { var s = null; try { s = sessionStorage.getItem("gc.claim"); sessionStorage.removeItem("gc.claim"); } catch (e) {} diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index f5fae7f0..b47c56d0 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -72,7 +72,7 @@ import { import { appExtrasData, appExtrasHtml } from "./billing-pages.js"; import { forgetExports } from "./export.js"; import { json, mediaType, readCapped } from "./http.js"; -import { dashboardPage, ownEndpoint, securityHeaders } from "./pages.js"; +import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js"; import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js"; import { accountAccess, accountBilling, appLimit, appLimitBody, hasDashboard, pickRange, viewRanges } from "./plans.js"; import { cancelForDeletion } from "./stripe.js"; @@ -1742,7 +1742,7 @@ async function accountView(c: C, a: Auth): Promise { // dashboards: open (every plan today), or locked with the plans to see dashboards: hasDashboard(access) ? "open" : "plans", plan: access.plan.name, - pick: { claim: c.url.searchParams.get("claim") === "1" }, + pick: { claim: c.url.searchParams.get("claim") === "1", save: c.url.searchParams.get("save") === "1" }, recovery: RECOVERY_CONTACT, }; } @@ -1756,7 +1756,7 @@ export function accountContext(request: Request, url: URL, env: Env, opts: Handl } const PREFIXES = ["/auth/", "/api/auth/", "/api/device/", "/api/account/", "/account/"]; -const EXACT = new Set(["/signup", "/login", "/login/device", "/login/passkey", "/login/forgot", "/logout", "/account", "/api/me", "/api/account"]); +const EXACT = new Set(["/start", "/signup", "/login", "/login/device", "/login/passkey", "/login/forgot", "/logout", "/account", "/api/me", "/api/account"]); /** Account routes. Returns null for paths this module does not own (the caller continues routing). */ export async function routeAuth(request: Request, url: URL, path: string, env: Env, opts: HandleOptions): Promise { @@ -1772,6 +1772,23 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E const method = (): Response => fail(405, "method not allowed"); switch (path) { + case "/start": { + // the one click, signed in: the new app goes in the account (not signed in, or any GET: routeSite answers) + if (!post) return null; + const a = await webSession(c); + if (!a) return null; + const json201 = (request.headers.get("accept") ?? "").includes("application/json"); + const elsewhere = (): Response => (json201 ? json(200, { url: "/account/new", copiedUrl: "/account/new", token: null, command: null }) : redirect("/account/new", [], 303)); + if (!originOk(c) || (await overAppLimit(c, a.account.id))) return elsewhere(); + if (c.env.CREATE_LIMITER) { + const { success } = await c.env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:account:${a.account.id}`)).slice(0, 32) }); + if (!success) return elsewhere(); + } + if ((await projectsCreatedSince(c.db, new Date(c.now.getTime() - MINUTE), "account")) >= MAX_ACCOUNT_PROJECTS_PER_MINUTE) return elsewhere(); + const p = await createProject(c.db, sanitizeName(""), c.now, a.account.id); + const to = `/account/apps/${p.id}#connect`; + return json201 ? json(201, { url: to, copiedUrl: to, token: p.token, command: setupCommand(p.token) }) : redirect(to, [], 303); + } case "/signup": case "/login": { if (!get) return method(); diff --git a/telemetry-worker/src/collector.ts b/telemetry-worker/src/collector.ts index 90c7061b..8ac302d7 100644 --- a/telemetry-worker/src/collector.ts +++ b/telemetry-worker/src/collector.ts @@ -11,7 +11,7 @@ // OPTIONS /v1/events (CORS preflight), GET /v1/health. // // Per-app tokens and dashboards (src/dashboard.ts, src/pages.ts; also routed on genclass.dev/{api/*,start*,dashboard*}): -// POST /api/projects, GET /api/projects/, GET /start, GET /start/open-source (no account, no token), GET +// POST /api/projects, GET /api/projects/, POST and GET /start, GET /start/, GET /start/open-source (no account, no token), GET // /dashboard/. A batch with a known top-level `token` is also aggregated into D1 for that app's dashboard; // without one it is stored in R2 only, exactly as before. // Every plan includes the dashboard (billing/plans.json), so a private link opens it, also for an app with no account @@ -43,7 +43,7 @@ import { import { routeBilling } from "./billing.js"; import { json, readCapped } from "./http.js"; import { afterBatch } from "./paid.js"; -import { dashboardPage, notFoundPage, openSourcePage, ownEndpoint, securityHeaders, startPage } from "./pages.js"; +import { dashboardPage, notFoundPage, openSourcePage, ownEndpoint, securityHeaders, setupCommand, setupPage, startPage } from "./pages.js"; import { lockedLink } from "./locked.js"; import { hasDashboard, projectAccess } from "./plans.js"; import { SAMPLE_PROJECT, SAMPLE_SECRET, sampleStats } from "./sample.js"; @@ -327,6 +327,42 @@ function html(status: number, body: string, nonce: string): Response { return new Response(body, { status, headers: securityHeaders(nonce) }); } +/** Why a new anonymous app must wait, or null: the per-client limiter (a hash of the IP, never stored), then a global ceiling. */ +async function creationLimited(request: Request, db: D1Like, env: Env, now: Date): Promise { + const busy = "too many projects created; try again in a minute"; + if (env.CREATE_LIMITER) { + const ip = request.headers.get("cf-connecting-ip") ?? "unknown"; + const { success } = await env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:${ip}`)).slice(0, 32) }); + if (!success) return busy; + } + // Backstop for the (per-location, eventually consistent) limiter: a global ceiling on new projects per minute. + return (await projectsCreatedSince(db, new Date(now.getTime() - 60_000), "anonymous")) >= MAX_PROJECTS_PER_MINUTE ? busy : null; +} + +function accountHtml(status: number, body: string, nonce: string): Response { + return new Response(body, { status, headers: securityHeaders(nonce, {}, { account: true }) }); +} + +/** + * POST /start, the one click, for someone not signed in (routeAuth takes the signed-in case first). A form post is sent + * on to the app's setup page; the page's script asks for JSON, so it can copy the command while the click still counts. + */ +async function startApp(request: Request, env: Env, now: Date): Promise { + const wantsJson = (request.headers.get("accept") ?? "").includes("application/json"); + const refuse = (status: number, error: string, headers: Record = {}): Response => { + if (wantsJson) return json(status, { ok: false, error }, headers); + const nonce = randomBase62(22); + return new Response(startPage(nonce, { error }), { status, headers: { ...securityHeaders(nonce, {}, { account: true }), ...headers } }); + }; + if (!env.DB) return refuse(503, "dashboards unavailable"); + const slow = await creationLimited(request, env.DB, env, now); + if (slow) return refuse(429, slow, { "retry-after": "60" }); + const p = await createProject(env.DB, sanitizeName(""), now); + const to = `/start/${p.secret}`; + if (wantsJson) return json(201, { url: to, copiedUrl: `${to}?copied=1`, token: p.token, command: setupCommand(p.token) }); + return new Response(null, { status: 303, headers: { location: to, "cache-control": "no-store" } }); +} + async function routeSite(request: Request, url: URL, path: string, env: Env, opts: HandleOptions): Promise { const m = request.method; const now = (opts.now ?? (() => new Date()))(); @@ -335,16 +371,8 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt if (m === "OPTIONS") return new Response(null, { status: 204, headers: PROJECT_CORS }); if (m !== "POST") return json(405, { ok: false, error: "method not allowed" }, PROJECT_CORS); if (!env.DB) return json(503, { ok: false, error: "dashboards unavailable" }, PROJECT_CORS); - if (env.CREATE_LIMITER) { - // The limiter key is a hash of the client IP; the IP itself is never stored or logged. - const ip = request.headers.get("cf-connecting-ip") ?? "unknown"; - const { success } = await env.CREATE_LIMITER.limit({ key: (await sha256Hex(`create:${ip}`)).slice(0, 32) }); - if (!success) return json(429, { ok: false, error: "too many projects created; try again in a minute" }, { ...PROJECT_CORS, "retry-after": "60" }); - } - // Backstop for the (per-location, eventually consistent) limiter: a global ceiling on new projects per minute. - if ((await projectsCreatedSince(env.DB, new Date(now.getTime() - 60_000), "anonymous")) >= MAX_PROJECTS_PER_MINUTE) { - return json(429, { ok: false, error: "too many projects created; try again in a minute" }, { ...PROJECT_CORS, "retry-after": "60" }); - } + const slow = await creationLimited(request, env.DB, env, now); + if (slow) return json(429, { ok: false, error: slow }, { ...PROJECT_CORS, "retry-after": "60" }); const raw = await readCapped(request, 4096); if (raw === null) return json(413, { ok: false, error: "body too large" }, PROJECT_CORS); let body: unknown = {}; @@ -395,6 +423,15 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt return putFunnels(request, env.DB, p.token); } + // whether the first page load has arrived (/start/ asks every few seconds): one row, not the whole stats + const statusApi = /^\/api\/projects\/([^/]+)\/status$/.exec(path); + if (statusApi) { + if (m !== "GET" && m !== "HEAD") return json(405, { ok: false, error: "method not allowed" }); + const p = env.DB && statusApi[1] !== SAMPLE_SECRET ? await projectBySecret(env.DB, statusApi[1]!) : null; + if (!p) return json(404, { ok: false, error: "not found" }); + return json(200, { lastEvent: p.last_event ?? null, firstEvent: p.first_event ?? null }); + } + const api = /^\/api\/projects\/([^/]+)$/.exec(path); if (api) { if (m !== "GET" && m !== "HEAD") return json(405, { ok: false, error: "method not allowed" }); @@ -408,9 +445,24 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt } if (path === "/start") { + if (m === "POST") return startApp(request, env, now); + if (m !== "GET" && m !== "HEAD") return null; + const nonce = randomBase62(22); + return accountHtml(200, startPage(nonce), nonce); + } + + // what to do after the one click: the command, the agent links and whether the first page load has arrived + const setup = /^\/start\/([A-Za-z0-9]{32})$/.exec(path); + if (setup) { if (m !== "GET" && m !== "HEAD") return null; + const p = env.DB ? await projectBySecret(env.DB, setup[1]!) : null; const nonce = randomBase62(22); - return html(200, startPage(nonce, { runtimeReady: runtimeHasTokens(env), endpoint: ownEndpoint(env) }), nonce); + if (!p) return html(404, notFoundPage(nonce), nonce); + return accountHtml( + 200, + setupPage(nonce, { token: p.token, secret: setup[1]!, lastEvent: p.last_event ?? null, runtimeReady: runtimeHasTokens(env), endpoint: ownEndpoint(env), copied: url.searchParams.get("copied") === "1" }), + nonce, + ); } // the open-source parts with no account and no token (pages.ts openSourcePage): static, nothing stored diff --git a/telemetry-worker/src/pages.ts b/telemetry-worker/src/pages.ts index 51e09db8..d94776df 100644 --- a/telemetry-worker/src/pages.ts +++ b/telemetry-worker/src/pages.ts @@ -229,7 +229,7 @@ function renderSnippets(host, tok, ep){ `; export interface ShellOptions { - /** Header links (HTML). Default: Home, Get a token, Docs, Sign in. */ + /** Header links (HTML). Default: Home, Docs, Sign in, Add to my app. */ nav?: string; /** No top header: the sign-in pages put the logo at the top of their own column. */ bare?: boolean; @@ -250,7 +250,7 @@ export const MESH_SCRIPT = "https://genclass.dev/signin-mesh.js?v=2"; /** As on genclass.dev: plain links, then one compact green button. Narrow screens drop Home (the logo links there), * then Docs. */ const DEFAULT_NAV = - 'HomeDocsSign inGet a token'; + 'HomeDocsSign inAdd to my app'; /** The page frame shared by every page this worker serves. */ export function shell(nonce: string, title: string, body: string, script: string, opts: ShellOptions = {}): string { @@ -286,66 +286,153 @@ export const SIGNED_IN_NAV = 'AppsDocs
'; // ------------------------------------------------------------------------------------------------- /start +// One click: POST /start makes the app and its private link (collector.ts), and /start/ says the one command +// that connects it. The page has no form to fill in; the app's name can be changed later on its dashboard. -export function startPage(nonce: string, opts: { runtimeReady?: boolean; endpoint?: string | null } = {}): string { - const notYet = opts.runtimeReady === true ? "" : `

${NOT_ON_NPM}

`; +/** The command that connects an app: it finds the framework, installs the runtime and adds the import, asking once. */ +export function setupCommand(token: string): string { + return `npx ${RUNTIME_PKG} init --token ${token}`; +} + +/** The shortest prompt that gets a coding agent to do the same; --yes because an agent has no Y/n to answer. */ +export function agentPrompt(token: string): string { + return `Add GenClass to this web app: run \`${setupCommand(token)} --yes\` in the app's folder (ask me which folder if this one has no web app). Then start the app, check that it still works, and tell me what changed. \`npx ${RUNTIME_PKG} remove\` undoes it.`; +} + +/** + * Links that open a coding agent with the prompt filled in. None of them runs anything: the person reads the prompt + * and presses Enter (cursor.com/docs/integrations/deeplinks, code.claude.com/docs/en/deep-links). + */ +export function agentLinks(prompt: string): { label: string; href: string }[] { + const q = encodeURIComponent(prompt); + return [ + { label: "Cursor", href: `https://cursor.com/link/prompt?text=${q}` }, + { label: "Claude Code", href: `claude-cli://open?q=${q}` }, + { label: "Claude in VS Code", href: `vscode://anthropic.claude-code/open?prompt=${q}` }, + ]; +} + +const START_CSS = ` +.oneclick .btn.xl{min-height:52px;font-size:16px;padding:0 24px} +.cmdbox{display:flex;align-items:center;gap:10px;background:var(--code);color:var(--codeink);padding:12px 14px;min-width:0} +.cmdbox code{flex:1;min-width:0;font:14px/1.5 var(--mono);overflow-wrap:anywhere} +.cmdbox .btn{flex:none} +.setup-agents{align-items:center} +.setup-steps{margin:0;padding-left:20px;display:grid;gap:8px} +.setup-status{display:inline-flex;align-items:center;gap:8px} +.setup-status i{width:8px;height:8px;border-radius:50%;background:var(--warn);flex:none} +.setup-status.live i{background:var(--accent-f)} +`; + +/** The one button, with a