Merge pull request 'Guide mobile operators through device readiness setup' (#556)
All checks were successful
CI / lint (push) Successful in 1m14s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s

Closes #555
This commit is contained in:
timmy 2026-08-11 08:35:08 +00:00
commit cdf6ecdcb8
11 changed files with 386 additions and 29 deletions

View File

@ -18,6 +18,16 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.active-devices-header h2, .active-devices-header p { margin-top:0; } .active-devices-header h2, .active-devices-header p { margin-top:0; }
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; } .active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
.active-devices-list { display:grid; gap:10px; margin-top:16px; } .active-devices-list { display:grid; gap:10px; margin-top:16px; }
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.device-setup-sheet[hidden] { display:none; }
.device-setup-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.device-setup-header h2, .device-setup-header p { margin-top:0; }
.device-setup-header button, .device-setup-action { min-height:44px; }
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
.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; }
.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 { 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 strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; } .active-device-current { color:#55d6be; font-weight:700; }
@ -463,6 +473,9 @@ textarea { resize: vertical; min-height: 120px; }
.app-menu:not([open]) > .app-menu-panel { display:none; } .app-menu:not([open]) > .app-menu-panel { display:none; }
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); } .app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
.app-menu-panel button { min-height:44px; width:100%; } .app-menu-panel button { min-height:44px; width:100%; }
.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%; }
.my-work { margin:0; } .my-work { margin:0; }
.my-work-header { align-items:flex-start; } .my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; } .my-work-actions { width:100%; flex-wrap:nowrap; }

View File

@ -418,6 +418,7 @@
urls: { urls: {
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '', 'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '', 'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '',
}, },
}); });
let sharedLaunchState = null; let sharedLaunchState = null;
@ -5066,20 +5067,25 @@
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
}); });
} }
keepWorkOffline.addEventListener('change', async () => { async function setOfflineWorkEnabled(enabled) {
offlineWorkStore.setEnabled(keepWorkOffline.checked); keepWorkOffline.checked = enabled;
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) { offlineWorkStore.setEnabled(enabled);
if (enabled && liveMode && lastContextSnapshot) {
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications, const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
notification_pagination:notificationPagination }); notification_pagination:notificationPagination });
if (admitted === false) { if (admitted === false) {
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.'); await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
return; return false;
} }
} }
if (!keepWorkOffline.checked) await offlineWorkStore.clear(); if (!enabled) await offlineWorkStore.clear();
await updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.'); await updateOfflineWorkControls(enabled ? 'Offline saving enabled.' : 'Offline work data cleared.');
if (keepWorkOffline.checked) warmTodayOffline(); if (enabled) warmTodayOffline();
else offlineToday.cancel(); else offlineToday.cancel();
return offlineWorkStore.enabled();
}
keepWorkOffline.addEventListener('change', async () => {
await setOfflineWorkEnabled(keepWorkOffline.checked);
}); });
deliveryReceipts.addEventListener('change', async () => { deliveryReceipts.addEventListener('change', async () => {
let enabled = deliveryReceipts.checked; let enabled = deliveryReceipts.checked;
@ -5270,40 +5276,63 @@
renderMyWork(); renderMyWork();
if (workSession.active()) workSession.reconcile(); if (workSession.active()) workSession.reconcile();
}); });
let pushControllerReady = Promise.resolve(null);
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js').then(async () => { pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
await issueCaptureFeatures.load('push-notifications'); await issueCaptureFeatures.load('push-notifications');
return createPushNotifications({ const controller = createPushNotifications({
control:qs('#push-updates'), control:qs('#push-updates'),
status:qs('#push-update-status'), status:qs('#push-update-status'),
notification:window.Notification, notification:window.Notification,
serviceWorker:navigator.serviceWorker, serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson, fetchJson:fetchReviewJson,
}).init(); });
await controller.init();
return controller;
}).catch(error => { }).catch(error => {
qs('#push-update-status').textContent = 'Update notification settings unavailable.'; qs('#push-update-status').textContent = 'Update notification settings unavailable.';
console.warn('Push notifications unavailable', error); console.warn('Push notifications unavailable', error);
return null;
}); });
} else { } else {
qs('#push-updates').disabled = true; qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'This browser does not support update notifications.'; qs('#push-update-status').textContent = 'This browser does not support update notifications.';
} }
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || let deferredInstallPrompt = null;
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); window.addEventListener('beforeinstallprompt', event => {
const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) && event.preventDefault();
!/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent); deferredInstallPrompt = event;
createInstallApp({ });
window, let deviceSetup = null;
card: qs('#install-app-card'), qs('#open-device-setup').addEventListener('click', async event => {
installButton: qs('#install-app'), if (deviceSetup) return;
dismissButton: qs('#dismiss-install-app'), await issueCaptureFeatures.load('device-setup');
guidance: qs('#install-app-guidance'), const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
status: qs('#install-app-status'), (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
storage: localStorage, const installApp = createInstallApp({
isStandalone: () => window.matchMedia('(display-mode: standalone)').matches || window, initialPrompt:deferredInstallPrompt,
navigator.standalone === true, card:qs('#install-app-card'), installButton:qs('#install-app'),
isIosSafari: () => isIosSafari, dismissButton:qs('#dismiss-install-app'), guidance:qs('#install-app-guidance'),
}).start(); status:qs('#install-app-status'), storage:localStorage,
isStandalone:() => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true,
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
});
installApp.start();
deviceSetup = createMobileDeviceSetup.mount({
document, installApp,
offlineAvailable:() => offlineStorageReady,
offlineEnabled:() => offlineWorkStore.enabled(),
enableOffline:() => setOfflineWorkEnabled(true),
enablePush:async () => {
const controller = await pushControllerReady;
if (!controller) return;
qs('#push-updates').checked = true;
await controller.change();
},
});
deviceSetup.start();
await deviceSetup.open(event);
});
contextPoller.start(); contextPoller.start();
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden); contextPoller.setVisible(!document.hidden);

