eleven / ocho Public

server+home+web: demo countdown/claim banner — the last demo surface piece (#1062)

* server+home+web: demo countdown/claim banner — the last demo surface piece

The in-space band a demo space wears from its first second (docs/demo-spaces.md
lifecycle step 2): a live countdown across the top of the shell with "Keep this
space" linking to home's new #claim=<host> screen.

A claim flips the cloud ledger WITHOUT restarting the instance (deliberate),
so the boot-time LCHAT_ENTITLEMENT can read stale-demo for a space that's
already safe. The band therefore trusts a live status read, not the env:

- cloud: GET /api/instances/{host}/demo-status (caller-token) — "still a demo,
  and until when"; claimed/ordinary/unknown hosts are one indistinguishable
  {demo:false}, so nothing can probe existence or plan.
- home: GET /api/spaces/demo-status?host= — public, CORS-open relay reshaped
  to exactly the banner's two fields. Deliberately OUTSIDE the shared ceremony
  rate limiter (behind the router every caller shares one RemoteAddr bucket;
  an auto-fired read would drain sign-in's tokens) — a short per-host cache
  bounds the load instead.
- web (instance): the #demo-banner band — inline, occupies layout, accent-
  tinted while running, amber once ended; ticks every 30s via the new pure
  formatCountdown helper; re-checks on tab refocus so it vanishes the moment
  the space is claimed; hidden in native wrappers' webviews (same posture as
  Upgrade) and in embed mode.
- home (page): #claim=<host> deep link waits through sign-in (a demo claimant
  often has no account yet), then a one-confirm Keep screen relays the claim
  and folds the space into the account's encrypted list like a create.

Native apps deliberately don't show the band yet — a demo reaching a native
app is an add-device edge case; scoped web-first, noted in docs/demo-spaces.md.
Changelog entry held until demos flip on for real (the standing decision).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: LCHAT_CONTROL_HOME_ORIGIN — a box default for provisioned instances' home origin

cloud's controlCreate never names a home origin, so provisioner-created
spaces always got the compiled production default — right on the prod box,
wrong on any other fleet (dev mesh): their backup nudges point at prod home,
and the demo banner's live status read would ask a home that answers
{demo:false} and hide the band on a real demo. controld now takes a box-level
default (env LCHAT_CONTROL_HOME_ORIGIN / -home-origin) applied to creates
that don't name one; an explicit request value still wins. Empty keeps the
compiled default, so production needs nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* infra: dev SSH ingress default follows the operator's rotated IP (109.255.206.133)

The live SGs (eleven-dev-box + eleven-dev-mesh-ie) were updated via the AWS
CLI when SSH went dark 2026-07-17; this keeps the next tofu apply from
reverting them. PR #1043's dev-mesh tofu carries the same stale default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5dc5b8c4a6b7bd710c0593322a1b9eb869b43318 parent 04e3393
20 files changed, +648 −14
  • cloud.go +3 −0
  • controld.go +18 −2
  • controld_test.go +43 −0
  • demo.go +28 −0
  • demo_test.go +74 −0
  • docs/demo-spaces.md +8 −1
  • docs/planet-scale-guide/reference.html +1 −0
  • home/demo.go +100 −0
  • home/demo_test.go +93 −0
  • home/main.go +11 −0
  • home/web/pages/index.html +17 −0
  • home/web/static/home.js +92 −0
  • infra/dev/compute.tf +1 −1
  • main.go +5 −3
  • meter.go +3 −2
  • test/format.test.mjs +16 −1
  • web/pages/index.html +9 −3
  • web/static/format.js +13 −0
  • web/static/shell.js +71 −1
  • web/static/style.css +42 −0
