feat: surface mobile device readiness (Closes #559)
All checks were successful
CI / lint (pull_request) Successful in 1m16s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-11 09:33:27 +00:00
parent f446e10670
commit 7f8ee771dc
15 changed files with 151 additions and 26 deletions

View File

@ -28,6 +28,11 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
.device-setup-step p { margin:5px 0 0; }
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
.device-readiness-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.device-readiness-actions button { min-height:44px; min-width:0; }
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; }
@ -476,6 +481,7 @@ textarea { resize: vertical; min-height: 120px; }
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.device-setup-step { grid-template-columns:1fr; }
.device-setup-action { width:100%; }
.device-readiness-card:not([hidden]) { display:grid; gap:12px; margin-left:max(0px,env(safe-area-inset-left)); margin-right:max(0px,env(safe-area-inset-right)); }
.my-work { margin:0; }
.my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; }

View File

@ -5290,6 +5290,7 @@
await controller.init();
return controller;
}).catch(error => {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
console.warn('Push notifications unavailable', error);
return null;
@ -5304,8 +5305,8 @@
deferredInstallPrompt = event;
});
let deviceSetup = null;
qs('#open-device-setup').addEventListener('click', async event => {
if (deviceSetup) return;
async function ensureDeviceSetup() {
if (deviceSetup) return deviceSetup;
await issueCaptureFeatures.load('device-setup');
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
@ -5319,7 +5320,7 @@
});
installApp.start();
deviceSetup = createMobileDeviceSetup.mount({
document, installApp,
document, installApp, promptStorage:localStorage,
offlineAvailable:() => offlineStorageReady,
offlineEnabled:() => offlineWorkStore.enabled(),
enableOffline:() => setOfflineWorkEnabled(true),
@ -5330,12 +5331,17 @@
await controller.change();
},
});
deviceSetup.start();
await deviceSetup.open(event);
await deviceSetup.start();
return deviceSetup;
}
qs('#open-device-setup').addEventListener('click', async event => {
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
});
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
contextPoller.start();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
if (!document.hidden) deviceSetup?.render();
});
/* Widgets */

View File

@ -146,6 +146,17 @@
</div>
</details>
</div>
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
<div>
<strong id="device-readiness-heading">Make this phone work-ready</strong>
<p class="small muted">Install, save work offline, and enable update alerts.</p>
<p class="small device-readiness-summary" id="device-readiness-summary" role="status" aria-live="polite"></p>
</div>
<div class="device-readiness-actions">
<button id="finish-device-setup" type="button">Finish setup</button>
<button id="dismiss-device-readiness" type="button">Not now</button>
</div>
</section>
<div class="update-selection-controls" id="update-selection-controls" hidden>
<button id="select-updates" type="button">Select updates</button>
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>

View File

