Add a thumb-reachable mobile task dock #237
|
|
@ -70,6 +70,12 @@ HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
||||||
|
|
||||||
## Offline mobile shell
|
## Offline mobile shell
|
||||||
|
|
||||||
|
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
|
||||||
|
**Search**, and **Drafts** thumb-reachable. It reuses the existing sheets and My Work
|
||||||
|
filter, preserves the selected release lane, shows the current draft count, respects
|
||||||
|
the device safe area, and moves out of the way while a full-screen task is open.
|
||||||
|
Desktop layout is unchanged.
|
||||||
|
|
||||||
After one successful online load, the installed dashboard precaches a versioned,
|
After one successful online load, the installed dashboard precaches a versioned,
|
||||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||||
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||||
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||||
|
.mobile-task-dock { display:none; }
|
||||||
|
.mobile-task-dock[hidden] { display:none; }
|
||||||
|
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||||
|
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||||
|
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||||
header { align-items:flex-start; }
|
header { align-items:flex-start; }
|
||||||
.my-work { margin:0; }
|
.my-work { margin:0; }
|
||||||
.my-work-list { grid-template-columns:1fr; }
|
.my-work-list { grid-template-columns:1fr; }
|
||||||
|
|
@ -262,6 +268,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
|
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||||
}
|
}
|
||||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||||
|
|
@ -284,7 +291,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section class="panel my-work" id="my-work">
|
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||||
<div class="my-work-header">
|
<div class="my-work-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>My Work</h2>
|
<h2>My Work</h2>
|
||||||
|
|
@ -711,6 +718,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||||
|
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work</button>
|
||||||
|
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||||
|
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||||
|
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||||
|
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||||
|
|
||||||
<script src="static/markdown.js"></script>
|
<script src="static/markdown.js"></script>
|
||||||
|
|
@ -730,6 +745,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<script src="static/review-sheet.js"></script>
|
<script src="static/review-sheet.js"></script>
|
||||||
<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>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const qs = (s, el=document) => el.querySelector(s);
|
const qs = (s, el=document) => el.querySelector(s);
|
||||||
|
|
@ -755,6 +771,36 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const mobileTaskButtons = Object.fromEntries(
|
||||||
|
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
||||||
|
);
|
||||||
|
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||||
|
const mobileTaskDock = createMobileTaskDock({
|
||||||
|
nav: qs('#mobile-task-dock'),
|
||||||
|
buttons: mobileTaskButtons,
|
||||||
|
overlays: mobileTaskOverlays,
|
||||||
|
actions: {
|
||||||
|
work: () => qs('#my-work').scrollIntoView({block:'start'}) || qs('#my-work').focus(),
|
||||||
|
find: () => qs('#find-work').click(),
|
||||||
|
new: () => qs('#new-issue').click(),
|
||||||
|
search: () => qs('#open-palette').click(),
|
||||||
|
drafts: () => qs('[data-work-filter="draft"]').click(),
|
||||||
|
},
|
||||||
|
observe(callback, overlays) {
|
||||||
|
const observer = new MutationObserver(callback);
|
||||||
|
overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']}));
|
||||||
|
return observer;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mobileTaskDock.start();
|
||||||
|
const sourceDraftCount = qs('[data-work-count="draft"]');
|
||||||
|
const draftCount = qs('#mobile-draft-count');
|
||||||
|
function syncMobileDraftCount() {
|
||||||
|
draftCount.textContent = sourceDraftCount.textContent;
|
||||||
|
draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts');
|
||||||
|
}
|
||||||
|
new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true});
|
||||||
|
syncMobileDraftCount();
|
||||||
let liveMode = true;
|
let liveMode = true;
|
||||||
let offlineWorkMode = false;
|
let offlineWorkMode = false;
|
||||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||||
|
|
|
||||||
38
frontend/mobile-task-dock.js
Normal file
38
frontend/mobile-task-dock.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
|
else root.createMobileTaskDock = factory;
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function createMobileTaskDock(options) {
|
||||||
|
const nav = options.nav;
|
||||||
|
const buttons = options.buttons;
|
||||||
|
const overlays = options.overlays || [];
|
||||||
|
let launcher = null;
|
||||||
|
let wasHidden = false;
|
||||||
|
|
||||||
|
function select(name) {
|
||||||
|
Object.entries(buttons).forEach(([key, button]) => {
|
||||||
|
if (key === name) button.setAttribute('aria-current', 'page');
|
||||||
|
else button.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshVisibility() {
|
||||||
|
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||||
|
nav.hidden = hidden;
|
||||||
|
if (wasHidden && !hidden && launcher) launcher.focus();
|
||||||
|
wasHidden = hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
Object.entries(buttons).forEach(([name, button]) => {
|
||||||
|
button.addEventListener('click', event => {
|
||||||
|
launcher = event.currentTarget;
|
||||||
|
select(name);
|
||||||
|
options.actions[name]();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
options.observe(refreshVisibility, overlays);
|
||||||
|
refreshVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {start, refreshVisibility};
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const CACHE = 'stackchain-dashboard-shell-v6';
|
const CACHE = 'stackchain-dashboard-shell-v7';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
@ -23,6 +23,7 @@ const SHELL = [
|
||||||
BASE + 'static/review-sheet.js',
|
BASE + 'static/review-sheet.js',
|
||||||
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',
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
|
|
||||||
93
tests/test_mobile_task_dock.py
Normal file
93
tests/test_mobile_task_dock.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.views import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||||
|
script = f"""
|
||||||
|
const createDock = require({json.dumps(str(DOCK))});
|
||||||
|
class FakeElement {{
|
||||||
|
constructor() {{
|
||||||
|
this.listeners = {{}};
|
||||||
|
this.attributes = {{}};
|
||||||
|
this.classList = {{ values:new Set(), contains:value => this.classList.values.has(value) }};
|
||||||
|
this.hidden = false;
|
||||||
|
this.focuses = 0;
|
||||||
|
}}
|
||||||
|
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||||
|
click() {{ this.listeners.click({{currentTarget:this}}); }}
|
||||||
|
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||||
|
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||||
|
focus() {{ this.focuses += 1; }}
|
||||||
|
}}
|
||||||
|
const nav = new FakeElement();
|
||||||
|
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||||
|
const overlay = new FakeElement();
|
||||||
|
const calls = [];
|
||||||
|
let observerCallback;
|
||||||
|
const dock = createDock({{
|
||||||
|
nav, buttons, overlays:[overlay],
|
||||||
|
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
|
||||||
|
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
|
||||||
|
}});
|
||||||
|
dock.start();
|
||||||
|
buttons.work.click();
|
||||||
|
buttons.find.click();
|
||||||
|
overlay.classList.values.add('open'); observerCallback();
|
||||||
|
const hiddenWhileOpen = nav.hidden;
|
||||||
|
overlay.classList.values.delete('open'); observerCallback();
|
||||||
|
buttons.drafts.click();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
|
||||||
|
findFocuses:buttons.find.focuses,
|
||||||
|
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"calls": ["work", "find", "drafts"],
|
||||||
|
"hiddenWhileOpen": True,
|
||||||
|
"hiddenAfterClose": False,
|
||||||
|
"findFocuses": 1,
|
||||||
|
"current": {
|
||||||
|
"work": None,
|
||||||
|
"find": None,
|
||||||
|
"new": None,
|
||||||
|
"search": None,
|
||||||
|
"drafts": "page",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||||
|
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||||
|
for task in ("work", "find", "new", "search", "drafts"):
|
||||||
|
assert f'data-mobile-task="{task}"' in html
|
||||||
|
assert 'id="mobile-draft-count"' in html
|
||||||
|
assert '.mobile-task-dock { display:none;' in html
|
||||||
|
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||||
|
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||||
|
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||||
|
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||||
|
assert "createMobileTaskDock({" in html
|
||||||
|
assert "work: () => qs('#my-work').scrollIntoView" in html
|
||||||
|
assert "find: () => qs('#find-work').click()" in html
|
||||||
|
assert "new: () => qs('#new-issue').click()" in html
|
||||||
|
assert "search: () => qs('#open-palette').click()" in html
|
||||||
|
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||||
|
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||||
|
|
@ -95,6 +95,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/review-sheet.js",
|
"/dashboard/static/review-sheet.js",
|
||||||
"/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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user