View File

@ -18,6 +18,7 @@
<div class="app-menu-panel"> <div class="app-menu-panel">
<button id="refresh">Refresh</button> <button id="refresh">Refresh</button>
<button id="open-palette">Command</button> <button id="open-palette">Command</button>
<button id="open-device-setup" type="button">Device setup</button>
<button id="active-devices" type="button">Active devices</button> <button id="active-devices" type="button">Active devices</button>
<button id="sign-out" type="button">Sign out &amp; clear this device</button> <button id="sign-out" type="button">Sign out &amp; clear this device</button>
<button id="sign-out-all" type="button">Sign out all devices</button> <button id="sign-out-all" type="button">Sign out all devices</button>
@ -59,6 +60,33 @@
</section> </section>
</div> </div>
<div id="device-setup-sheet" class="device-setup-sheet" hidden>
<section class="device-setup-panel" role="dialog" aria-modal="true" aria-labelledby="device-setup-heading">
<div class="device-setup-header">
<div>
<h2 id="device-setup-heading">Make this device work-ready</h2>
<p class="small muted">Prepare this phone for fast launch, outages, and new update alerts. Nothing asks for permission until you choose it.</p>
</div>
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
</div>
<ol class="device-setup-list">
<li class="device-setup-step">
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-install" type="button">Install</button>
</li>
<li class="device-setup-step">
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
</li>
</ol>
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
</section>
</div>
<main> <main>
<section class="panel my-work" id="my-work" tabindex="-1"> <section class="panel my-work" id="my-work" tabindex="-1">
<div class="my-work-header"> <div class="my-work-header">
@ -776,6 +804,7 @@
<script src="static/mobile-work-entry.js"></script> <script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-launch.js"></script> <script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script> <script src="static/install-app.js"></script>
<script src="static/mobile-device-setup.js"></script>
<script src="static/mobile-search-viewport.js"></script> <script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script> <script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script> <script src="static/mention-composer.js"></script>

