Merge pull request 'Add a thumb-reachable mobile task dock' (#237)
Closes #236
This commit is contained in:
commit
d155f0081f
|
|
@ -70,6 +70,12 @@ HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
|||
|
||||
## 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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 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; }
|
||||
.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) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
header { align-items:flex-start; }
|
||||
.my-work { margin:0; }
|
||||
.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; }
|
||||
.create-issue-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; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
|
|
@ -284,7 +291,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</div>
|
||||
|
||||
<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>
|
||||
<h2>My Work</h2>
|
||||
|
|
@ -711,6 +718,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</section>
|
||||
</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>
|
||||
|
||||
<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/work-route.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
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 offlineWorkMode = false;
|
||||
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 BASE = new URL('./', self.location.href).pathname;
|
||||
const SHELL = [
|
||||
|
|
@ -23,6 +23,7 @@ const SHELL = [
|
|||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
];
|
||||
|
||||
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/work-route.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user