← back to Studio Zero
DTD-A: persistent honesty banner when a live call falls back to demo
87f86c85d50e75f9326ba58fe740bd3a24d2a54e · 2026-07-26 19:39:16 -0700 · Steve Abrams
server.js flags a failed-live→demo response fallback:true (distinct from
intentional demo mode). Front-end raises a persistent 'generation unavailable —
placeholder demo content' banner (real reason surfaced, fallback-demo badge,
survives step navigation) so canned demo is never mistaken for a real result.
Adds self-contained test/fallback.spec.js regression guard (7 checks, green).
Existing ui.spec.js still 18/18 green; demo mode correctly stays banner-free.
v0.3.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M README.mdM VERSIONM public/index.htmlM server.jsM test/README.mdA test/fallback.spec.js
Diff
commit 87f86c85d50e75f9326ba58fe740bd3a24d2a54e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 26 19:39:16 2026 -0700
DTD-A: persistent honesty banner when a live call falls back to demo
server.js flags a failed-live→demo response fallback:true (distinct from
intentional demo mode). Front-end raises a persistent 'generation unavailable —
placeholder demo content' banner (real reason surfaced, fallback-demo badge,
survives step navigation) so canned demo is never mistaken for a real result.
Adds self-contained test/fallback.spec.js regression guard (7 checks, green).
Existing ui.spec.js still 18/18 green; demo mode correctly stays banner-free.
v0.3.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
README.md | 6 +++++
VERSION | 2 +-
public/index.html | 35 +++++++++++++++++++------
server.js | 5 +++-
test/README.md | 10 ++++++++
test/fallback.spec.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 119 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index a09fe5b..cc24285 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,12 @@ PORT=4173 node server.js # open http://localhost:4173
(override `SZ_MODEL`).
- **Demo mode** (no key): every step returns a canned deliverable so the whole
flow is clickable at `$0`.
+- **Fallback honesty**: if a *live* provider call fails mid-generation (e.g. an
+ expired CLI/OAuth token 401s), the server substitutes canned demo content but
+ flags it `fallback:true`, and the UI raises a **persistent "generation
+ unavailable — placeholder demo content" banner** (with the real reason) plus a
+ `fallback demo` badge — so a failed call is never silently mistaken for a real
+ result. Guarded by `test/fallback.spec.js`.
Each generate shows its **estimated + actual $ cost**; the header keeps a running
session total.
diff --git a/VERSION b/VERSION
index 0ea3a94..9325c3c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.0
+0.3.0
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index a320f9f..aaff77d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -92,6 +92,11 @@
.out .badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--mut)}
.out .badge.demo{border-color:#5a4a2b;color:#e6c98e}
.out .badge.live{border-color:#2b5a3f;color:#8ee6b6}
+ .out .badge.fallback{border-color:var(--red2);color:#ff9a9a}
+ .fbanner{display:none;margin:14px 0 4px;padding:11px 14px;border:1px solid var(--red2);border-left:3px solid var(--red);border-radius:8px;background:rgba(198,40,40,.10);color:#ffb3b3;font-size:13px;line-height:1.45}
+ .fbanner.show{display:block}
+ .fbanner b{color:#ff8a8a}
+ .fbanner .rs{color:var(--mut);font-size:12px}
.md{color:#e9e9ee}
.md h3{font-size:17px;margin:16px 0 8px} .md h4{font-size:15px;margin:14px 0 6px;color:var(--orange2)}
.md p{margin:8px 0} .md ul,.md ol{margin:8px 0;padding-left:22px} .md li{margin:3px 0}
@@ -270,7 +275,7 @@ async function generateInto(step,vals){
const res=await fetch('/api/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,stepKey:step.key})});
const j=await res.json();
if(vals['[NICHE]'])STATE.niche=vals['[NICHE]'];
- STATE.outputs[step.key]={text:j.text,demo:j.demo,cost:j.cost||0,maxPlan:!!j.maxPlan,equiv:j.equiv||null,at:new Date().toISOString()};
+ STATE.outputs[step.key]={text:j.text,demo:j.demo,fallback:!!j.fallback,warning:j.warning||null,cost:j.cost||0,maxPlan:!!j.maxPlan,equiv:j.equiv||null,at:new Date().toISOString()};
STATE.costs[step.key]=j.cost||0;
if(j.cost){sessionCost+=j.cost;updateCostChip();}
persist();
@@ -371,6 +376,7 @@ function renderStep(){
</div>
<div class="out ${saved?'show':''}" id="out">
<div class="ohead"><span class="t">Deliverable</span><span class="badge" id="outBadge"></span><span style="flex:1"></span><button class="btn ghost sm" id="copyBtn">Copy</button></div>
+ <div class="fbanner" id="fbanner"></div>
<div id="calWrap"></div>
<div class="md" id="mdOut"></div>
</div>
@@ -390,7 +396,7 @@ function renderStep(){
fld.addEventListener('input',()=>{$('#pbtext').innerHTML=promptHTML(step,currentVals(step));updateEst();});
});
updateEst();
- if(saved){renderOutput(step,saved.text,saved.demo,saved.cost,saved.maxPlan,saved.equiv);}
+ if(saved){renderOutput(step,saved.text,saved.demo,saved.cost,saved.maxPlan,saved.equiv,saved.fallback,saved.warning);}
$('#genBtn').onclick=doGenerate;
$('#prevBtn').onclick=()=>{if(cur>0){cur--;renderStep();renderStepper();}};
$('#nextBtn').onclick=()=>{if(cur<6){cur++;renderStep();renderStepper();}};
@@ -417,12 +423,12 @@ async function doGenerate(){
const j=await res.json();
const nicheVal=vals['[NICHE]'];if(nicheVal)STATE.niche=nicheVal;
if(STATE.name==='Untitled channel'&&STATE.niche)STATE.name=STATE.niche.slice(0,42);
- STATE.outputs[step.key]={text:j.text,demo:j.demo,cost:j.cost||0,maxPlan:!!j.maxPlan,equiv:j.equiv||null,at:new Date().toISOString()};
+ STATE.outputs[step.key]={text:j.text,demo:j.demo,fallback:!!j.fallback,warning:j.warning||null,cost:j.cost||0,maxPlan:!!j.maxPlan,equiv:j.equiv||null,at:new Date().toISOString()};
STATE.costs[step.key]=j.cost||0;
if(j.cost){sessionCost+=j.cost;updateCostChip();}
- if(j.warning)toast('Live call failed — showing demo');
+ if(j.fallback)toast('⚠ Live generation failed — showing placeholder demo');
persist();
- renderOutput(step,j.text,j.demo,j.cost,j.maxPlan,j.equiv);
+ renderOutput(step,j.text,j.demo,j.cost,j.maxPlan,j.equiv,j.fallback,j.warning);
renderStepper();renderBar();
}catch(e){
$('#mdOut').innerHTML='<p style="color:var(--red2)">Error: '+esc(e.message)+'</p>';
@@ -431,13 +437,26 @@ async function doGenerate(){
}
}
-function renderOutput(step,text,demo,cost,maxPlan,equiv){
+function renderOutput(step,text,demo,cost,maxPlan,equiv,fallback,warn){
$('#out').classList.add('show');
const b=$('#outBadge');
- if(demo)b.textContent='demo';
+ if(fallback)b.textContent='fallback demo';
+ else if(demo)b.textContent='demo';
else if(maxPlan)b.textContent='Max plan · $0'+(equiv?' (≈'+fmtCost(equiv)+' metered)':'');
else b.textContent=CFG.model+' · '+fmtCost(cost||0);
- b.className='badge '+(demo?'demo':'live');
+ b.className='badge '+(fallback?'fallback':demo?'demo':'live');
+ // Persistent honesty banner: whenever a LIVE call failed and we substituted canned
+ // demo, say so plainly and keep it on screen — never let fake content read as real.
+ const fb=$('#fbanner');
+ if(fb){
+ if(fallback){
+ const rs=warn?String(warn).replace(/^live call failed:\s*/,''):'';
+ fb.innerHTML='<b>⚠ Generation unavailable.</b> The live provider call failed'+
+ (rs?' <span class="rs">('+esc(rs)+')</span>':'')+
+ ' — this is <b>placeholder demo content</b>, not a real result. Check the provider token/connection and regenerate.';
+ fb.classList.add('show');
+ }else{fb.classList.remove('show');fb.innerHTML='';}
+ }
if(step.renderCalendar){renderCalendar(text);}else{$('#calWrap').innerHTML='';}
$('#mdOut').innerHTML=md(text);
}
diff --git a/server.js b/server.js
index a3f273d..cce19e4 100644
--- a/server.js
+++ b/server.js
@@ -173,7 +173,10 @@ const server = http.createServer((req, res) => {
});
} catch (e) {
// Graceful fallback to demo so the prototype never hard-fails mid-preview.
- return send(res, 200, { text: demoOutput(stepKey), demo: true, provider: 'demo', cost: 0, estCost: est, model: 'demo', warning: 'live call failed: ' + e.message });
+ // `fallback:true` is the honesty flag: distinguishes "a LIVE call just failed and
+ // this is substitute canned content" from an intentional demo-mode response — the
+ // client raises a persistent banner so fabricated demo is never mistaken for a real result.
+ return send(res, 200, { text: demoOutput(stepKey), demo: true, fallback: true, provider: 'demo', cost: 0, estCost: est, model: 'demo', warning: 'live call failed: ' + e.message });
}
});
return;
diff --git a/test/README.md b/test/README.md
index cdff731..d66b299 100644
--- a/test/README.md
+++ b/test/README.md
@@ -12,3 +12,13 @@ SAVEDIR=$(pwd)/.. NODE_PATH=~/Projects/animals/node_modules BASE=http://127.0.0.
```
Exit 0 = all checks pass, 0 console/page errors. NOTE: avoid WHATWG "bad ports"
(4190, 6000, 5060, …) — undici fetch refuses them ("fetch failed / bad port").
+
+`fallback.spec.js` is a **self-contained** regression guard (7 checks) for the
+demo-fallback **honesty banner**: it spawns its own studio-zero in LIVE API mode
+with a deliberately bogus key (→ 401), then asserts that when the live call fails
+the front-end raises a persistent "generation unavailable — placeholder demo
+content" banner (real reason surfaced, `fallback demo` badge, survives step
+navigation) so fabricated demo output is never mistaken for a real result.
+```bash
+NODE_PATH=~/Projects/animals/node_modules node fallback.spec.js # spawns + tears down its own server
+```
diff --git a/test/fallback.spec.js b/test/fallback.spec.js
new file mode 100644
index 0000000..ae4cea5
--- /dev/null
+++ b/test/fallback.spec.js
@@ -0,0 +1,71 @@
+// Regression guard for the demo-fallback HONESTY banner (DTD-A refinement, 2026-07-26).
+// When a LIVE provider call fails (e.g. an expired CLI/OAuth token 401s), server.js
+// substitutes canned demo content with `fallback:true`; the front-end MUST raise a
+// persistent "generation unavailable" banner so fabricated demo is never mistaken for
+// a real result. This test forces the failure path (live API mode + a bogus key → 401)
+// and asserts the banner is visible, honest, badge-distinct, and survives navigation.
+//
+// Self-contained: spawns its own studio-zero on a scratch port, then tears it down.
+// Run: node test/fallback.spec.js (needs `playwright` resolvable, e.g. via NODE_PATH)
+const { chromium } = require('playwright');
+const { spawn } = require('child_process');
+const path = require('path');
+
+const PORT = process.env.FB_PORT || '4174';
+const BASE = 'http://127.0.0.1:' + PORT;
+
+function waitReady(url, ms) {
+ const http = require('http'); const t0 = Date.now();
+ return new Promise((resolve, reject) => {
+ (function ping() {
+ const r = http.get(url, res => { res.resume(); resolve(); });
+ r.on('error', () => { if (Date.now() - t0 > ms) reject(new Error('server never came up')); else setTimeout(ping, 150); });
+ })();
+ });
+}
+
+(async () => {
+ const srv = spawn('node', ['server.js'], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, PORT, SZ_PROVIDER: 'api', ANTHROPIC_API_KEY: 'sk-ant-bogus-401-forcing' },
+ stdio: 'ignore',
+ });
+ const log = []; const ok = (c, m) => log.push((c ? 'PASS ' : 'FAIL ') + m);
+ const errs = [];
+ try {
+ await waitReady(BASE + '/api/config', 8000);
+ const browser = await chromium.launch();
+ const page = await browser.newPage();
+ page.on('console', m => { if (m.type() === 'error') errs.push('console:' + m.text()); });
+ page.on('pageerror', e => errs.push('pageerror:' + e.message));
+
+ await page.goto(BASE, { waitUntil: 'networkidle' });
+ ok(/LIVE/.test((await page.locator('#modeChip').textContent()).trim()), 'mode chip is LIVE (bogus key still resolves a provider)');
+
+ await page.click('#startBtn');
+ await page.waitForSelector('#genBtn');
+ await page.click('#genBtn');
+ await page.waitForFunction(() => { const o = document.querySelector('#mdOut'); return o && o.textContent && !/working/i.test(o.textContent); }, null, { timeout: 60000, polling: 400 });
+
+ ok(await page.locator('#fbanner').isVisible(), 'persistent fallback banner visible after failed live call');
+ const bt = (await page.locator('#fbanner').textContent()).trim();
+ ok(/Generation unavailable/i.test(bt), 'banner says "Generation unavailable"');
+ ok(/placeholder demo content/i.test(bt), 'banner labels the output as placeholder demo content');
+ ok(/invalid x-api-key/i.test(bt), 'banner surfaces the real failure reason');
+ ok(/fallback/i.test((await page.locator('#outBadge').textContent()).trim()), 'badge distinguishes fallback from intentional demo');
+
+ // Persistence: navigate away and back — banner must rehydrate from saved state, not vanish.
+ await page.click('#nextBtn'); await page.waitForTimeout(200);
+ await page.locator('#stepper .sdot').nth(0).click(); await page.waitForTimeout(300);
+ ok(await page.locator('#fbanner').isVisible(), 'banner persists across step navigation');
+
+ await browser.close();
+ } catch (e) {
+ ok(false, 'HARNESS: ' + e.message);
+ } finally {
+ srv.kill('SIGKILL');
+ }
+ console.log(log.join('\n'));
+ console.log('\nconsole/page errors: ' + (errs.length ? '\n' + errs.join('\n') : 'NONE'));
+ process.exit(log.some(l => l.startsWith('FAIL')) || errs.length ? 1 : 0);
+})();
← 6e679f7 studio-zero: first Kamatera deploy config (pm2 studio-zero :
·
back to Studio Zero
·
DTD-B: optional HTTP Basic-Auth gate behind default-OFF SZ_B bdd814b →