@ -2,6 +2,8 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeviceSetup = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
@ -9,6 +11,23 @@
];
let trigger = options.launcher;
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
return {available, complete};
}
function renderPrompt(readiness) {
if (!options.promptCard) return;
const {available, complete} = readinessCounts(readiness);
let dismissed = false;
try {
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
} catch (_error) {}
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
options.promptCard.hidden = dismissed || available === 0 || complete === available;
}
async function render() {
const readiness = await options.getReadiness();
let available = 0;
@ -24,6 +43,7 @@
options.readyStatus.textContent = complete === available
? 'This device is ready.'
: `${complete} of ${available} available steps ready.`;
renderPrompt(readiness);
return readiness;
}
@ -39,8 +59,15 @@
trigger?.focus?.();
}
function start() {
async function start() {
options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open);
options.promptDismiss?.addEventListener('click', () => {
try {
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
} catch (_error) {}
options.promptCard.hidden = true;
});
options.closeButton.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
@ -55,6 +82,7 @@
finally { await render(); }
});
});
renderPrompt(await options.getReadiness());
}
return {start, open, close, render};
@ -74,6 +102,9 @@ function mountMobileDeviceSetup(options) {
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
escapeTarget:options.document,
getReadiness:() => ({
install:options.installApp.state(),

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v87';
const CACHE = 'stackchain-dashboard-shell-v88';
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

@ -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-v87" in worker
assert "stackchain-dashboard-shell-v88" in worker

View File

@ -141,7 +141,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v87';",
"const CACHE = 'stackchain-dashboard-shell-v88';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)

View File

@ -347,5 +347,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-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,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-v87" in worker
assert "stackchain-dashboard-shell-v88" in worker

View File

@ -41,7 +41,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-v87" in worker
assert "stackchain-dashboard-shell-v88" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -27,6 +27,16 @@ const offlineStatus = new FakeTarget();
const pushStatus = new FakeTarget();
const readyStatus = new FakeTarget();
const escapeTarget = new FakeTarget();
const promptCard = new FakeTarget(); promptCard.hidden = true;
const promptSummary = new FakeTarget();
const promptLauncher = new FakeTarget();
const promptDismiss = new FakeTarget();
let now = 1000;
const promptStorage = {
values:{},
getItem(key) { return this.values[key] ?? null; },
setItem(key, value) { this.values[key] = String(value); },
};
let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'},
@ -35,12 +45,14 @@ let readiness = {
const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
promptCard, promptSummary, promptLauncher, promptDismiss,
promptStorage, now:() => now,
getReadiness:() => readiness,
install:async () => { state.installCalls += 1; },
enableOffline:async () => { state.offlineCalls += 1; },
enablePush:async () => { state.pushCalls += 1; },
});
(async () => { setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
@ -70,6 +82,54 @@ process.stdout.write(JSON.stringify({
}
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
result = run_scenario("""
process.stdout.write(JSON.stringify({
hidden:promptCard.hidden,
summary:promptSummary.textContent,
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
}));
""")
assert result == {
"hidden": False,
"summary": "1 of 2 steps complete",
"calls": [0, 0, 0],
}
def test_readiness_card_opens_setup_and_receives_focus_back_on_close():
result = run_scenario("""
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
const opened = !sheet.hidden && state.focused === closeButton;
await closeButton.dispatch('click');
process.stdout.write(JSON.stringify({
opened,
closed:sheet.hidden,
promptFocused:state.focused === promptLauncher,
}));
""")
assert result == {"opened": True, "closed": True, "promptFocused": True}
def test_not_now_hides_prompt_for_seven_days_then_allows_it_to_return():
result = run_scenario("""
await promptDismiss.dispatch('click');
const hiddenAfterDismiss = promptCard.hidden;
const dismissedUntil = Number(promptStorage.values['stackchain.device-setup-prompt-dismissed-until']);
now = dismissedUntil + 1;
await setup.render();
process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil - 1000, hiddenAfterExpiry:promptCard.hidden}));
""")
assert result == {
"hiddenAfterDismiss": True,
"delay": 7 * 24 * 60 * 60 * 1000,
"hiddenAfterExpiry": False,
}
def test_setup_action_rechecks_real_state_before_marking_step_ready():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
@ -79,6 +139,7 @@ process.stdout.write(JSON.stringify({
calls:state.offlineCalls,
offline:offlineStatus.textContent,
hidden:offlineButton.hidden,
promptHidden:promptCard.hidden,
summary:readyStatus.textContent,
}));
""")
@ -87,6 +148,7 @@ process.stdout.write(JSON.stringify({
"calls": 1,
"offline": "Offline work is saved.",
"hidden": True,
"promptHidden": True,
"summary": "This device is ready.",
}
@ -112,13 +174,22 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
css = (root / "frontend" / "dashboard.css").read_text()
assert 'id="open-device-setup"' in html
assert 'id="device-readiness-card"' in html
assert 'id="finish-device-setup"' in html
assert 'id="dismiss-device-readiness"' in html
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
assert 'id="device-setup-sheet"' in html
assert 'aria-labelledby="device-setup-heading"' in html
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
assert '<script src="static/mobile-device-setup.js"></script>' in html
assert "createMobileDeviceSetup.mount({" in dashboard
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v88" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
assert "env(safe-area-inset-bottom)" in css
assert ".device-setup-action { min-height:44px;" in css
assert ".device-readiness-actions button { min-height:44px;" in css

View File

@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -133,7 +133,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -142,14 +142,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v87" in source
assert "stackchain-dashboard-shell-v88" 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-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -157,7 +157,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-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -165,14 +165,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v87" in source
assert "stackchain-dashboard-shell-v88" 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-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -181,21 +181,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-v87" in source
assert "stackchain-dashboard-shell-v88" 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-v87" in source
assert "stackchain-dashboard-shell-v88" 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-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -432,7 +432,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-v87" in source
assert "stackchain-dashboard-shell-v88" 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-v87';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v88';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
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-v87" in source
assert "stackchain-dashboard-shell-v88" in source
assert "BASE + 'static/today-sync.js'" in source