diff --git a/cloud.go b/cloud.go
index 7e9a3d6..93a8751 100644
--- a/cloud.go
+++ b/cloud.go
@@ -113,6 +113,9 @@ func (s *cloudServer) handler() http.Handler {
mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler))
// Claiming a demo (demo.go) — additive, so caller-token gated like creation.
mux.HandleFunc("POST /api/instances/{host}/claim", s.callerAuth(s.claimDemoHandler))
+ // The countdown banner's live "still a demo?" check (demo.go) — read-only,
+ // relayed to browsers by home, so it discloses demo-ness and nothing else.
+ mux.HandleFunc("GET /api/instances/{host}/demo-status", s.callerAuth(s.demoStatusHandler))
mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler))
mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler)
// Usage ingest (#857): meter-token-gated, not caller-gated — an instance
diff --git a/controld.go b/controld.go
index 06b7412..1522111 100644
--- a/controld.go
+++ b/controld.go
@@ -76,7 +76,15 @@ type controld struct {
// a resource cap (hibernating rows hold no process). <=0 defaults to
// 10x maxInstances.
maxRegistered int
- mu sync.Mutex
+ // homeOrigin (env LCHAT_CONTROL_HOME_ORIGIN) is the box's default
+ // LCHAT_HOME_ORIGIN for provisioner creates that don't name one. The home
+ // origin is an environment property (which home server fronts this fleet),
+ // not per-space policy — without this, a non-production box's spaces point
+ // at the compiled production default, so their backup nudges and the demo
+ // banner's live status read ask the wrong home. Empty = keep the compiled
+ // default (production boxes need nothing).
+ homeOrigin string
+ mu sync.Mutex
}
// controlCreateReq mirrors addInstanceOpts across the socket. AdminToken is the
@@ -555,11 +563,17 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) {
if strings.EqualFold(strings.TrimSpace(req.Entitlement), "demo") {
hibernate = false
}
+ // The box default fills a create that names no home origin (see the
+ // controld.homeOrigin comment); an explicit request value still wins.
+ homeOrigin := req.HomeOrigin
+ if homeOrigin == "" {
+ homeOrigin = c.homeOrigin
+ }
res, err := addInstance(c.reg, addInstanceOpts{
Host: req.Host,
AdminToken: req.AdminToken,
AppStoreURL: req.AppStoreURL,
- HomeOrigin: req.HomeOrigin,
+ HomeOrigin: homeOrigin,
PushRelayURL: req.PushRelayURL,
PushRelayToken: req.PushRelayToken,
Entitlement: req.Entitlement,
@@ -622,6 +636,7 @@ func runControld(args []string) {
trashKeepDays := fs.Int("trash-keep-days", envInt("LCHAT_TRASH_KEEP_DAYS", 0), "purge archived (deleted) spaces from /data/trash after this many days; 0 keeps them forever")
hibernateCreates := fs.Bool("hibernate-creates", os.Getenv("LCHAT_CONTROL_HIBERNATE_CREATES") == "1", "create provisioner spaces as hibernating (activator-managed, no systemd unit) — requires the router to run with LCHAT_HIBERNATE_BUCKET")
maxRegistered := fs.Int("max-registered", envInt("LCHAT_MAX_REGISTERED", 0), "with -hibernate-creates: refuse registration beyond this many total spaces, hibernating included (0 = 10x -max-instances)")
+ homeOrigin := fs.String("home-origin", os.Getenv("LCHAT_CONTROL_HOME_ORIGIN"), "default LCHAT_HOME_ORIGIN for provisioner creates that don't name one (empty = the compiled production default)")
_ = fs.Parse(args)
tok, err := os.ReadFile(*tokenFile)
@@ -639,6 +654,7 @@ func runControld(args []string) {
minFreeGB: *minFreeGB,
hibernateCreates: *hibernateCreates,
maxRegistered: *maxRegistered,
+ homeOrigin: strings.TrimSpace(*homeOrigin),
}
if err := os.Remove(*socketPath); err != nil && !os.IsNotExist(err) {
diff --git a/controld_test.go b/controld_test.go
index 386ae47..9710cdc 100644
--- a/controld_test.go
+++ b/controld_test.go
@@ -765,3 +765,46 @@ func TestControldMaterializeIdempotent(t *testing.T) {
}
}
}
+
+// A box-level default home origin fills provisioner creates that name none
+// (LCHAT_CONTROL_HOME_ORIGIN — non-production fleets front a different home);
+// an explicit request value still wins.
+func TestControldHomeOriginDefault(t *testing.T) {
+ reg := controlTestEnv(t)
+ c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5,
+ homeOrigin: "https://home.ie.dev.example"}
+ srv := httptest.NewServer(c.handler())
+ defer srv.Close()
+
+ create := func(payload map[string]string) {
+ t.Helper()
+ body, _ := json.Marshal(payload)
+ req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer test-control-token")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create %v: HTTP %d", payload, resp.StatusCode)
+ }
+ }
+ envOrigin := func(host string) string {
+ t.Helper()
+ env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return env["LCHAT_HOME_ORIGIN"]
+ }
+
+ create(map[string]string{"host": "defaulted.example.com"})
+ if got := envOrigin("defaulted.example.com"); got != "https://home.ie.dev.example" {
+ t.Fatalf("defaulted create's LCHAT_HOME_ORIGIN = %q", got)
+ }
+ create(map[string]string{"host": "explicit.example.com", "home_origin": "https://home.other.example"})
+ if got := envOrigin("explicit.example.com"); got != "https://home.other.example" {
+ t.Fatalf("explicit create's LCHAT_HOME_ORIGIN = %q, want the request's own", got)
+ }
+}
diff --git a/demo.go b/demo.go
index bcf2413..81da983 100644
--- a/demo.go
+++ b/demo.go
@@ -241,3 +241,31 @@ func (s *cloudServer) claimDemoHandler(w http.ResponseWriter, r *http.Request) {
"host": host, "url": "https://" + host, "entitlement": entFree,
})
}
+
+// demoStatusHandler: GET /api/instances/{host}/demo-status — the countdown
+// banner's live truth. A claim flips the ledger WITHOUT restarting the
+// instance (deliberate — see claimDemoHandler), so the instance's boot-time
+// LCHAT_ENTITLEMENT can read stale-demo for a space that's already safe; the
+// banner asks here (via home's public relay) before promising anyone a death.
+// One shape for claimed, ordinary, and never-existed hosts — the answer is
+// only ever "still a demo, and until when", so the endpoint can't be used to
+// probe which hosts exist or what anyone pays.
+func (s *cloudServer) demoStatusHandler(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ _, entitlement, createdAt, ok, err := s.db.spaceByHost(host)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "status_failed", "db error")
+ return
+ }
+ if !ok || entitlement != entDemo {
+ writeJSON(w, http.StatusOK, map[string]any{"demo": false})
+ return
+ }
+ out := map[string]any{"demo": true}
+ // TTL unset (demos since turned off, row predates that) leaves the expiry
+ // unknown rather than inventing one; the banner falls back to generic copy.
+ if s.demoTTL > 0 {
+ out["expires_at"] = createdAt + int64(s.demoTTL/time.Second)
+ }
+ writeJSON(w, http.StatusOK, out)
+}
diff --git a/demo_test.go b/demo_test.go
index 5d3e121..56b0793 100644
--- a/demo_test.go
+++ b/demo_test.go
@@ -415,3 +415,77 @@ func TestClaimRefusals(t *testing.T) {
t.Fatalf("quota-full claim: HTTP %d %v", resp.StatusCode, body)
}
}
+
+// ---- the countdown banner's live status read ---------------------------------
+
+func demoStatus(t *testing.T, srv *httptest.Server, token, host string) (*http.Response, map[string]any) {
+ t.Helper()
+ req, _ := http.NewRequest("GET", srv.URL+"/api/instances/"+host+"/demo-status", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ var m map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&m)
+ return resp, m
+}
+
+func TestDemoStatus(t *testing.T) {
+ srv, s := newTestCloud(t, okControl(t))
+ s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
+ token := mintCaller(t, srv, "home")
+
+ // A live demo reports demo:true with its death-date.
+ resp := createKind(t, srv, token, "demo-status", "demo")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("demo create: HTTP %d", resp.StatusCode)
+ }
+ host, _ := decodeBody(t, resp)["host"].(string)
+ resp, body := demoStatus(t, srv, token, host)
+ if resp.StatusCode != http.StatusOK || body["demo"] != true {
+ t.Fatalf("live demo status: HTTP %d %v", resp.StatusCode, body)
+ }
+ exp, ok := body["expires_at"].(float64)
+ if !ok || int64(exp) < time.Now().Add(23*time.Hour).Unix() {
+ t.Fatalf("expires_at = %v, want ~now+24h", body["expires_at"])
+ }
+
+ // Claimed → one indistinguishable {demo:false}, same as unknown and free
+ // hosts: the read discloses demo-ness only, never existence.
+ if resp, _ := claimHost(t, srv, token, host, "alice"); resp.StatusCode != http.StatusOK {
+ t.Fatalf("claim: HTTP %d", resp.StatusCode)
+ }
+ for _, h := range []string{host, "nope.example.com"} {
+ resp, body := demoStatus(t, srv, token, h)
+ if resp.StatusCode != http.StatusOK || body["demo"] != false {
+ t.Fatalf("status for %s: HTTP %d %v, want 200 demo:false", h, resp.StatusCode, body)
+ }
+ if _, present := body["expires_at"]; present {
+ t.Fatalf("status for %s leaked expires_at: %v", h, body)
+ }
+ }
+
+ // An expired-but-unswept demo still reads demo:true, expiry in the past —
+ // the banner's "this demo has ended" state.
+ seedDemo(t, s.db, "stale.example.com", time.Now().Add(-25*time.Hour).Unix())
+ resp, body = demoStatus(t, srv, token, "stale.example.com")
+ if resp.StatusCode != http.StatusOK || body["demo"] != true {
+ t.Fatalf("expired status: HTTP %d %v", resp.StatusCode, body)
+ }
+ if exp, _ := body["expires_at"].(float64); int64(exp) >= time.Now().Unix() {
+ t.Fatalf("expired demo's expires_at not in the past: %v", body["expires_at"])
+ }
+
+ // TTL since unset (demos switched off, row survives): demo:true with the
+ // expiry left unknown rather than invented.
+ s.demoTTL = 0
+ resp, body = demoStatus(t, srv, token, "stale.example.com")
+ if resp.StatusCode != http.StatusOK || body["demo"] != true {
+ t.Fatalf("no-TTL status: HTTP %d %v", resp.StatusCode, body)
+ }
+ if _, present := body["expires_at"]; present {
+ t.Fatalf("no-TTL status invented an expiry: %v", body)
+ }
+}
diff --git a/docs/demo-spaces.md b/docs/demo-spaces.md
index fa5394d..bf16109 100644
--- a/docs/demo-spaces.md
+++ b/docs/demo-spaces.md
@@ -134,7 +134,14 @@ cloud service, physics in root controld, IP-adjacent checks in home.
2. **PR 2 — the gates.** IP-bucket semaphore + cooldown in home, adaptive PoW
difficulty, demo caps in cloud.
3. **PR 3 — the surface.** Home "Try it now", the countdown banner, the claim
- flow (including the quota-blocked variants).
+ flow (including the quota-blocked variants). *Built:* the home offer + create
+ path shipped first; the banner + claim landed next as the in-space band
+ ("Keep this space" → home's `#claim=<host>` screen). The banner trusts the
+ HOME server's live status (`GET /api/spaces/demo-status`, relaying the
+ cloud's per-host read), not the boot-time entitlement env — a claim flips
+ the ledger without restarting the instance, so only the live read can hide
+ the band. Native apps don't show the band yet (a demo reaching a native app
+ is an add-device edge case; deliberate web-first scope).
4. **PR 4 — pool coupling.** TTL and live-cap become pressure-tunable once
`docs/free-pool.md` PR 3 lands.
diff --git a/docs/planet-scale-guide/reference.html b/docs/planet-scale-guide/reference.html
index 7f35be6..513ab35 100644
--- a/docs/planet-scale-guide/reference.html
+++ b/docs/planet-scale-guide/reference.html
@@ -125,6 +125,7 @@
<tr><td><code>LCHAT_CONTROL_HIBERNATE_CREATES</code></td><td>off</td><td><code>1</code> = every provisioner-created space is born hibernating. <a href="spaces.html#hibernating-creates">Part 05</a>.</td></tr>
<tr><td><code>LCHAT_MAX_REGISTERED</code></td><td>0 (= 10 &times; max instances)</td><td>Row ceiling counting hibernating registrations.</td></tr>
<tr><td><code>LCHAT_TRASH_KEEP_DAYS</code></td><td>0 (keep forever)</td><td>Age at which archived (deleted) spaces in <code>/data/trash</code> are purged.</td></tr>
+ <tr><td><code>LCHAT_CONTROL_HOME_ORIGIN</code></td><td>empty (compiled production default)</td><td>Default <code>LCHAT_HOME_ORIGIN</code> for provisioner creates that name none — set on non-production fleets so their spaces point at the right home server (backup nudges, the demo banner's live status read).</td></tr>
<tr><td><code>LCHAT_CONTROL_SOCKET</code> / <code>LCHAT_CONTROL_TOKEN_FILE</code></td><td><code>/run/lchat/control.sock</code> / <code>/etc/lchat/control.token</code></td><td>Where it listens / the root-only bearer token gating it.</td></tr>
</table>
</div>
diff --git a/home/demo.go b/home/demo.go
index de0a555..1eec7f1 100644
--- a/home/demo.go
+++ b/home/demo.go
@@ -355,3 +355,103 @@ func (c *cloudProvisioner) claimSpace(host, requester string) (int, map[string]a
payload, _ := json.Marshal(map[string]string{"requester": requester})
return c.do("POST", "/api/instances/"+url.PathEscape(host)+"/claim", payload)
}
+
+// demoStatusCache collapses the banner's repeated status reads (one per page
+// load and per tab refocus, on every open demo tab) into at most one
+// provisioner round trip per host per window. It exists so this public,
+// auto-fired read can stay OUT of the shared ceremony rate limiter without
+// becoming a relay amplifier at the cloud. Size-bounded: a flood of made-up
+// hosts degrades to uncached reads, never to unbounded memory.
+type demoStatusCache struct {
+ mu sync.Mutex
+ entries map[string]demoStatusEntry
+}
+
+type demoStatusEntry struct {
+ demo bool
+ expiresAt int64 // unix seconds; 0 = none reported
+ until time.Time
+}
+
+const demoStatusTTL = 10 * time.Second
+const demoStatusCacheMax = 4096
+
+func (c *demoStatusCache) get(host string, now time.Time) (demoStatusEntry, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ e, ok := c.entries[host]
+ if !ok || now.After(e.until) {
+ return demoStatusEntry{}, false
+ }
+ return e, true
+}
+
+func (c *demoStatusCache) put(host string, e demoStatusEntry, now time.Time) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.entries == nil {
+ c.entries = map[string]demoStatusEntry{}
+ }
+ if len(c.entries) >= demoStatusCacheMax {
+ for h, old := range c.entries {
+ if now.After(old.until) {
+ delete(c.entries, h)
+ }
+ }
+ if len(c.entries) >= demoStatusCacheMax {
+ return // still full of live entries — skip caching this one
+ }
+ }
+ e.until = now.Add(demoStatusTTL)
+ c.entries[host] = e
+}
+
+// demoStatusHandler: GET /api/spaces/demo-status?host=… — the in-space
+// countdown banner's live check. Public and CORS-open on purpose: the banner
+// renders on the demo space's own origin before any account exists, and a
+// claim flips the cloud ledger without restarting the instance, so this is
+// the only truth the banner can trust. It answers "still a demo, and until
+// when" and nothing else — claimed, ordinary, and never-existed hosts all
+// come back {demo:false}, so it can't probe what exists or who pays. Gated on
+// s.cloud only (NOT s.demo): live demos must keep their countdown even if the
+// create gate is later switched off.
+func (s *Server) demoStatusHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ if s.cloud == nil {
+ writeJSON(w, http.StatusOK, map[string]any{"demo": false})
+ return
+ }
+ host := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("host")))
+ if host == "" || len(host) > 255 {
+ writeJSON(w, http.StatusBadRequest, map[string]any{
+ "error": "bad_request", "message": "host required",
+ })
+ return
+ }
+ now := time.Now()
+ e, hit := s.demoStatus.get(host, now)
+ if !hit {
+ status, body, err := s.cloud.demoStatus(host)
+ if err != nil || status != http.StatusOK {
+ writeJSON(w, http.StatusBadGateway, map[string]any{"error": "status_failed"})
+ return
+ }
+ e.demo, _ = body["demo"].(bool)
+ if exp, ok := body["expires_at"].(float64); ok && e.demo {
+ e.expiresAt = int64(exp)
+ }
+ s.demoStatus.put(host, e, now)
+ }
+ // Re-shape rather than relay: only the two fields the banner needs cross
+ // the public boundary, whatever the provisioner adds later.
+ out := map[string]any{"demo": e.demo}
+ if e.demo && e.expiresAt > 0 {
+ out["expires_at"] = e.expiresAt
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// demoStatus forwards one status read to the provisioner.
+func (c *cloudProvisioner) demoStatus(host string) (int, map[string]any, error) {
+ return c.do("GET", "/api/instances/"+url.PathEscape(host)+"/demo-status", nil)
+}
diff --git a/home/demo_test.go b/home/demo_test.go
index 18ad30f..f70ba0b 100644
--- a/home/demo_test.go
+++ b/home/demo_test.go
@@ -305,3 +305,96 @@ func TestClaimRelay(t *testing.T) {
t.Fatalf("weird relay: HTTP %d %v", resp.StatusCode, body)
}
}
+
+// ---- the countdown banner's live status read -----------------------------------
+
+func TestDemoStatusRelay(t *testing.T) {
+ cloud, captured, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "demo": true, "expires_at": 1999999999, "internal": "never-relayed",
+ })
+ ts, _ := newDemoTestServer(t, cloud.URL)
+
+ get := func(query string) (*http.Response, map[string]any) {
+ t.Helper()
+ resp, err := http.Get(ts.URL + "/api/spaces/demo-status" + query)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var m map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&m)
+ return resp, m
+ }
+
+ // No auth needed; host required.
+ if resp, body := get(""); resp.StatusCode != http.StatusBadRequest || body["error"] != "bad_request" {
+ t.Fatalf("no host: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // Happy path: re-shaped to exactly the banner's two fields, CORS-open (the
+ // banner fetches from the demo space's own origin), forwarded to the
+ // cloud's per-host route.
+ resp, body := get("?host=8OE.11ch.at")
+ if resp.StatusCode != http.StatusOK || body["demo"] != true {
+ t.Fatalf("status: HTTP %d %v", resp.StatusCode, body)
+ }
+ if exp, _ := body["expires_at"].(float64); int64(exp) != 1999999999 {
+ t.Fatalf("expires_at = %v", body["expires_at"])
+ }
+ if _, leaked := body["internal"]; leaked {
+ t.Fatalf("relayed a field beyond the banner's two: %v", body)
+ }
+ if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
+ t.Fatal("missing CORS header — the banner's cross-origin fetch would fail")
+ }
+ if (*captured).URL.Path != "/api/instances/8oe.11ch.at/demo-status" {
+ t.Fatalf("forwarded path = %s (host not lowercased?)", (*captured).URL.Path)
+ }
+
+ // Repeats inside the cache window never re-hit the provisioner (the read
+ // auto-fires per tab load/refocus) — and the endpoint sits outside the
+ // shared ceremony limiter, so a burst can't 429 (drained tokens would
+ // starve sign-in, since the router funnels every caller into one bucket).
+ ts4, s := newDemoTestServer(t, cloud.URL)
+ s.limiter = newRateLimiter(0, 0) // every rateLimited endpoint would 429
+ for i := 0; i < 3; i++ {
+ if resp, body := getFrom(t, ts4, "/api/spaces/demo-status?host=cached.11ch.at"); resp.StatusCode != http.StatusOK || body["demo"] != true {
+ t.Fatalf("read %d: HTTP %d %v", i, resp.StatusCode, body)
+ }
+ }
+ cloud.Close() // cached answers must not need the upstream at all
+ if resp, body := getFrom(t, ts4, "/api/spaces/demo-status?host=cached.11ch.at"); resp.StatusCode != http.StatusOK || body["demo"] != true {
+ t.Fatalf("cached read after upstream gone: HTTP %d %v", resp.StatusCode, body)
+ }
+
+ // A non-demo answer carries no expiry even if the upstream sent one.
+ claimed, _, _ := fakeCloud(t, http.StatusOK, map[string]any{"demo": false, "expires_at": 123})
+ ts2, _ := newDemoTestServer(t, claimed.URL)
+ resp, body = getFrom(t, ts2, "/api/spaces/demo-status?host=x.11ch.at")
+ if resp.StatusCode != http.StatusOK || body["demo"] != false {
+ t.Fatalf("claimed status: HTTP %d %v", resp.StatusCode, body)
+ }
+ if _, present := body["expires_at"]; present {
+ t.Fatalf("claimed status leaked an expiry: %v", body)
+ }
+
+ // No provisioner wired = no demos, quietly.
+ ts3, _ := newDemoTestServer(t, "")
+ resp, body = getFrom(t, ts3, "/api/spaces/demo-status?host=x.11ch.at")
+ if resp.StatusCode != http.StatusOK || body["demo"] != false {
+ t.Fatalf("no-cloud status: HTTP %d %v", resp.StatusCode, body)
+ }
+}
+
+// getFrom fetches a JSON GET off one of the harness servers.
+func getFrom(t *testing.T, ts *httptest.Server, path string) (*http.Response, map[string]any) {
+ t.Helper()
+ resp, err := http.Get(ts.URL + path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var m map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&m)
+ return resp, m
+}
diff --git a/home/main.go b/home/main.go
index 7f22837..81f48b6 100644
--- a/home/main.go
+++ b/home/main.go
@@ -58,6 +58,9 @@ type Server struct {
// demo is the account-free demo-space surface and its abuse gates
// (demo.go); nil = demos not offered here (LCHAT_HOME_DEMO unset).
demo *demoGate
+ // demoStatus caches the countdown banner's live status reads (demo.go) —
+ // zero value ready.
+ demoStatus demoStatusCache
// origin is this server's public origin (also the WebAuthn RP origin) —
// used to build the checkout return URLs.
origin string
@@ -309,6 +312,14 @@ func (s *Server) handler() http.Handler {
// Claiming a demo as your free space — additive, so the link token
// suffices (same reasoning as creation).
mux.HandleFunc("POST /api/spaces/claim", s.rateLimited(s.claimSpaceHandler))
+ // The countdown banner's live "still a demo?" read — public + CORS-open
+ // (it renders on the demo space's origin, pre-account); discloses only
+ // demo-ness + expiry, never existence or ownership. Deliberately OUTSIDE
+ // the shared ceremony limiter: this read auto-fires on every demo-tab load
+ // and refocus, and behind the router every caller shares one RemoteAddr
+ // bucket — it would drain the tokens sign-in ceremonies need. A short
+ // per-host cache bounds the load instead (demoStatusCache).
+ mux.HandleFunc("GET /api/spaces/demo-status", s.demoStatusHandler)
// Which spaces this user provisioned (read; drives the delete affordance)…
mux.HandleFunc("GET /api/spaces", s.listSpacesHandler)
// …and permanent deletion of one — ceremony-gated like every destructive op.
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 5ce5915..9a04361 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -519,6 +519,23 @@
<p class="status" id="upgrade-status"></p>
</div>
+ <!-- Keep a demo space: the in-space countdown banner deep-links here as
+ #claim=<host>. One confirm, one call — the provisioner does the real
+ work (ownership, quota, expiry guard); success folds the space into
+ this account's list exactly like a create. -->
+ <div id="claim" class="hidden screen">
+ <button id="claim-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title">Keep <span id="claim-host"></span></h2>
+ <p class="create-sub">Claiming stops this demo space's 24-hour countdown —
+ it becomes one of your free spaces, saved to your list, and everything
+ in it stays.</p>
+ <div class="actions">
+ <button id="claim-go" type="button">Keep this space</button>
+ <a id="claim-open" class="btn-link hidden" href="#">Open your space</a>
+ </div>
+ <p class="status" id="claim-status"></p>
+ </div>
+
<!-- The archive: spaces you've set aside, hidden from the main list. Bring one
back (unarchive), or delete it forever behind a stern typed-confirmation —
"delete your access" (held while the space reports this is its ONLY admin
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 80eff93..6fae43e 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -100,6 +100,10 @@ if (upgradedHost) history.replaceState(null, "", location.pathname + location.ha
// A space's own menu deep-links here as #upgrade=<host>; consumed once the
// provisioner ledger is in hand (before sign-in it just waits in the fragment).
let upgradeLinkHost = ((location.hash.match(/^#upgrade=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase();
+// A demo space's countdown banner deep-links here as #claim=<host>; waits in
+// the fragment through sign-in — a demo needs no account, so its claimant
+// often has to create their sign-in first, and the claim resumes after.
+let claimLinkHost = ((location.hash.match(/^#claim=([A-Za-z0-9.-]+)$/) || [])[1] || "").toLowerCase();
// Ceremony options are pre-fetched so the button click reaches
// navigator.credentials.* synchronously — Safari drops the user activation
@@ -249,6 +253,7 @@ function signOut() {
$("vault").classList.add("hidden");
$("create").classList.add("hidden");
$("upgrade").classList.add("hidden");
+ $("claim").classList.add("hidden");
$("archive").classList.add("hidden");
archiving = null;
clearUndo();
@@ -330,6 +335,10 @@ function showVault() {
upgradedHost = "";
setTimeout(() => { provisioned = null; if (vault && !$("vault").classList.contains("hidden")) showVault(); }, 5000);
}
+
+ // A pending #claim=<host> deep link resumes here, the first vault render
+ // after sign-in — it needs only the account, not the provisioner ledger.
+ consumeClaimLink();
}
// refreshProvisioned learns which spaces this account created and their
@@ -768,6 +777,81 @@ async function startUpgrade(host, tier, box) {
}
}
+// ---- keep (claim) a demo space --------------------------------------------------
+
+function claimStatus(msg, kind) {
+ const el = $("claim-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// consumeClaimLink honours a #claim=<host> deep link (the demo banner's "Keep
+// this space") once signed in: opens the claim screen for that host. Cleared
+// either way, so a stale fragment can't re-fire the screen on later renders.
+function consumeClaimLink() {
+ if (!claimLinkHost || !vault) return;
+ const host = claimLinkHost;
+ claimLinkHost = "";
+ history.replaceState(null, "", location.pathname + location.search);
+ openClaim(host);
+}
+
+// openClaim shows the one-confirm claim screen. No pre-checks here — the
+// provisioner's answers (quota, not-a-demo, expired) are member-facing and
+// shown verbatim, so asking twice would only add a failure mode.
+function openClaim(host) {
+ $("vault").classList.add("hidden");
+ $("claim").classList.remove("hidden");
+ $("claim-host").textContent = host;
+ claimStatus("");
+ const go = $("claim-go");
+ go.hidden = false;
+ go.disabled = false;
+ go.onclick = () => doClaim(host, go);
+ $("claim-open").classList.add("hidden");
+}
+
+function closeClaim() {
+ $("claim").classList.add("hidden");
+ showVault();
+}
+
+// doClaim is the real call: home relays to the provisioner (ownership
+// transfer, quota, expiry guard), then the space folds into this account's
+// encrypted list exactly like a create — sign-in always finds the way back.
+async function doClaim(host, btn) {
+ btn.disabled = true;
+ claimStatus("Claiming " + host + "…", "busy");
+ try {
+ const res = await fetch("/api/spaces/claim", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
+ body: JSON.stringify({ host }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(body.message || body.error || `Couldn't claim this space (HTTP ${res.status}).`);
+ const url = body.url || "https://" + host;
+ vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
+ vault.obj.servers.push({ url, name: host });
+ vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins
+ try {
+ await saveVault();
+ } catch {
+ // The claim stuck even if the vault save raced; the entry lands on the
+ // next successful save.
+ }
+ provisioned = null; // the ledger now lists this space as this account's
+ claimStatus("This space is yours now — the countdown is off, and it's saved to your list.", "ok");
+ btn.hidden = true;
+ const open = $("claim-open");
+ open.href = url;
+ open.classList.remove("hidden");
+ } catch (e) {
+ claimStatus(e.message || "Couldn't claim this space.", "error");
+ btn.disabled = false;
+ }
+}
+
// openPortal hands off to Stripe's Billing Portal for one space — cancel,
// change card, invoices. Same-tab round trip; the portal's Return link comes
// back here.
@@ -1330,6 +1414,13 @@ const onCreatePasskeyError = (err) => {
setSignedOutBusy(false);
};
+// Arriving from a demo banner's "Keep this space" while signed out: say why
+// you're looking at a sign-in page. The fragment waits; the claim screen
+// opens on the first vault render after sign-in (or after creating one).
+if (claimLinkHost && !(state.token && state.dek)) {
+ setStatus("Sign in — or create your sign-in — to keep your demo space.", "");
+}
+
if (!window.PublicKeyCredential) {
setStatus("This browser can't sign you in — try another browser.", "error");
setSignedOutBusy(true);
@@ -1348,6 +1439,7 @@ $("create-done").addEventListener("click", closeCreate);
// (Creating a space is the fader now — mounted in openCreate; see fader.js.)
$("archive-back").addEventListener("click", closeArchive);
$("upgrade-back").addEventListener("click", closeUpgrade);
+$("claim-back").addEventListener("click", closeClaim);
$("archive-cancel").addEventListener("click", closeArchiveStern);
$("delete-access").addEventListener("click", deleteAccess);
$("destroy-forever").addEventListener("click", destroyForEveryone);
diff --git a/infra/dev/compute.tf b/infra/dev/compute.tf
index a21d1e1..2a0487e 100644
--- a/infra/dev/compute.tf
+++ b/infra/dev/compute.tf
@@ -26,7 +26,7 @@ resource "aws_key_pair" "dev_box" {
variable "ssh_ingress_cidr" {
description = "Where SSH is allowed from (the operator's current IP/32)"
type = string
- default = "109.255.91.23/32"
+ default = "109.255.206.133/32"
}
resource "aws_security_group" "dev_box" {
diff --git a/main.go b/main.go
index b99c314..46e6d91 100644
--- a/main.go
+++ b/main.go
@@ -84,9 +84,11 @@ type Server struct {
// the enrolment cross-origin contract); set LCHAT_HOME_ORIGIN per deployment.
homeOrigin string
// The cloud ledger's plan flag, mirrored into this instance's env by the
- // provisioner (LCHAT_ENTITLEMENT, "free"/"paid"). Gates the space menu's
- // Upgrade entry; empty on self-hosted/operator instances, which have no
- // billing surface and show nothing.
+ // provisioner (LCHAT_ENTITLEMENT, "free"/"paid"/"demo"). Gates the space
+ // menu's Upgrade entry (free) and the demo countdown band (demo); empty on
+ // self-hosted/operator instances, which have no billing surface and show
+ // nothing. Display only — a claim flips the ledger without restarting this
+ // process, so the band verifies against home's live status before showing.
entitlement string
// Where this instance's webhook-ingest sidecar is reachable from outside
// (LCHAT_HOOKS_BASE, e.g. "http://127.0.0.1:8090" in dev). Empty — the
diff --git a/meter.go b/meter.go
index 7b17ee5..35b39dc 100644
--- a/meter.go
+++ b/meter.go
@@ -276,8 +276,9 @@ func updateInstanceEnv(envPath string, mutate func(env map[string]string)) error
// setInstanceQuota upserts LCHAT_BLOB_QUOTA_BYTES in an instance's env file
// (0 removes it — back to the compiled default) and, when entitlement is
-// non-empty, mirrors LCHAT_ENTITLEMENT ("free"/"paid" — the UI's billing-
-// affordance gate) in the same write, preserving every other var.
+// non-empty, mirrors LCHAT_ENTITLEMENT ("free"/"paid"/"demo" — the UI's
+// billing-affordance / demo-band gate) in the same write, preserving every
+// other var.
func setInstanceQuota(envPath string, quota int64, entitlement string) error {
return updateInstanceEnv(envPath, func(env map[string]string) {
if quota <= 0 {
diff --git a/test/format.test.mjs b/test/format.test.mjs
index 65551ce..5980410 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -1,6 +1,6 @@
// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
import assert from "node:assert/strict";
-import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs } from "../web/static/format.js";
+import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js";
import { emojify, emojiMatches } from "../web/static/emoji.js";
let failures = 0;
@@ -473,6 +473,21 @@ await test("emphasis markers around a URL never ride into the href", () => {
assert.ok(!link2.href.endsWith("*"));
});
+await test("formatCountdown: whole units, ceiled, never optimistic", () => {
+ const MIN = 60000;
+ assert.equal(formatCountdown(0), "under a minute");
+ assert.equal(formatCountdown(30 * 1000), "under a minute");
+ assert.equal(formatCountdown(MIN), "under a minute");
+ assert.equal(formatCountdown(MIN + 1), "2m"); // ceil: the label outlives the space, not vice versa
+ assert.equal(formatCountdown(45 * MIN), "45m");
+ assert.equal(formatCountdown(59 * MIN), "59m");
+ assert.equal(formatCountdown(59 * MIN + 1), "1h"); // 59m + a tick ceils to the hour
+ assert.equal(formatCountdown(60 * MIN), "1h");
+ assert.equal(formatCountdown(60 * MIN + 1), "1h 1m");
+ assert.equal(formatCountdown(23 * 60 * MIN + 40 * MIN), "23h 40m");
+ assert.equal(formatCountdown(24 * 60 * MIN), "24h");
+});
+
await test("bare domains glued mid-token stay plain (the lookbehind rule)", () => {
// Mid-word, email, and mid-hyphenated-token candidates never link — and a
// hyphenated host still links whole, from its clean start.
diff --git a/web/pages/index.html b/web/pages/index.html
index be18337..df24149 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -26,9 +26,10 @@
is public, so exposing it here is fine. -->
<script nonce="__CSP_NONCE__">
window.__homeOrigin = "__HOME_ORIGIN__";
- // The provisioner-pushed plan flag ("free"/"paid"; empty when self-hosted).
- // shell.js reveals the menu's Upgrade entry from it — display only, nothing
- // here enforces billing.
+ // The provisioner-pushed plan flag ("free"/"paid"/"demo"; empty when
+ // self-hosted). shell.js reveals the menu's Upgrade entry (free) or the
+ // demo countdown band (demo) from it — display only, nothing here enforces
+ // billing or the demo TTL.
window.__entitlement = "__ENTITLEMENT__";
</script>
<!-- Pre-paint bounce: a signed-out browser visit belongs on home's sign-in
@@ -126,6 +127,11 @@
top band shown ONLY when this space fails an integrity check. Inline
(occupies layout, never overlays); contents built in shell.js. -->
<div class="integrity-banner hidden" id="integrity-banner" role="alert"></div>
+ <!-- Demo countdown band (docs/demo-spaces.md): shown only on a demo space.
+ Inline (occupies layout, never overlays); contents built in shell.js
+ from the HOME server's live status — the boot-time entitlement can go
+ stale the moment someone claims the space. -->
+ <div class="demo-banner hidden" id="demo-banner"></div>
<div class="tt-body">
<aside class="sidebar">
<header class="side-head">
diff --git a/web/static/format.js b/web/static/format.js
index 3b1c12d..4465d20 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -379,3 +379,16 @@ export function relTime(sec) {
if ((now - d) / 86400000 < 7) return d.toLocaleDateString([], { weekday: "short" });
return d.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
}
+
+// formatCountdown renders a remaining duration as a compact whole-unit
+// countdown for the demo banner: "23h 40m" above an hour, "40m" under one,
+// "under a minute" at the end. Minutes are ceiled so the promise is never
+// optimistic — the space outlives the label, not the other way round.
+export function formatCountdown(ms) {
+ const mins = Math.ceil(ms / 60000);
+ if (mins <= 1) return "under a minute";
+ if (mins < 60) return mins + "m";
+ const h = Math.floor(mins / 60);
+ const m = mins % 60;
+ return m ? h + "h " + m + "m" : h + "h";
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index e234865..87a1902 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -48,7 +48,7 @@ import {
capAllows,
appReloadMessage,
} from "/static/ui.js";
-import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime } from "/static/format.js";
+import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
import { startBootScene, finishBootScene } from "/static/bootscene.js";
import { processImage, videoMeta } from "/static/media.js";
@@ -2659,6 +2659,76 @@ function renderApp() {
);
}
+ // Demo countdown band (docs/demo-spaces.md): a demo space wears its 24-hour
+ // death across the top — disclosed from the first second, with the one happy
+ // exit (claim it into an account, on the home page) a tap away. The boot-time
+ // flag only says "born a demo": a claim flips the ledger WITHOUT restarting
+ // this instance, so the HOME server's live status decides whether the band
+ // shows at all, and hides it when the user comes back from claiming.
+ if (window.__entitlement === "demo" && window.__homeOrigin && !embed) {
+ const banner = document.getElementById("demo-banner");
+ const text = document.createElement("span");
+ text.className = "demo-banner-text";
+ let ticker = 0;
+ const dismiss = () => {
+ banner.classList.add("hidden");
+ if (ticker) clearInterval(ticker);
+ ticker = 0;
+ };
+ const paint = (expiresAt) => {
+ const left = expiresAt ? expiresAt * 1000 - Date.now() : NaN;
+ if (Number.isNaN(left)) {
+ text.textContent = "This is a demo space — it disappears 24 hours after it was created.";
+ } else if (left <= 0) {
+ text.textContent = "This demo has ended — the space will disappear shortly.";
+ banner.classList.add("demo-over");
+ } else {
+ text.textContent = "This is a demo space — it disappears in " + formatCountdown(left) + ".";
+ }
+ };
+ const check = async () => {
+ let expiresAt = 0; // unix seconds; 0 = status unreachable (generic copy)
+ try {
+ const res = await fetch(
+ window.__homeOrigin + "/api/spaces/demo-status?host=" + encodeURIComponent(location.host),
+ );
+ if (!res.ok) throw new Error();
+ const body = await res.json();
+ if (!body.demo) return dismiss(); // claimed — the space is safe now
+ expiresAt = body.expires_at || 0;
+ } catch {
+ /* home unreachable — the death is still the contract; show the band */
+ }
+ if (!banner.contains(text)) {
+ banner.append(text);
+ // Claiming is free (not a purchase), but the native wrappers keep all
+ // account plumbing out-of-app for now — same posture as Upgrade above.
+ if (!inNativeWrapper) {
+ const keep = document.createElement("a");
+ keep.className = "demo-banner-keep";
+ keep.textContent = "Keep this space";
+ keep.href = window.__homeOrigin + "/#claim=" + encodeURIComponent(location.host);
+ keep.target = "_blank";
+ keep.rel = "noopener";
+ banner.append(keep);
+ }
+ }
+ paint(expiresAt);
+ banner.classList.remove("hidden");
+ if (ticker) clearInterval(ticker);
+ if (expiresAt) ticker = setInterval(() => paint(expiresAt), 30_000);
+ };
+ check();
+ // The claim happens in another tab; re-check when this one comes back into
+ // view so the band vanishes the moment the space is actually theirs.
+ // (renderApp can re-mount the template — a listener over a replaced band
+ // retires itself via isConnected.)
+ document.addEventListener("visibilitychange", () => {
+ if (!banner.isConnected) return;
+ if (document.visibilityState === "visible" && !banner.classList.contains("hidden")) check();
+ });
+ }
+
// Notifications toggle (hidden where push isn't usable, e.g. iOS before install).
const notif = document.getElementById("menu-notif");
const refreshNotif = async () => {
diff --git a/web/static/style.css b/web/static/style.css
index 2e6b027..eec546a 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2061,6 +2061,48 @@ input.switch:disabled { opacity: 0.5; cursor: default; }
.integrity-banner { flex-wrap: wrap; }
.integrity-banner .ib-actions { width: 100%; }
}
+
+/* Demo countdown band: same full-width top-band idiom as the integrity banner,
+ but informational, not an alarm — accent-tinted while the demo runs, amber
+ only once it has ended. Occupies layout, never overlays. */
+.demo-banner {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 8px 14px;
+ padding: 9px 16px;
+ padding-top: calc(9px + env(safe-area-inset-top, 0px));
+ background: color-mix(in srgb, var(--accent) 12%, var(--panel));
+ border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
+ color: var(--ink);
+ font-size: 13.5px;
+ line-height: 1.45;
+ text-align: center;
+}
+.demo-banner.hidden { display: none; }
+.demo-banner .demo-banner-text { font-variant-numeric: tabular-nums; }
+.demo-banner .demo-banner-keep {
+ font: inherit;
+ font-weight: 600;
+ border-radius: 8px;
+ padding: 4px 12px;
+ min-height: 24px;
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+ background: var(--accent);
+ color: #fff;
+}
+.demo-banner .demo-banner-keep:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.demo-banner.demo-over {
+ background: color-mix(in srgb, #d97706 16%, var(--panel));
+ border-bottom-color: color-mix(in srgb, #d97706 45%, transparent);
+}
+@media (prefers-color-scheme: dark) {
+ .demo-banner.demo-over { background: color-mix(in srgb, #f59e0b 22%, var(--panel)); }
+}
/* floating connection-status pill (only shown during a sustained outage) */
.conn-status {
position: fixed;