feat: open Human Gates before optional hydration
All checks were successful
CI / lint (pull_request) Successful in 4m13s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m51s
CI / release-candidate (pull_request) Has been skipped

Closes #1419
This commit is contained in:
timmy 2026-08-26 04:52:14 +00:00
parent 718f0d1209
commit 5ec4dd7f6e
19 changed files with 224 additions and 63 deletions

View File

@ -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. 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 ## Producer intake
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as: Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:

View File

@ -3,6 +3,7 @@
await workspaceLifecycle.optionalReady; await workspaceLifecycle.optionalReady;
const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.(); const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.(); const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.();
window.stackchainProgressiveMyWork?.stop(); window.stackchainProgressiveMyWork?.stop();
const qs = (s, el=document) => el.querySelector(s); const qs = (s, el=document) => el.querySelector(s);
const announceWork = message => qs('#my-work-action-status').textContent = message; const announceWork = message => qs('#my-work-action-status').textContent = message;
@ -636,53 +637,58 @@
return payload; 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, storage:localStorage,
getLogin:()=>planningOwnerLogin, getLogin:()=>planningOwnerLogin,
getAccountKey:()=>planningOwnerAccountKey, getAccountKey:()=>planningOwnerAccountKey,
isOnline:()=>navigator.onLine, isOnline:()=>navigator.onLine,
location:window.location, location:window.location,
fetchJson:fetchReviewJson, fetchJson:fetchReviewJson,
onChange:(snapshot, state)=>{ onChange:humanGatesOnChange,
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();
},
nodes:{ nodes:{
count:qs('#human-gates-count'), list:qs('#human-gates-list'), count:qs('#human-gates-count'), list:qs('#human-gates-list'),
status:qs('#human-gates-status'), panel:qs('#human-gates'), status:qs('#human-gates-status'), panel:qs('#human-gates'),
detail:qs('#human-gate-detail'), detail:qs('#human-gate-detail'),
}, },
}); });
progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey);
humanGates.setOnChange?.(humanGatesOnChange);
const openHumanGates = () => humanGates.open().catch(error => { const openHumanGates = () => humanGates.open().catch(error => {
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.'; qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
}); });
qs('#open-human-gates').addEventListener('click', openHumanGates); if (!progressiveHumanGatesHandoff) {
qs('#close-human-gates').addEventListener('click', () => { qs('#open-human-gates').addEventListener('click', openHumanGates);
qs('#human-gates').hidden = true; qs('#close-human-gates').addEventListener('click', () => {
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work'); 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]'); qs('#human-gates-list').addEventListener('click', event => {
if (!card) return; const card = event.target.closest('[data-human-gate-id]');
humanGates.select(card.dataset.humanGateId); 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; qs('#human-gate-detail').addEventListener('click', event => {
if (!decision) return; const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
const detail = qs('#human-gate-detail'); if (!decision) return;
const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked])); const detail = qs('#human-gate-detail');
humanGates.decideAndNext(decision, { const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
checklist, humanGates.decideAndNext(decision, {
reason:detail.querySelector('[data-gate-reason]')?.value || '', checklist,
override_reason:detail.querySelector('[data-gate-override]')?.value || '', reason:detail.querySelector('[data-gate-reason]')?.value || '',
}).catch(error => { qs('#human-gates-status').textContent = error.message; }); 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?.started) humanGates.load().catch(() => {});
if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates();
function syncCompletedFiledReviews() { function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false); if (!planningOwnerLogin) return Promise.resolve(false);

View File

@ -13,6 +13,7 @@ function createHumanGates(options = {}) {
let loadEpoch = 0; let loadEpoch = 0;
const decisionKeys = new Map(); const decisionKeys = new Map();
let decisionFlight = null; let decisionFlight = null;
let onChange = options.onChange;
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({ const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@ -20,7 +21,7 @@ function createHumanGates(options = {}) {
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase(); const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; }; const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = 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) { function validSnapshot(value) {
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null; return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
@ -207,6 +208,7 @@ function createHumanGates(options = {}) {
return { return {
load, open, reviewNext, select, decideAndNext, current, load, open, reviewNext, select, decideAndNext, current,
setOnChange(callback) { onChange = callback; },
restoreCached: restore, restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)), snapshot: () => JSON.parse(JSON.stringify(queue)),
route: () => location.hash, route: () => location.hash,

View File

@ -2412,6 +2412,7 @@
<script src="static/mobile-find-work-nav.js"></script> <script src="static/mobile-find-work-nav.js"></script>
<script src="static/workspace-bootstrap.js"></script> <script src="static/workspace-bootstrap.js"></script>
<script src="static/human-gates.js"></script> <script src="static/human-gates.js"></script>
<script src="static/progressive-human-gates.js"></script>
<script src="static/dashboard.js"></script> <script src="static/dashboard.js"></script>
</body> </body>
</html> </html>

View File

@ -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;
}

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@ -152,6 +152,7 @@ const SHELL = [
BASE + 'static/dashboard.css', BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js', BASE + 'static/dashboard.js',
BASE + 'static/human-gates.js', BASE + 'static/human-gates.js',
BASE + 'static/progressive-human-gates.js',
BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png', BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js', BASE + 'static/session.js',

View File

@ -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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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

View File

@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && 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(): def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -4,6 +4,7 @@ from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "human-gates.js" 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" INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.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="gate"' in index
assert 'data-mobile-queue-count="gate"' in index assert 'data-mobile-queue-count="gate"' in index
assert "openHumanGates: () => openHumanGates()" in dashboard 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 "queueCounts.gate = snapshot.pending_count" in dashboard
assert "preparationItems.gate = snapshot.items" in dashboard assert "preparationItems.gate = snapshot.items" in dashboard
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
assert "counts.gate = queueCounts.gate" in dashboard assert "counts.gate = queueCounts.gate" in dashboard
assert "gate:preparationItems.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": '<button class="human-gate-card" type="button" data-human-gate-id="g1"><strong>Ship it</strong><code>abc</code><span>Priority 0</span></button>',
"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

View File

@ -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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/later-sync.js'" in source

View File

@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

View File

@ -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])) 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 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(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker 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-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() 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 assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -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 ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker 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 @pytest.mark.anyio

View File

@ -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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() 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.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -189,7 +189,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell(): def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text() 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(): def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() 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/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.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(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() 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-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() 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/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() 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/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() 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/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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.css",
"/dashboard/static/dashboard.js", "/dashboard/static/dashboard.js",
"/dashboard/static/human-gates.js", "/dashboard/static/human-gates.js",
"/dashboard/static/progressive-human-gates.js",
"/dashboard/static/icons/stackchain-192.png", "/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png", "/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js", "/dashboard/static/session.js",

View File

@ -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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() 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 assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/today-sync.js'" in source