eleven / ocho Public

web: home create-screen region picker (per-country provisioning) (#1084)

The backend (#1083) lets a create choose its region via an API field; this adds
the visible control. On a home server that provisions per country
(GET /api/features → regions), the create screen shows a quiet inline
"Region [ Ireland ]" select under the pitch, defaulting to default_region; the
choice rides POST /api/spaces as `region`. Options are built as DOM nodes (never
innerHTML) from the server's code+name; the native <select> is keyboard-operable
and labelled.

Single-region (or older) home servers return no regions, so the control stays
hidden and creation auto-assigns exactly as before — no behaviour change on
production, which runs one provisioner.

Follow-on to #1083; the create-page control is the last piece of the
"auto-assign, owner can override" model. Verified on the dev env (home multi-
region) once deployed — this touches the singleton home service, not localhost.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell 0baa858c6440e49c5fcc59f220e070eea1cdee58 parent db2a9cf
2 files changed, +51 −1
  • home/web/pages/index.html +14 −0
  • home/web/static/home.js +37 −1
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 6b27dc9..62f5840 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -87,6 +87,13 @@
line-height: 1.2; letter-spacing: -0.015em; margin: 20px 0 4px;
}
.create-sub { color: var(--muted); font-size: 14px; margin: 0 0 24px; }
+ /* Region picker (only shown for a multi-region home server) — a quiet inline
+ control, not a headline: "Region [ Ireland ]". */
+ .create-region { display: inline-flex; align-items: center; gap: 8px; margin: -8px 0 24px; font-size: 13px; color: var(--muted); }
+ .create-region select {
+ font: inherit; font-size: 13px; color: var(--ink); background: transparent;
+ border: 1px solid var(--border); border-radius: 6px; padding: 3px 8px; cursor: pointer;
+ }
/* The stage: dust + confetti behind the card, full-viewport. */
#stage-dust { position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; }
main.card { position: relative; z-index: 1; will-change: transform; }
@@ -505,6 +512,13 @@
<div id="create-pitch">
<h2 class="create-title">Slide to create</h2>
<p class="create-sub">Your new space will be created immediately.</p>
+ <!-- Region picker: only shown when this home server provisions per country
+ (GET /api/features → regions). One region (or none) auto-assigns and
+ this stays hidden. -->
+ <p id="create-region" class="create-region hidden">
+ <label for="region-select">Region</label>
+ <select id="region-select"></select>
+ </p>
<!-- The goes-to-eleven fader (fader.js): slide 1→10, hold the pull for a
beat and it snaps to eleven — which fires the real create. -->
<div class="strip">
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 7179b7e..fec73e5 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -87,6 +87,11 @@ function fmtBytes(n) {
// /api/features). The create-a-space controls only appear when it's both
// enabled here and you're signed in.
let spacesEnabled = false;
+// Per-country provisioning (GET /api/features): regions is [{code,name}] and
+// regionChoice is the default the picker starts on. Empty/one region → the
+// picker stays hidden and creation auto-assigns server-side.
+let regions = [];
+let regionChoice = "";
// provisioned: host → row from GET /api/spaces — which listed spaces THIS
// account created, plus their entitlement. billingEnabled mirrors the
// provisioner's flag; both drive the quiet Upgrade affordance + Supporter
@@ -1003,6 +1008,29 @@ async function openPortal(host, btn) {
// ---- create a new space -------------------------------------------------------
+// populateRegionPicker fills the create screen's region <select> from
+// /api/features. Built as DOM nodes (never innerHTML). Shown only for 2+
+// regions — one region, or a single-region home server, auto-assigns and the
+// control stays hidden.
+function populateRegionPicker() {
+ const wrap = $("create-region");
+ const sel = $("region-select");
+ if (!wrap || !sel) return;
+ sel.textContent = "";
+ if (regions.length < 2) {
+ wrap.classList.add("hidden");
+ return;
+ }
+ for (const r of regions) {
+ const opt = document.createElement("option");
+ opt.value = r.code;
+ opt.textContent = r.name || r.code;
+ if (r.code === regionChoice) opt.selected = true;
+ sel.appendChild(opt);
+ }
+ wrap.classList.remove("hidden");
+}
+
// requestSpace is the real create call, fired by the fader the moment it snaps
// to eleven: POST /api/spaces (link-token auth) — it returns only once the
// server has confirmed the space is live and reachable, so the fader's build
@@ -1014,10 +1042,15 @@ async function requestSpace() {
// and grind it out right here, during the fader's build phase — the CPU
// burn IS the "Hold on…" moment. bits 0 = no gate, nothing to solve.
const proof = await solveCreateChallenge();
+ // The owner's region choice rides alongside the proof (empty = auto-assign
+ // server-side). The picker only exists for a home server with 2+ regions.
+ const payload = proof || {};
+ const sel = $("region-select");
+ if (regions.length > 1 && sel && sel.value) payload.region = sel.value;
const res = await fetch("/api/spaces", {
method: "POST",
headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
- body: JSON.stringify(proof || {}),
+ body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -1588,6 +1621,9 @@ $("space-search").addEventListener("input", () => { if (vault) showVault(); });
apiJSON("/api/features")
.then((f) => {
spacesEnabled = !!f.spaces;
+ regions = Array.isArray(f.regions) ? f.regions : [];
+ regionChoice = f.default_region || (regions[0] && regions[0].code) || "";
+ populateRegionPicker();
// The no-account demo path (shown signed-out, where it matters).
$("demo-offer").classList.toggle("hidden", !f.demo);
if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace());