diff --git a/docs/human-gates.md b/docs/human-gates.md index 65abc70..fbbbf7a 100644 --- a/docs/human-gates.md +++ b/docs/human-gates.md @@ -2,6 +2,8 @@ Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip. +A cold open of the canonical route confirms the account and opens Human Gates from the core browser runtime while optional Today and Planning bundles continue hydrating or recovering. The full workspace adopts that controller and fixed review snapshot without a second queue request or duplicate decision handlers. + ## Producer intake Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as: diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 96d2fe4..1c51474 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3,6 +3,7 @@ await workspaceLifecycle.optionalReady; const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.(); const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.(); + const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.(); window.stackchainProgressiveMyWork?.stop(); const qs = (s, el=document) => el.querySelector(s); const announceWork = message => qs('#my-work-action-status').textContent = message; @@ -636,53 +637,58 @@ return payload; } - const humanGates = createHumanGates({ + const humanGatesOnChange = (snapshot, state)=>{ + queueCounts.gate = snapshot.pending_count; + queueCounts.gateUnavailable = state.available === false; + preparationItems.gate = snapshot.items; + mobileTaskDock.updateQueues(queueCounts); + if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']}); + mobileStartDay.render(); + }; + const humanGates = progressiveHumanGatesHandoff?.controller || createHumanGates({ storage:localStorage, getLogin:()=>planningOwnerLogin, getAccountKey:()=>planningOwnerAccountKey, isOnline:()=>navigator.onLine, location:window.location, fetchJson:fetchReviewJson, - onChange:(snapshot, state)=>{ - queueCounts.gate = snapshot.pending_count; - queueCounts.gateUnavailable = state.available === false; - preparationItems.gate = snapshot.items; - mobileTaskDock.updateQueues(queueCounts); - if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']}); - mobileStartDay.render(); - }, + onChange:humanGatesOnChange, nodes:{ count:qs('#human-gates-count'), list:qs('#human-gates-list'), status:qs('#human-gates-status'), panel:qs('#human-gates'), detail:qs('#human-gate-detail'), }, }); + progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey); + humanGates.setOnChange?.(humanGatesOnChange); const openHumanGates = () => humanGates.open().catch(error => { qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.'; }); - qs('#open-human-gates').addEventListener('click', openHumanGates); - qs('#close-human-gates').addEventListener('click', () => { - qs('#human-gates').hidden = true; - if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work'); - }); - qs('#human-gates-list').addEventListener('click', event => { - const card = event.target.closest('[data-human-gate-id]'); - if (!card) return; - humanGates.select(card.dataset.humanGateId); - }); - 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.load().catch(() => {}); - if (window.location.hash === '#/my-work/human-gates') openHumanGates(); + if (!progressiveHumanGatesHandoff) { + qs('#open-human-gates').addEventListener('click', openHumanGates); + qs('#close-human-gates').addEventListener('click', () => { + qs('#human-gates').hidden = true; + if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work'); + }); + qs('#human-gates-list').addEventListener('click', event => { + const card = event.target.closest('[data-human-gate-id]'); + if (!card) return; + humanGates.select(card.dataset.humanGateId); + }); + 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; }); + }); + } + if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {}); + if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates(); function syncCompletedFiledReviews() { if (!planningOwnerLogin) return Promise.resolve(false); diff --git a/frontend/human-gates.js b/frontend/human-gates.js index 785b3ae..09ad515 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -13,6 +13,7 @@ function createHumanGates(options = {}) { let loadEpoch = 0; const decisionKeys = new Map(); let decisionFlight = null; + let onChange = options.onChange; const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', @@ -20,7 +21,7 @@ function createHumanGates(options = {}) { const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase(); const setText = (node, value) => { if (node) node.textContent = value; }; const setHtml = (node, value) => { if (node) node.innerHTML = value; }; - const publish = state => options.onChange?.(JSON.parse(JSON.stringify(queue)), state); + const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state); function validSnapshot(value) { return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null; @@ -207,6 +208,7 @@ function createHumanGates(options = {}) { return { load, open, reviewNext, select, decideAndNext, current, + setOnChange(callback) { onChange = callback; }, restoreCached: restore, snapshot: () => JSON.parse(JSON.stringify(queue)), route: () => location.hash, diff --git a/frontend/index.html b/frontend/index.html index 02db075..2d8d05a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2412,6 +2412,7 @@ + diff --git a/frontend/progressive-human-gates.js b/frontend/progressive-human-gates.js new file mode 100644 index 0000000..607e58c --- /dev/null +++ b/frontend/progressive-human-gates.js @@ -0,0 +1,100 @@ +function createProgressiveHumanGates(options = {}) { + const document = options.document || globalThis.document; + const location = options.location || globalThis.location; + const history = options.history || globalThis.history; + const storage = options.storage || globalThis.localStorage; + const isOnline = options.isOnline || (() => globalThis.navigator?.onLine !== false); + const fetchJson = options.fetchJson || (async (path, requestOptions = {}) => { + const response = await fetch(path, requestOptions); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.'); + return payload; + }); + const getIdentity = options.getIdentity || (async () => { + const response = await fetchJson('api/v1/live', {headers:{Accept:'application/json'}}); + const user = response?.context?.user || {}; + const login = String(user.login || '').trim(); + return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login}; + }); + const query = selector => document.querySelector(selector); + const nodes = { + count:query('#human-gates-count'), list:query('#human-gates-list'), + status:query('#human-gates-status'), panel:query('#human-gates'), + detail:query('#human-gate-detail'), + }; + let login = ''; + let accountKey = ''; + let started = false; + let startFlight = null; + + const controller = createHumanGates({ + storage, isOnline, location, fetchJson, + getLogin:() => login, getAccountKey:() => accountKey, nodes, + }); + + const showError = error => { + if (nodes.status) nodes.status.textContent = error?.message || 'Human Gates are unavailable.'; + }; + const open = () => start(true).catch(showError); + query('#open-human-gates')?.addEventListener?.('click', open); + query('#close-human-gates')?.addEventListener?.('click', () => { + if (nodes.panel) nodes.panel.hidden = true; + if (location.hash === '#/my-work/human-gates') history.replaceState({}, '', '#/my-work'); + }); + nodes.list?.addEventListener?.('click', event => { + const card = event.target?.closest?.('[data-human-gate-id]'); + if (card) controller.select(card.dataset.humanGateId); + }); + 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); + }); + + async function start(force = false) { + if (!force && location.hash !== '#/my-work/human-gates') return false; + if (started) { + if (nodes.panel) nodes.panel.hidden = false; + return true; + } + if (startFlight) return startFlight; + startFlight = (async () => { + const identity = await getIdentity(); + login = String(identity?.login || '').trim(); + accountKey = String(identity?.accountKey || login).trim(); + if (!login) throw new Error('Authenticated account identity is required.'); + await controller.open(); + started = true; + return true; + })(); + try { return await startFlight; } + finally { startFlight = null; } + } + + return { + start, + handoff() { + return { + controller, started, + adoptIdentity(nextLogin, nextAccountKey) { + login = String(nextLogin || '').trim(); + accountKey = String(nextAccountKey || login).trim(); + }, + }; + }, + }; +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + window.stackchainProgressiveHumanGates = createProgressiveHumanGates({document}); + void window.stackchainProgressiveHumanGates.start().catch(() => {}); +} +if (typeof module !== 'undefined' && module.exports) { + globalThis.createHumanGates = globalThis.createHumanGates || require('./human-gates.js'); + module.exports = createProgressiveHumanGates; +} diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 1c144d6..f3f3fdb 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-v140'; +const CACHE = 'stackchain-dashboard-shell-v141'; 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; @@ -152,6 +152,7 @@ const SHELL = [ BASE + 'static/dashboard.css', BASE + 'static/dashboard.js', BASE + 'static/human-gates.js', + BASE + 'static/progressive-human-gates.js', BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index fd78493..0e06056 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-v140" in worker + assert "stackchain-dashboard-shell-v141" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 9b325d7..ed94120 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-v140" in service_worker + assert "stackchain-dashboard-shell-v141" 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 bbadecb..def152c 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -4,6 +4,7 @@ from pathlib import Path 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" WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" @@ -195,10 +196,57 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired(): assert 'data-mobile-queue="gate"' in index assert 'data-mobile-queue-count="gate"' in index assert "openHumanGates: () => openHumanGates()" in dashboard - assert "onChange:(snapshot, state)=>" in dashboard + assert "const humanGatesOnChange = (snapshot, state)=>" in dashboard assert "queueCounts.gate = snapshot.pending_count" in dashboard assert "preparationItems.gate = snapshot.items" in dashboard 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-v140" in WORKER.read_text() + assert "stackchain-dashboard-shell-v141" in WORKER.read_text() + + +def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace(): + script = f""" +const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))}); +const listeners={{}}; const requests=[]; +const nodes={{ + '#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:''}}, + '#human-gates-status':{{}}, '#human-gates':{{hidden:true}}, + '#human-gate-detail':{{innerHTML:'',querySelectorAll:()=>[]}}, + '#open-human-gates':{{addEventListener:(name,fn)=>listeners.open=fn}}, + '#close-human-gates':{{addEventListener:(name,fn)=>listeners.close=fn}}, +}}; +nodes['#human-gates-list'].addEventListener=(name,fn)=>listeners.list=fn; +nodes['#human-gate-detail'].addEventListener=(name,fn)=>listeners.detail=fn; +const document={{querySelector:selector=>nodes[selector]||null}}; +const app=createProgressiveHumanGates({{ + document, location:{{hash:'#/my-work/human-gates'}}, + history:{{replaceState(){{}}}}, storage:{{getItem:()=>null,setItem(){{}}}}, + isOnline:()=>true, getIdentity:async()=>({{login:'timmy',accountKey:'7:timmy'}}), + fetchJson:async path=>{{requests.push(path);return {{pending_count:1,items:[{{id:'g1',title:'Ship it',candidate_hash:'abc',revision:1,checks:[]}}]}};}}, +}}); +(async()=>{{const started=await app.start();process.stdout.write(JSON.stringify({{ + started,hidden:nodes['#human-gates'].hidden,html:nodes['#human-gates-list'].innerHTML, + requests,listeners:Object.keys(listeners).sort(),handoff:app.handoff().started, +}}));}})(); +""" + result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) + output = json.loads(result.stdout) + assert output == { + "started": True, + "hidden": False, + "html": '', + "requests": ["api/v1/human-gates", "api/v1/human-gates/g1"], + "listeners": ["close", "detail", "list", "open"], + "handoff": True, + } + + +def test_dashboard_adopts_progressive_human_gates_without_a_second_list_load(): + dashboard = DASHBOARD.read_text() + index = INDEX.read_text() + assert 'static/progressive-human-gates.js' in index + assert "window.stackchainProgressiveHumanGates?.handoff?.()" in dashboard + assert "progressiveHumanGatesHandoff?.controller || createHumanGates" in dashboard + assert "if (!progressiveHumanGatesHandoff?.started) humanGates.load()" in dashboard + assert "if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates()" in dashboard diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index f3e1a01..f728da0 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-v140" in source + assert "stackchain-dashboard-shell-v141" 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 8a84a02..e14d59f 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-v140" in worker + assert "stackchain-dashboard-shell-v141" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 9279dcb..4218e74 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-v140" in worker + assert "stackchain-dashboard-shell-v141" 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 b3eb521..cf591ac 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-v140" in worker + assert "stackchain-dashboard-shell-v141" 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 abfd87a..ac69eb8 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-v140" in worker + assert "stackchain-dashboard-shell-v141" 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 3e41f84..30d45fc 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-v140" in service_worker + assert "stackchain-dashboard-shell-v141" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 86c602b..ca3a1ae 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-v140" in source + assert "stackchain-dashboard-shell-v141" 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 d516713..bbadafa 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -189,7 +189,7 @@ async function dispatchPush(payload) {{ def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -197,20 +197,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-v140" in source + assert "stackchain-dashboard-shell-v141" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v140" in source + assert "stackchain-dashboard-shell-v141" 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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -219,7 +219,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-v140" in source + assert "stackchain-dashboard-shell-v141" 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 @@ -228,7 +228,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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -236,14 +236,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-v140" in source + assert "stackchain-dashboard-shell-v141" 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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -251,7 +251,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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -259,7 +259,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-v140" in source + assert "stackchain-dashboard-shell-v141" 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 @@ -269,14 +269,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-v140" in source + assert "stackchain-dashboard-shell-v141" 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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -285,21 +285,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-v140" in source + assert "stackchain-dashboard-shell-v141" 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-v140" in source + assert "stackchain-dashboard-shell-v141" 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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1354,7 +1354,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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/queue-today.js'" in source @@ -1374,6 +1374,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/dashboard.css", "/dashboard/static/dashboard.js", "/dashboard/static/human-gates.js", + "/dashboard/static/progressive-human-gates.js", "/dashboard/static/icons/stackchain-192.png", "/dashboard/static/icons/stackchain-512.png", "/dashboard/static/session.js", diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 6e7105d..e2f35fe 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-v140';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v141';" 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 b08aa99..7808f89 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-v140" in source + assert "stackchain-dashboard-shell-v141" in source assert "BASE + 'static/today-sync.js'" in source