diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 9972189..b056965 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 4bc6592..6ebedb3 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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 */
diff --git a/frontend/index.html b/frontend/index.html
index c0f22e3..e5ebb5f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -146,6 +146,17 @@
+
+
+
Make this phone work-ready
+
Install, save work offline, and enable update alerts.
+
+
+
+
+
+
+
diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js
index 7a60640..fdd7f3e 100644
--- a/frontend/mobile-device-setup.js
+++ b/frontend/mobile-device-setup.js
@@ -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(),
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 5d81c21..b30f115 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -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;
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index b0c345c..5d67fff 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-v87" in worker
+ assert "stackchain-dashboard-shell-v88" in worker
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index efdc099..e20628c 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -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';",
)
)
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index e64f184..02e476c 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -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
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 4a59c7f..20f30d1 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -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
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index f5ae168..39fc811 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -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():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index 65e7729..8126b7e 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -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 '' 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
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index 5ea4903..3b2af95 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -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
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index b64a1ce..745074f 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index d041f95..76afaed 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-v87';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v88';" 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 6b2d48a..db4b07d 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -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