View File

@ -3,7 +3,8 @@
else root.createInstallApp = factory; else root.createInstallApp = factory;
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) { })(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
const dismissedKey = 'stackchain.install.dismissed.v1'; const dismissedKey = 'stackchain.install.dismissed.v1';
let deferredPrompt = null; let deferredPrompt = options.initialPrompt || null;
let installed = false;
function hide() { function hide() {
options.card.hidden = true; options.card.hidden = true;
@ -25,6 +26,7 @@
await prompt.prompt(); await prompt.prompt();
const choice = await prompt.userChoice; const choice = await prompt.userChoice;
if (choice.outcome === 'accepted') { if (choice.outcome === 'accepted') {
installed = true;
hide(); hide();
options.status.textContent = 'Stackchain was added to your device.'; options.status.textContent = 'Stackchain was added to your device.';
} else { } else {
@ -44,6 +46,7 @@
hide(); hide();
return; return;
} }
if (deferredPrompt) showNativePrompt(deferredPrompt);
if (options.isIosSafari()) { if (options.isIosSafari()) {
options.installButton.hidden = true; options.installButton.hidden = true;
options.guidance.hidden = false; options.guidance.hidden = false;
@ -51,6 +54,7 @@
} }
options.window.addEventListener('beforeinstallprompt', showNativePrompt); options.window.addEventListener('beforeinstallprompt', showNativePrompt);
options.window.addEventListener('appinstalled', () => { options.window.addEventListener('appinstalled', () => {
installed = true;
deferredPrompt = null; deferredPrompt = null;
hide(); hide();
}); });
@ -58,5 +62,18 @@
options.dismissButton.addEventListener('click', dismiss); options.dismissButton.addEventListener('click', dismiss);
} }
return {start}; function state() {
if (installed || options.isStandalone()) {
return {state:'complete', detail:'Stackchain is installed on this device.'};
}
if (options.isIosSafari()) {
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
}
if (deferredPrompt) {
return {state:'incomplete', detail:'Stackchain is ready to install.'};
}
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
}
return {start, install, state};
}); });

View File

@ -0,0 +1,95 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeviceSetup = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
];
let trigger = options.launcher;
async function render() {
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
const step = readiness[name];
status.textContent = step.detail;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
if (step.state === 'complete') complete += 1;
});
options.readyStatus.textContent = complete === available
? 'This device is ready.'
: `${complete} of ${available} available steps ready.`;
return readiness;
}
async function open(event) {
trigger = event?.currentTarget || options.launcher;
await render();
options.sheet.hidden = false;
options.closeButton.focus();
}
function close() {
options.sheet.hidden = true;
trigger?.focus?.();
}
function start() {
options.launcher.addEventListener('click', open);
options.closeButton.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget.addEventListener('keydown', event => {
if (event.key === 'Escape' && !options.sheet.hidden) close();
});
steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => {
button.disabled = true;
try { await action(); }
finally { await render(); }
});
});
}
return {start, open, close, render};
});
if (typeof module === 'object' && module.exports) {
module.exports.mount = mountMobileDeviceSetup;
} else {
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
}
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
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'),
escapeTarget:options.document,
getReadiness:() => ({
install:options.installApp.state(),
offline:!options.offlineAvailable()
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
enablePush:options.enablePush,
});
}

View File

@ -60,6 +60,7 @@ const SHELL = [
BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-launch.js', BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js', BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
BASE + 'static/mobile-search-viewport.js', BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js', BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js', BASE + 'static/mention-composer.js',

View File

@ -20,6 +20,7 @@ FEATURE_SOURCES = {
"issue-capture": ("static/create-issue-sheet.js",), "issue-capture": ("static/create-issue-sheet.js",),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",), "push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
} }
CACHE_DECLARATION = re.compile( CACHE_DECLARATION = re.compile(
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"

View File

@ -45,7 +45,7 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND) first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications"} assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications", "device-setup"}
capture = first.feature_bundles["issue-capture"] capture = first.feature_bundles["issue-capture"]
pull_workflow = first.feature_bundles["pull-workflow"] pull_workflow = first.feature_bundles["pull-workflow"]
assert b"function createIssueCapture" not in first.runtime_bytes assert b"function createIssueCapture" not in first.runtime_bytes

