From 62f92105ec4e745ee8302fcc06de6e4c2034886f Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 18:10:22 +0000 Subject: [PATCH] feat: keep Human Gate decisions in reach on mobile (Closes #1439) --- frontend/dashboard.css | 3 +- frontend/dashboard.js | 10 +-- frontend/human-gates.js | 80 +++++++++++++++++--- frontend/progressive-human-gates.js | 9 +-- frontend/service-worker.js | 2 +- tests/e2e/test_human_gates_reopen_release.py | 26 +++++++ tests/test_comment_next.py | 2 +- tests/test_following_frontend.py | 2 +- tests/test_human_gates_frontend.py | 79 ++++++++++++++++++- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 34 ++++----- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 19 files changed, 209 insertions(+), 56 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index ff71d07..68c2be6 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1624,5 +1624,6 @@ textarea { resize: vertical; min-height: 120px; } @media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}} .human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))} -.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-detail>div{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f} +.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f} +@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}} @media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}} \ No newline at end of file diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 4f1b4e3..9428373 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -701,13 +701,9 @@ qs('#human-gate-detail').addEventListener('click', event => { const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision; if (!decision) return; - const detail = qs('#human-gate-detail'); - const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked])); - humanGates.decideAndNext(decision, { - checklist, - reason:detail.querySelector('[data-gate-reason]')?.value || '', - override_reason:detail.querySelector('[data-gate-override]')?.value || '', - }).catch(error => { qs('#human-gates-status').textContent = error.message; }); + humanGates.submitDecision(decision).catch(error => { + if (!error?.targetSelector) qs('#human-gates-status').textContent = error.message; + }); }); } if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {}); diff --git a/frontend/human-gates.js b/frontend/human-gates.js index 26295d1..f2c3539 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -61,14 +61,26 @@ function createHumanGates(options = {}) { try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; } } + function valuesFromDetail() { + const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked])); + return { + checklist, + reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '', + override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '', + }; + } + + function updateReadiness(values = valuesFromDetail()) { + const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'] + .filter(key => values.checklist?.[key] === true).length; + setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete'); + } + 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 || '', - }); + const values = valuesFromDetail(); + updateReadiness(values); + return saveProgress(values); } nodes.detail?.addEventListener?.('input', captureProgress); @@ -116,9 +128,13 @@ function createHumanGates(options = {}) { '' + '' + '' + - '
' + - '
' + '
' + + '0 of 3 confirmations complete' + + '
' + + '
' + + '
' ); + updateReadiness(); } async function load() { @@ -194,6 +210,12 @@ function createHumanGates(options = {}) { return { operation, key }; } + function decisionError(message, selector) { + const error = new Error(message); + error.targetSelector = selector; + return error; + } + async function decideAndNext(decision, values = {}) { const item = current(); if (!item) throw new Error('No gate is selected.'); @@ -201,12 +223,18 @@ function createHumanGates(options = {}) { if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.'); const checklist = values.checklist || {}; const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true); - if (decision === 'release' && !complete) throw new Error('Complete the release checklist before deciding.'); + if (decision === 'release' && !complete) { + const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true); + throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]'); + } const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success'); if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) { - throw new Error('An explicit override reason is required for unmet required checks.'); + const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', '); + throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]'); + } + if (decision === 'hold' && !String(values.reason || '').trim()) { + throw decisionError('A hold reason is required.', '[data-gate-reason]'); } - if (decision === 'hold' && !String(values.reason || '').trim()) throw new Error('A hold reason is required.'); const payload = { expected_revision: item.revision, decision, reason: String(values.reason || '').trim(), @@ -240,6 +268,34 @@ function createHumanGates(options = {}) { } } + async function submitDecision(decision) { + const values = valuesFromDetail(); + updateReadiness(values); + const errorNode = nodes.detail?.querySelector?.('[data-gate-error]'); + if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; } + const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []); + buttons.forEach(button => { button.disabled = true; }); + try { + return await decideAndNext(decision, values); + } catch (error) { + if (errorNode) { + errorNode.textContent = error?.message || 'The decision could not be saved.'; + errorNode.hidden = false; + } + let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector); + if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) { + const key = error.targetSelector.match(/"([^"]+)"/)?.[1]; + target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []) + .find(input => input.dataset.gateChecklist === key); + } + target?.scrollIntoView?.({block:'center', behavior:'smooth'}); + target?.focus?.({preventScroll:true}); + throw error; + } finally { + buttons.forEach(button => { button.disabled = false; }); + } + } + function open() { location.hash = '#/my-work/human-gates'; if (nodes.panel) nodes.panel.hidden = false; @@ -259,7 +315,7 @@ function createHumanGates(options = {}) { } return { - load, open, reviewNext, select, decideAndNext, current, saveProgress, + load, open, reviewNext, select, decideAndNext, submitDecision, current, saveProgress, setOnChange(callback) { onChange = callback; }, restoreCached: restore, snapshot: () => JSON.parse(JSON.stringify(queue)), diff --git a/frontend/progressive-human-gates.js b/frontend/progressive-human-gates.js index e0e0ff6..3789706 100644 --- a/frontend/progressive-human-gates.js +++ b/frontend/progressive-human-gates.js @@ -50,12 +50,9 @@ function createProgressiveHumanGates(options = {}) { nodes.detail?.addEventListener?.('click', event => { const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision; if (!decision) return; - const checklist = Object.fromEntries(Array.from(nodes.detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked])); - controller.decideAndNext(decision, { - checklist, - reason:nodes.detail.querySelector?.('[data-gate-reason]')?.value || '', - override_reason:nodes.detail.querySelector?.('[data-gate-override]')?.value || '', - }).catch(showError); + controller.submitDecision(decision).catch(error => { + if (!error?.targetSelector) showError(error); + }); }); async function start(force = false) { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 2a51700..fca8dae 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-v146'; +const CACHE = 'stackchain-dashboard-shell-v147'; 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 426e43c..3409dab 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -29,6 +29,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( current = {"gate": "g1"} evidence_url = {"value": "/evidence/manifest"} list_requests: list[str] = [] + decision_requests: list[str] = [] browser_errors: list[str] = [] def gate(gate_id: str) -> dict: @@ -61,6 +62,7 @@ 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 route.request.method == "POST": + decision_requests.append(path) payload = {"receipt_id": "receipt-1", "state": "released"} elif path.endswith("/api/v1/human-gates"): list_requests.append(current["gate"]) @@ -81,9 +83,31 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( expect(page.locator("#human-gates")).to_be_visible() expect(page.locator("#human-gate-detail")).to_contain_text("First candidate") + tray = page.locator(".human-gate-decision-tray") + expect(tray).to_be_visible() + expect(page.locator("[data-gate-readiness]")).to_have_text( + "0 of 3 confirmations complete" + ) + tray_bounds = tray.bounding_box() + assert tray_bounds + assert tray_bounds["y"] + tray_bounds["height"] <= height + 1 + for action in ("hold", "release"): + bounds = page.locator(f'[data-gate-decision="{action}"]').bounding_box() + assert bounds and bounds["height"] >= 44 + + page.locator('[data-gate-decision="release"]').click() + expect(page.locator("[data-gate-error]")).to_contain_text( + "Complete the release checklist" + ) + expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_focused() + assert decision_requests == [] + page.locator('[data-gate-checklist="exact_hash"]').check() page.locator('[data-gate-checklist="artifacts_reviewed"]').check() page.locator('[data-gate-checklist="provenance_reviewed"]').check() + expect(page.locator("[data-gate-readiness]")).to_have_text( + "3 of 3 confirmations complete" + ) 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() @@ -108,6 +132,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( 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") + expect(page.locator(".human-gate-decision-tray")).to_have_count(0) + assert len(decision_requests) == 1 assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key) page.evaluate("document.querySelector('#close-human-gates').click()") diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 6d56188..39ed95e 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-v146" in worker + assert "stackchain-dashboard-shell-v147" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 1211c57..3c7e8fe 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-v146" in service_worker + assert "stackchain-dashboard-shell-v147" 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 8d8300d..3f1ec2c 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -7,6 +7,7 @@ MODULE = Path(__file__).parents[1] / "frontend" / "human-gates.js" PROGRESSIVE = Path(__file__).parents[1] / "frontend" / "progressive-human-gates.js" INDEX = Path(__file__).parents[1] / "frontend" / "index.html" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" +CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" LIVE_SNAPSHOT = Path(__file__).parents[1] / "frontend" / "progressive-live-snapshot.js" PROGRESSIVE_MY_WORK = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js" @@ -274,6 +275,82 @@ const gates=createHumanGates({ } +def test_mobile_decision_tray_reports_readiness_and_targets_the_first_missing_confirmation(): + output = run_node(r""" +let posts=0; +const focused=[]; +const checklist=[ + {dataset:{gateChecklist:'exact_hash'},checked:true,focus(){focused.push('exact_hash')},scrollIntoView(){focused.push('scroll-exact_hash')}}, + {dataset:{gateChecklist:'artifacts_reviewed'},checked:false,focus(){focused.push('artifacts_reviewed')},scrollIntoView(){focused.push('scroll-artifacts_reviewed')}}, + {dataset:{gateChecklist:'provenance_reviewed'},checked:false,focus(){focused.push('provenance_reviewed')},scrollIntoView(){focused.push('scroll-provenance_reviewed')}}, +]; +const error={textContent:'',hidden:true}; +const readiness={textContent:''}; +const buttons=[{disabled:false},{disabled:false}]; +const detail={ + innerHTML:'', addEventListener(){}, + querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:selector==='[data-gate-decision]'?buttons:[], + querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-readiness]'?readiness:selector==='[data-gate-reason]'?{value:''}:selector==='[data-gate-override]'?{value:''}:null, +}; +const item={id:'g1',title:'Long evidence review',candidate_hash:'abc',revision:1,checks:[]}; +const gates=createHumanGates({ + storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true, + nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''}, + fetchJson:async(path,options={})=>{if(options.method==='POST')posts+=1;return options.method==='POST'?{receipt_id:'r1'}:{pending_count:1,items:[item]};}, +}); +(async()=>{ + await gates.load(); gates.reviewNext(); + const rendered=detail.innerHTML; + let message=''; + try { await gates.submitDecision('release'); } catch(error) { message=error.message; } + process.stdout.write(JSON.stringify({rendered,message,error,readiness,focused,posts})); +})(); +""") + assert 'class="human-gate-decision-tray"' in output["rendered"] + assert 'data-gate-readiness' in output["rendered"] + assert output["message"] == "Complete the release checklist before deciding." + assert output["error"] == { + "textContent": "Complete the release checklist before deciding.", + "hidden": False, + } + assert output["readiness"]["textContent"] == "1 of 3 confirmations complete" + assert output["focused"] == ["scroll-artifacts_reviewed", "artifacts_reviewed"] + assert output["posts"] == 0 + + +def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_form_fields(): + css = CSS.read_text() + + assert ".human-gate-decision-tray" in css + assert "position:sticky" in css + assert "bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)))" in css + assert "padding-bottom:max(16px,env(safe-area-inset-bottom))" in css + assert ".human-gate-decision-actions button{min-height:44px}" in css + assert ".human-gate-detail{padding-bottom:" in css + + +def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache(): + worker = WORKER.read_text() + + assert "stackchain-dashboard-shell-v147" in worker + + +def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason(): + output = run_node(r""" +const focused=[]; +const checklist=['exact_hash','artifacts_reviewed','provenance_reviewed'].map(key=>({dataset:{gateChecklist:key},checked:true})); +const override={value:'',focus(){focused.push('override')},scrollIntoView(){focused.push('scroll-override')}}; +const error={textContent:'',hidden:true}; +const detail={innerHTML:'',addEventListener(){},querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:[],querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-override]'?override:selector==='[data-gate-reason]'?{value:''}:null}; +const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:1,checks:[{name:'Mobile browser journey',state:'failure',required:true}]}; +const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},fetchJson:async()=>({pending_count:1,items:[item]})}); +(async()=>{await gates.load();gates.reviewNext();let message='';try{await gates.submitDecision('release')}catch(error){message=error.message}process.stdout.write(JSON.stringify({message,inline:error.textContent,focused}));})(); +""") + assert "Mobile browser journey" in output["message"] + assert output["inline"] == output["message"] + assert output["focused"] == ["scroll-override", "override"] + + 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; @@ -492,7 +569,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-v146" in WORKER.read_text() + assert "stackchain-dashboard-shell-v147" 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 8ca6500..5f8b9af 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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 2aadf40..dc5994a 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-v146" in worker + assert "stackchain-dashboard-shell-v147" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 8567bc7..2e74685 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-v146" in worker + assert "stackchain-dashboard-shell-v147" 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 108a75d..e25a48e 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-v146" in worker + assert "stackchain-dashboard-shell-v147" 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 494c7c6..7395860 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-v146" in worker + assert "stackchain-dashboard-shell-v147" 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 4856392..b9d508b 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-v146" in service_worker + assert "stackchain-dashboard-shell-v147" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 73322c8..715c674b 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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 998a177..a7c5897 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" 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-v146" in source + assert "stackchain-dashboard-shell-v147" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1390,7 +1390,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-v146" in source + assert "stackchain-dashboard-shell-v147" 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 85bb3af..42639b9 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-v146';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v147';" 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 58babca..50e66f9 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-v146" in source + assert "stackchain-dashboard-shell-v147" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0