diff --git a/frontend/human-gates.js b/frontend/human-gates.js
index 5c532f9..26295d1 100644
--- a/frontend/human-gates.js
+++ b/frontend/human-gates.js
@@ -20,6 +20,8 @@ function createHumanGates(options = {}) {
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[character]);
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
+ const progressKey = item => 'stackchain.human-gate-review.v1:' +
+ String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
@@ -38,6 +40,40 @@ function createHumanGates(options = {}) {
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
}
+ function restoreProgress(item) {
+ if (!getLogin() || !item) return null;
+ try {
+ const value = JSON.parse(storage.getItem(progressKey(item)) || 'null');
+ if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null;
+ return value;
+ } catch (_) { return null; }
+ }
+
+ function saveProgress(values = {}) {
+ const item = current();
+ if (!getLogin() || !item) return false;
+ const checklist = values.checklist || {};
+ const progress = {
+ gate_id:item.id, revision:item.revision,
+ checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])),
+ reason:String(values.reason || ''), override_reason:String(values.override_reason || ''),
+ };
+ try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
+ }
+
+ function captureProgress() {
+ if (!nodes.detail?.querySelectorAll) return false;
+ const checklist = Object.fromEntries(Array.from(nodes.detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
+ return saveProgress({
+ checklist,
+ reason:nodes.detail.querySelector?.('[data-gate-reason]')?.value || '',
+ override_reason:nodes.detail.querySelector?.('[data-gate-override]')?.value || '',
+ });
+ }
+
+ nodes.detail?.addEventListener?.('input', captureProgress);
+ nodes.detail?.addEventListener?.('change', captureProgress);
+
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
@@ -58,11 +94,13 @@ function createHumanGates(options = {}) {
setHtml(nodes.detail, '
Inbox zeroFixed review snapshot complete.
');
return;
}
+ const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
+ const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
const checks = (item.checks || []).map(check =>
'' + escape(check.name) + ' · ' + escape(check.state) + (check.required ? ' · required' : '') + ''
).join('');
- const artifacts = (item.artifacts || []).map(artifact => '' + escape(artifact.name) + '').join('');
- const links = (item.links || []).map(link => '' + escape(link.label) + '').join('');
+ const artifacts = (item.artifacts || []).map(artifact => '' + escape(artifact.name) + '').join('');
+ const links = (item.links || []).map(link => '' + escape(link.label) + '').join('');
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '' + escape(key) + ' · ' + escape(value) + '').join('');
const history = (item.history || []).map(event => '' + escape(event.action) + ' · ' + escape(event.at) + '').join('');
setHtml(nodes.detail,
@@ -73,11 +111,11 @@ function createHumanGates(options = {}) {
'Artifacts
Links
' +
'Checks
Provenance
' +
'History
' +
- '' +
- '' +
- '' +
- '' +
- '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
'' +
'
'
);
@@ -183,6 +221,7 @@ function createHumanGates(options = {}) {
body: JSON.stringify(payload),
});
decisionKeys.delete(decisionKey.operation);
+ try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
@@ -220,7 +259,7 @@ function createHumanGates(options = {}) {
}
return {
- load, open, reviewNext, select, decideAndNext, current,
+ load, open, reviewNext, select, decideAndNext, current, saveProgress,
setOnChange(callback) { onChange = callback; },
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index cb52202..587323b 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v145';
+const CACHE = 'stackchain-dashboard-shell-v146';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
diff --git a/tests/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py
index f112450..426e43c 100644
--- a/tests/e2e/test_human_gates_reopen_release.py
+++ b/tests/e2e/test_human_gates_reopen_release.py
@@ -27,6 +27,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
current = {"gate": "g1"}
+ evidence_url = {"value": "/evidence/manifest"}
list_requests: list[str] = []
browser_errors: list[str] = []
@@ -39,7 +40,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
"revision": 1,
"priority": 5,
"checks": [],
- "artifacts": [],
+ "artifacts": [{"name": "Signed manifest", "url": evidence_url["value"]}],
"links": [],
"provenance": {},
"history": [],
@@ -49,6 +50,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
with release_server(
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
) as origin, sync_playwright() as playwright:
+ evidence_url["value"] = origin + "/evidence/manifest"
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
@@ -58,7 +60,9 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
def human_gates_route(route):
path = route.request.url.split("?", 1)[0]
- if path.endswith("/api/v1/human-gates"):
+ if route.request.method == "POST":
+ payload = {"receipt_id": "receipt-1", "state": "released"}
+ elif path.endswith("/api/v1/human-gates"):
list_requests.append(current["gate"])
payload = {"pending_count": 1, "items": [gate(current["gate"])]}
else:
@@ -66,6 +70,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
page.route("**/api/v1/human-gates**", human_gates_route)
+ page.route("**/api/v1/human-gates/**", human_gates_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Human Gates release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
@@ -75,6 +80,36 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
+
+ page.locator('[data-gate-checklist="exact_hash"]').check()
+ page.locator('[data-gate-checklist="artifacts_reviewed"]').check()
+ page.locator('[data-gate-checklist="provenance_reviewed"]').check()
+ page.locator("[data-gate-reason]").fill("Awaiting final approval")
+ with page.expect_popup() as popup_info:
+ page.get_by_role("link", name="Signed manifest").click()
+ popup = popup_info.value
+ popup.wait_for_load_state("domcontentloaded")
+ assert not page.url.endswith("/evidence/manifest")
+ expect(page.locator("#human-gates")).to_be_visible()
+ expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
+ popup.close()
+
+ page.reload(wait_until="networkidle")
+ page.evaluate("document.querySelector('#open-human-gates').click()")
+ expect(page.locator("#human-gates")).to_be_visible()
+ expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
+ expect(page.locator('[data-gate-checklist="artifacts_reviewed"]')).to_be_checked()
+ expect(page.locator('[data-gate-checklist="provenance_reviewed"]')).to_be_checked()
+ expect(page.locator("[data-gate-reason]")).to_have_value("Awaiting final approval")
+ progress_key = page.evaluate(
+ "Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-review.v1:'))"
+ )
+ assert progress_key and progress_key.endswith(":g1:1")
+ assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
+ page.locator('[data-gate-decision="release"]').click()
+ expect(page.locator("#human-gates-status")).to_contain_text("Decision saved")
+ assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
+
page.evaluate("document.querySelector('#close-human-gates').click()")
before_reopen = len(list_requests)
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 128d4c7..6d56188 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v145" in worker
+ assert "stackchain-dashboard-shell-v146" in worker
diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py
index bc91419..1211c57 100644
--- a/tests/test_following_frontend.py
+++ b/tests/test_following_frontend.py
@@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
- assert "stackchain-dashboard-shell-v145" in service_worker
+ assert "stackchain-dashboard-shell-v146" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py
index 0ff8ad0..8d8300d 100644
--- a/tests/test_human_gates_frontend.py
+++ b/tests/test_human_gates_frontend.py
@@ -196,10 +196,84 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
""")
assert "stackchain/dashboard" in output["html"]
assert "https://forge.example/pull/1" in output["html"]
+ assert output["html"].count('target="_blank"') == 2
+ assert output["html"].count('rel="noreferrer noopener"') == 2
assert "bot" in output["html"]
assert "intake" in output["html"]
+def test_unfinished_review_restores_after_reload_for_same_account_gate_and_revision():
+ output = run_node(r"""
+const values=new Map();
+const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
+const item={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:4,checks:[]};
+const make=()=>{
+ const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:'',addEventListener(){}}};
+ return {nodes,gates:createHumanGates({
+ storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''},
+ fetchJson:async path=>path.endsWith('/g1')?item:{pending_count:1,items:[item]},
+ })};
+};
+(async()=>{
+ const first=make(); await first.gates.load(); first.gates.reviewNext();
+ first.gates.saveProgress({
+ checklist:{exact_hash:true,artifacts_reviewed:false,provenance_reviewed:true},
+ reason:'Need the signed manifest',override_reason:'Approved exception',
+ });
+ const reloaded=make(); await reloaded.gates.load(); reloaded.gates.reviewNext();
+ await new Promise(resolve=>setTimeout(resolve,0));
+ process.stdout.write(JSON.stringify({keys:[...values.keys()],html:reloaded.nodes.detail.innerHTML}));
+})();
+""")
+ assert "stackchain.human-gate-review.v1:7:timmy:g1:4" in output["keys"]
+ assert 'data-gate-checklist="exact_hash" checked' in output["html"]
+ assert 'data-gate-checklist="artifacts_reviewed" checked' not in output["html"]
+ assert 'data-gate-checklist="provenance_reviewed" checked' in output["html"]
+ assert "Need the signed manifest" in output["html"]
+ assert "Approved exception" in output["html"]
+
+
+def test_review_form_changes_are_saved_without_a_decision_tap():
+ output = run_node(r"""
+const values=new Map(), listeners={};
+const inputs=[
+ {dataset:{gateChecklist:'exact_hash'},checked:true},
+ {dataset:{gateChecklist:'artifacts_reviewed'},checked:true},
+ {dataset:{gateChecklist:'provenance_reviewed'},checked:false},
+];
+const reason={value:'Waiting for mobile evidence'}, override={value:'Temporary exception'};
+const detail={
+ innerHTML:'', addEventListener:(name,listener)=>listeners[name]=listener,
+ querySelectorAll:()=>inputs,
+ querySelector:selector=>selector==='[data-gate-reason]'?reason:override,
+};
+const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:2,checks:[]};
+const gates=createHumanGates({
+ storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
+ getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,
+ nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},
+ fetchJson:async()=>({pending_count:1,items:[item]}),
+});
+(async()=>{
+ await gates.load(); gates.reviewNext();
+ listeners.input();
+ const saved=JSON.parse(values.get('stackchain.human-gate-review.v1:7:timmy:g1:2'));
+ process.stdout.write(JSON.stringify(saved));
+})();
+""")
+ assert output == {
+ "gate_id": "g1",
+ "revision": 2,
+ "checklist": {
+ "exact_hash": True,
+ "artifacts_reviewed": True,
+ "provenance_reviewed": False,
+ },
+ "reason": "Waiting for mobile evidence",
+ "override_reason": "Temporary exception",
+ }
+
+
def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity():
output = run_node(r"""
let online=true, login='timmy', posts=0;
@@ -259,6 +333,32 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
assert output["keys"][0] == output["keys"][1]
+def test_failed_decision_keeps_review_progress_and_successful_retry_clears_it():
+ output = run_node(r"""
+const stored=new Map(); let attempts=0;
+const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)};
+const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:3,checks:[]};
+const gates=createHumanGates({
+ storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,
+ nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},
+ fetchJson:async(path,options={})=>{
+ if(options.method==='POST') { attempts += 1; if(attempts===1) throw new Error('offline'); return {receipt_id:'r1'}; }
+ return {pending_count:1,items:[item]};
+ },
+});
+(async()=>{
+ await gates.load(); gates.reviewNext();
+ gates.saveProgress({reason:'Awaiting approval',checklist:{}});
+ const key='stackchain.human-gate-review.v1:7:timmy:g1:3';
+ try { await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {}
+ const retained=stored.has(key);
+ await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}});
+ process.stdout.write(JSON.stringify({retained,cleared:!stored.has(key)}));
+})();
+""")
+ assert output == {"retained": True, "cleared": True}
+
+
def test_concurrent_decision_taps_submit_once_and_advance_once():
output = run_node(r"""
let posts=0, releasePost; const posted=new Promise(resolve=>releasePost=resolve);
@@ -392,7 +492,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
assert "counts.gate = queueCounts.gate" in dashboard
assert "gate:preparationItems.gate || []" in dashboard
- assert "stackchain-dashboard-shell-v145" in WORKER.read_text()
+ assert "stackchain-dashboard-shell-v146" in WORKER.read_text()
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 3f49c6b..8ca6500 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index a23e79f..2aadf40 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v145" in worker
+ assert "stackchain-dashboard-shell-v146" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 5be6334..8567bc7 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v145" in worker
+ assert "stackchain-dashboard-shell-v146" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index 65851ab..108a75d 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
- assert "stackchain-dashboard-shell-v145" in worker
+ assert "stackchain-dashboard-shell-v146" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py
index 51cedbd..494c7c6 100644
--- a/tests/test_mobile_insights.py
+++ b/tests/test_mobile_insights.py
@@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v145" in worker
+ assert "stackchain-dashboard-shell-v146" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py
index 02ab8bc..4856392 100644
--- a/tests/test_mobile_start_day.py
+++ b/tests/test_mobile_start_day.py
@@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
- assert "stackchain-dashboard-shell-v145" in service_worker
+ assert "stackchain-dashboard-shell-v146" in service_worker
@pytest.mark.anyio
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 61340b4..73322c8 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index d6a2345..465f310 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/progressive-live-snapshot.js'" in source
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -258,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -292,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1361,7 +1361,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index 5b3f564..85bb3af 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
- assert "const CACHE = 'stackchain-dashboard-shell-v145';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v146';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index aca1272..58babca 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v145" in source
+ assert "stackchain-dashboard-shell-v146" in source
assert "BASE + 'static/today-sync.js'" in source