Keep Human Gate release decisions visible and actionable on mobile #1440

Merged
rockachopa merged 1 commits from timmy/1439-human-gate-mobile-decision-tray into main 2026-08-26 18:38:57 +00:00
19 changed files with 209 additions and 56 deletions

View File

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

View File

@ -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(() => {});

View File

@ -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 = {}) {
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
'<div><button type="button" data-gate-decision="release">Release &amp; next</button>' +
'<button type="button" data-gate-decision="hold">Hold &amp; next</button></div></article>'
'<div class="human-gate-decision-tray"><div class="human-gate-decision-state">' +
'<strong data-gate-readiness>0 of 3 confirmations complete</strong>' +
'<span data-gate-error role="alert" hidden></span></div>' +
'<div class="human-gate-decision-actions"><button type="button" data-gate-decision="hold">Hold &amp; next</button>' +
'<button type="button" data-gate-decision="release">Release &amp; next</button></div></div></article>'
);
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)),

View File

@ -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) {

View File

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

View File

@ -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()")

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 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

View File

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

View File

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

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():
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

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 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

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

View File

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

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():
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

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 "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

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():
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

View File

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

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():
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

View File

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