Compare commits
No commits in common. "fefbe7e084dc9cc626192f6d1265837ebd09c33e" and "b458dbaa78b3f5affffb230d3b3d23de0db952e2" have entirely different histories.
fefbe7e084
...
b458dbaa78
|
|
@ -62,10 +62,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||||
.offline-work-controls input { width:20px; height:20px; }
|
.offline-work-controls input { width:20px; height:20px; }
|
||||||
.offline-work-controls button { min-height:44px; }
|
.offline-work-controls button { min-height:44px; }
|
||||||
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
|
||||||
.install-app-card[hidden] { display:none; }
|
|
||||||
.install-app-card p { margin:0; flex:1 1 240px; }
|
|
||||||
.install-app-card button { min-height:44px; }
|
|
||||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||||
|
|
@ -347,13 +343,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||||
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
|
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
|
||||||
<div class="install-app-card" id="install-app-card" hidden>
|
|
||||||
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
|
|
||||||
<p class="small" id="install-app-guidance" hidden>On Safari, tap Share, then choose <strong>Add to Home Screen</strong>.</p>
|
|
||||||
<button id="install-app" type="button">Install Stackchain</button>
|
|
||||||
<button id="dismiss-install-app" type="button">Not now</button>
|
|
||||||
</div>
|
|
||||||
<span class="small" id="install-app-status" role="status" aria-live="polite"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-work-list" id="my-work-list"></div>
|
<div class="my-work-list" id="my-work-list"></div>
|
||||||
|
|
@ -803,7 +792,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<script src="static/work-route.js"></script>
|
<script src="static/work-route.js"></script>
|
||||||
<script src="static/context-poller.js"></script>
|
<script src="static/context-poller.js"></script>
|
||||||
<script src="static/mobile-task-dock.js"></script>
|
<script src="static/mobile-task-dock.js"></script>
|
||||||
<script src="static/install-app.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const qs = (s, el=document) => el.querySelector(s);
|
const qs = (s, el=document) => el.querySelector(s);
|
||||||
|
|
@ -3743,22 +3731,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
console.warn('Stackchain install support unavailable', error)
|
console.warn('Stackchain install support unavailable', error)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
||||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
||||||
const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) &&
|
|
||||||
!/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent);
|
|
||||||
createInstallApp({
|
|
||||||
window,
|
|
||||||
card: qs('#install-app-card'),
|
|
||||||
installButton: qs('#install-app'),
|
|
||||||
dismissButton: qs('#dismiss-install-app'),
|
|
||||||
guidance: qs('#install-app-guidance'),
|
|
||||||
status: qs('#install-app-status'),
|
|
||||||
storage: localStorage,
|
|
||||||
isStandalone: () => window.matchMedia('(display-mode: standalone)').matches ||
|
|
||||||
navigator.standalone === true,
|
|
||||||
isIosSafari: () => isIosSafari,
|
|
||||||
}).start();
|
|
||||||
contextPoller.start();
|
contextPoller.start();
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
contextPoller.setVisible(!document.hidden);
|
contextPoller.setVisible(!document.hidden);
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
(function (root, factory) {
|
|
||||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
|
||||||
else root.createInstallApp = factory;
|
|
||||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
|
||||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
|
||||||
let deferredPrompt = null;
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
options.card.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNativePrompt(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
deferredPrompt = event;
|
|
||||||
options.guidance.hidden = true;
|
|
||||||
options.installButton.hidden = false;
|
|
||||||
options.card.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function install() {
|
|
||||||
if (!deferredPrompt) return;
|
|
||||||
const prompt = deferredPrompt;
|
|
||||||
deferredPrompt = null;
|
|
||||||
options.installButton.disabled = true;
|
|
||||||
await prompt.prompt();
|
|
||||||
const choice = await prompt.userChoice;
|
|
||||||
if (choice.outcome === 'accepted') {
|
|
||||||
hide();
|
|
||||||
options.status.textContent = 'Stackchain was added to your device.';
|
|
||||||
} else {
|
|
||||||
hide();
|
|
||||||
options.installButton.disabled = false;
|
|
||||||
options.status.textContent = 'Installation was not completed.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss() {
|
|
||||||
options.storage.setItem(dismissedKey, '1');
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
if (options.isStandalone() || options.storage.getItem(dismissedKey) === '1') {
|
|
||||||
hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.isIosSafari()) {
|
|
||||||
options.installButton.hidden = true;
|
|
||||||
options.guidance.hidden = false;
|
|
||||||
options.card.hidden = false;
|
|
||||||
}
|
|
||||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
|
||||||
options.window.addEventListener('appinstalled', () => {
|
|
||||||
deferredPrompt = null;
|
|
||||||
hide();
|
|
||||||
});
|
|
||||||
options.installButton.addEventListener('click', install);
|
|
||||||
options.dismissButton.addEventListener('click', dismiss);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {start};
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v24';
|
const CACHE = 'stackchain-dashboard-shell-v23';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -30,7 +30,6 @@ const SHELL = [
|
||||||
BASE + 'static/work-route.js',
|
BASE + 'static/work-route.js',
|
||||||
BASE + 'static/context-poller.js',
|
BASE + 'static/context-poller.js',
|
||||||
BASE + 'static/mobile-task-dock.js',
|
BASE + 'static/mobile-task-dock.js',
|
||||||
BASE + 'static/install-app.js',
|
|
||||||
BASE + 'static/background-issue-sync.js',
|
BASE + 'static/background-issue-sync.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.views import dashboard
|
|
||||||
|
|
||||||
|
|
||||||
INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js"
|
|
||||||
|
|
||||||
|
|
||||||
def run_install_scenario(scenario: str) -> dict:
|
|
||||||
script = f"""
|
|
||||||
const createInstallApp = require({json.dumps(str(INSTALL_APP))});
|
|
||||||
class FakeTarget {{
|
|
||||||
constructor() {{ this.listeners = {{}}; }}
|
|
||||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
|
||||||
dispatch(name, event = {{}}) {{ for (const callback of this.listeners[name] || []) callback(event); }}
|
|
||||||
}}
|
|
||||||
class FakeButton extends FakeTarget {{
|
|
||||||
constructor() {{ super(); this.disabled = false; }}
|
|
||||||
click() {{ this.dispatch('click', {{currentTarget:this}}); }}
|
|
||||||
}}
|
|
||||||
(async () => {{
|
|
||||||
{scenario}
|
|
||||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
||||||
"""
|
|
||||||
completed = subprocess.run(
|
|
||||||
["node", "-e", script], capture_output=True, text=True
|
|
||||||
)
|
|
||||||
assert completed.returncode == 0, completed.stderr
|
|
||||||
return json.loads(completed.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_install_prompt_is_revealed_by_eligibility_and_invoked_once():
|
|
||||||
result = run_install_scenario(
|
|
||||||
"""
|
|
||||||
const windowTarget = new FakeTarget();
|
|
||||||
const card = {hidden:true};
|
|
||||||
const install = new FakeButton();
|
|
||||||
const dismiss = new FakeButton();
|
|
||||||
const guidance = {hidden:true};
|
|
||||||
const status = {textContent:''};
|
|
||||||
let promptCalls = 0;
|
|
||||||
const app = createInstallApp({
|
|
||||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
|
||||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
|
||||||
isStandalone:()=>false, isIosSafari:()=>false,
|
|
||||||
});
|
|
||||||
app.start();
|
|
||||||
windowTarget.dispatch('beforeinstallprompt', {
|
|
||||||
preventDefault(){},
|
|
||||||
prompt:async () => { promptCalls += 1; },
|
|
||||||
userChoice:Promise.resolve({outcome:'accepted'}),
|
|
||||||
});
|
|
||||||
const shown = !card.hidden;
|
|
||||||
install.click();
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
install.click();
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
process.stdout.write(JSON.stringify({shown, promptCalls, hidden:card.hidden, status:status.textContent}));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"shown": True,
|
|
||||||
"promptCalls": 1,
|
|
||||||
"hidden": True,
|
|
||||||
"status": "Stackchain was added to your device.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_declined_native_install_hides_the_spent_choice_and_announces_it():
|
|
||||||
result = run_install_scenario(
|
|
||||||
"""
|
|
||||||
const windowTarget = new FakeTarget();
|
|
||||||
const card = {hidden:true};
|
|
||||||
const install = new FakeButton();
|
|
||||||
const status = {textContent:''};
|
|
||||||
const app = createInstallApp({
|
|
||||||
window:windowTarget, card, installButton:install, dismissButton:new FakeButton(),
|
|
||||||
guidance:{hidden:true}, status, storage:{getItem:()=>null, setItem:()=>{}},
|
|
||||||
isStandalone:()=>false, isIosSafari:()=>false,
|
|
||||||
});
|
|
||||||
app.start();
|
|
||||||
windowTarget.dispatch('beforeinstallprompt', {
|
|
||||||
preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'dismissed'}),
|
|
||||||
});
|
|
||||||
install.click();
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
process.stdout.write(JSON.stringify({hidden:card.hidden, disabled:install.disabled, status:status.textContent}));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"hidden": True,
|
|
||||||
"disabled": False,
|
|
||||||
"status": "Installation was not completed.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_ios_safari_gets_manual_add_to_home_screen_guidance():
|
|
||||||
result = run_install_scenario(
|
|
||||||
"""
|
|
||||||
const windowTarget = new FakeTarget();
|
|
||||||
const card = {hidden:true};
|
|
||||||
const install = new FakeButton(); install.hidden = false;
|
|
||||||
const dismiss = new FakeButton();
|
|
||||||
const guidance = {hidden:true};
|
|
||||||
const status = {textContent:''};
|
|
||||||
const app = createInstallApp({
|
|
||||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
|
||||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
|
||||||
isStandalone:()=>false, isIosSafari:()=>true,
|
|
||||||
});
|
|
||||||
app.start();
|
|
||||||
process.stdout.write(JSON.stringify({cardHidden:card.hidden, installHidden:install.hidden, guidanceHidden:guidance.hidden}));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"cardHidden": False,
|
|
||||||
"installHidden": True,
|
|
||||||
"guidanceHidden": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_standalone_launch_never_promotes_installation():
|
|
||||||
result = run_install_scenario(
|
|
||||||
"""
|
|
||||||
const windowTarget = new FakeTarget();
|
|
||||||
const card = {hidden:true};
|
|
||||||
const app = createInstallApp({
|
|
||||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
|
||||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
|
||||||
isStandalone:()=>true, isIosSafari:()=>true,
|
|
||||||
});
|
|
||||||
app.start();
|
|
||||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
|
||||||
process.stdout.write(JSON.stringify({hidden:card.hidden}));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"hidden": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dismissal_is_remembered_and_suppresses_later_promotion():
|
|
||||||
result = run_install_scenario(
|
|
||||||
"""
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {getItem:key => values.get(key) || null, setItem:(key, value) => values.set(key, value)};
|
|
||||||
const firstWindow = new FakeTarget();
|
|
||||||
const firstCard = {hidden:true};
|
|
||||||
const firstDismiss = new FakeButton();
|
|
||||||
const first = createInstallApp({
|
|
||||||
window:firstWindow, card:firstCard, installButton:new FakeButton(), dismissButton:firstDismiss,
|
|
||||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
|
||||||
isStandalone:()=>false, isIosSafari:()=>true,
|
|
||||||
});
|
|
||||||
first.start();
|
|
||||||
firstDismiss.click();
|
|
||||||
const secondCard = {hidden:true};
|
|
||||||
const second = createInstallApp({
|
|
||||||
window:new FakeTarget(), card:secondCard, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
|
||||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
|
||||||
isStandalone:()=>false, isIosSafari:()=>true,
|
|
||||||
});
|
|
||||||
second.start();
|
|
||||||
process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:values.size, secondHidden:secondCard.hidden}));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_appinstalled_hides_an_visible_native_promotion():
|
|
||||||
result = run_install_scenario(
|
|
||||||
"""
|
|
||||||
const windowTarget = new FakeTarget();
|
|
||||||
const card = {hidden:true};
|
|
||||||
const app = createInstallApp({
|
|
||||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
|
||||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
|
||||||
isStandalone:()=>false, isIosSafari:()=>false,
|
|
||||||
});
|
|
||||||
app.start();
|
|
||||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
|
||||||
const shown = !card.hidden;
|
|
||||||
windowTarget.dispatch('appinstalled');
|
|
||||||
process.stdout.write(JSON.stringify({shown, hidden:card.hidden}));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"shown": True, "hidden": True}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_dashboard_renders_and_wires_touch_safe_install_promotion():
|
|
||||||
html = await dashboard()
|
|
||||||
|
|
||||||
assert 'id="install-app-card"' in html
|
|
||||||
assert 'id="install-app"' in html
|
|
||||||
assert 'id="dismiss-install-app"' in html
|
|
||||||
assert 'id="install-app-guidance"' in html
|
|
||||||
assert 'Share, then choose <strong>Add to Home Screen</strong>' in html
|
|
||||||
assert '.install-app-card button { min-height:44px;' in html
|
|
||||||
assert '<script src="static/install-app.js"></script>' in html
|
|
||||||
assert 'createInstallApp({' in html
|
|
||||||
assert "window.matchMedia('(display-mode: standalone)').matches" in html
|
|
||||||
assert "navigator.standalone === true" in html
|
|
||||||
assert "isIosSafari" in html
|
|
||||||
|
|
@ -91,11 +91,11 @@ async function dispatchNotificationClick(route) {{
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_install_flow_ships_in_a_new_shell_cache():
|
def test_live_section_revisions_ship_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v24" in source
|
assert "stackchain-dashboard-shell-v23" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/today-work.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||||
|
|
@ -207,7 +207,6 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/work-route.js",
|
"/dashboard/static/work-route.js",
|
||||||
"/dashboard/static/context-poller.js",
|
"/dashboard/static/context-poller.js",
|
||||||
"/dashboard/static/mobile-task-dock.js",
|
"/dashboard/static/mobile-task-dock.js",
|
||||||
"/dashboard/static/install-app.js",
|
|
||||||
"/dashboard/static/background-issue-sync.js",
|
"/dashboard/static/background-issue-sync.js",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user