View File

@ -174,6 +174,53 @@ process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:va
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True} assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
def test_setup_api_reports_install_state_without_prompting():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const app = createInstallApp({
window:windowTarget, card:{hidden:true}, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
const before = app.state();
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
const after = app.state();
process.stdout.write(JSON.stringify({before, after}));
"""
)
assert result == {
"before": {"state": "unavailable", "detail": "Installation is available from a supported browser."},
"after": {"state": "incomplete", "detail": "Stackchain is ready to install."},
}
def test_captured_install_prompt_remains_available_until_setup_opens():
result = run_install_scenario(
"""
let prevented = 0;
const prompt = {preventDefault(){prevented += 1;}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})};
const card = {hidden:true};
const app = createInstallApp({
window:new FakeTarget(), initialPrompt:prompt, card,
installButton:new FakeButton(), dismissButton:new FakeButton(), guidance:{hidden:true},
status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
process.stdout.write(JSON.stringify({state:app.state(), shown:!card.hidden, prevented}));
"""
)
assert result == {
"state": {"state": "incomplete", "detail": "Stackchain is ready to install."},
"shown": True,
"prevented": 1,
}
def test_appinstalled_hides_an_visible_native_promotion(): def test_appinstalled_hides_an_visible_native_promotion():
result = run_install_scenario( result = run_install_scenario(
""" """

View File

@ -0,0 +1,124 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js"
def run_scenario(script: str) -> dict:
harness = r"""
const createMobileDeviceSetup = require(__MODULE__);
class FakeTarget {
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
focus() { state.focused = this; }
}
const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null};
const launcher = new FakeTarget();
const closeButton = new FakeTarget();
const sheet = new FakeTarget(); sheet.hidden = true;
const installButton = new FakeTarget();
const offlineButton = new FakeTarget();
const pushButton = new FakeTarget();
const installStatus = new FakeTarget();
const offlineStatus = new FakeTarget();
const pushStatus = new FakeTarget();
const readyStatus = new FakeTarget();
const escapeTarget = new FakeTarget();
let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'},
push:{state:'unavailable', detail:'Notifications are unavailable.'},
};
const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
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); });
""".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
return json.loads(completed.stdout)
def test_opening_setup_reports_actual_readiness_without_triggering_permissions():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
install:installStatus.textContent,
offline:offlineStatus.textContent,
push:pushStatus.textContent,
summary:readyStatus.textContent,
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
}));
""")
assert result == {
"hidden": False,
"install": "Stackchain is installed.",
"offline": "Offline work is off.",
"push": "Notifications are unavailable.",
"summary": "1 of 2 available steps ready.",
"calls": [0, 0, 0],
}
def test_setup_action_rechecks_real_state_before_marking_step_ready():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
await offlineButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:state.offlineCalls,
offline:offlineStatus.textContent,
hidden:offlineButton.hidden,
summary:readyStatus.textContent,
}));
""")
assert result == {
"calls": 1,
"offline": "Offline work is saved.",
"hidden": True,
"summary": "This device is ready.",
}
def test_escape_closes_setup_and_restores_launcher_focus():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
await escapeTarget.dispatch('keydown', {key:'Escape'});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
launcherFocused:state.focused === launcher,
}));
""")
assert result == {"hidden": True, "launcherFocused": True}
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
assert 'id="open-device-setup"' in html
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 "BASE + 'static/mobile-device-setup.js'" in worker
assert ".device-setup-panel" 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

View File

@ -502,6 +502,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-launch.js", "/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js", "/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js", "/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js", "/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js", "/dashboard/static/mention-composer.js",