feat: compact active Today controls on mobile (Closes #1074)
This commit is contained in:
1 parent
fb7bfe0a5d
commit
b77be4f5b0
11 files changed
+308
-31
No files matched your search
@@ -15,6 +15,13 @@ from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def open_today_action(page, selector: str):
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
page.locator(selector).click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_hidden()
|
||||
|
||||
|
||||
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
@@ -87,11 +94,14 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
blocked = page.locator("[data-mobile-today-blocked]")
|
||||
expect(blocked).to_be_visible()
|
||||
blocked_bounds = blocked.bounding_box()
|
||||
assert blocked_bounds and blocked_bounds["height"] >= 44
|
||||
blocked.click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_hidden()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
expect(page.locator("#today-progress-title")).to_have_text("Report blocker")
|
||||
expect(page.locator("#today-progress-target")).to_contain_text("Ship mobile capture")
|
||||
@@ -116,7 +126,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
"created_at": "2026-08-18T00:00:00Z",
|
||||
}]
|
||||
}
|
||||
page.locator("[data-mobile-today-update]").click()
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
progress = page.locator("#today-progress-body")
|
||||
progress.fill("Pairing with @al")
|
||||
@@ -143,7 +153,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
assert admitted and admitted["repository"] == "acme/mobile"
|
||||
assert admitted["body"] == "Pairing with @alex"
|
||||
|
||||
page.locator("[data-mobile-today-update]").click()
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
owned = page.locator('[data-comment-id="701"]')
|
||||
try:
|
||||
@@ -178,9 +188,12 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#cancel-today-progress").click()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
take_break = page.locator("[data-today-break-open]")
|
||||
expect(take_break).to_be_visible()
|
||||
take_break.click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_hidden()
|
||||
expect(page.locator("#today-break-sheet")).to_be_visible()
|
||||
for control in page.locator(".today-break-actions button").all():
|
||||
bounds = control.bounding_box()
|
||||
@@ -188,7 +201,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
page.go_back()
|
||||
expect(page.locator("#today-break-sheet")).to_be_hidden()
|
||||
|
||||
take_break.click()
|
||||
open_today_action(page, "[data-today-break-open]")
|
||||
page.locator('[data-today-break-minutes="5"]').click()
|
||||
expect(page.locator("#today-break-status")).to_have_text("On break · resume in 5:00")
|
||||
expect(page.locator("[data-mobile-today-toggle]")).to_have_text("Resume timer")
|
||||
@@ -219,7 +232,18 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.set_viewport_size({"width": 320, "height": 568})
|
||||
page.locator("[data-today-break-open]").click()
|
||||
layout = page.evaluate("""() => {
|
||||
const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect();
|
||||
const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect();
|
||||
const padding = parseFloat(getComputedStyle(document.body).paddingBottom);
|
||||
return {hudHeight:hud.height, hudTop:hud.top, dockTop:dock.top, padding, viewport:window.innerHeight};
|
||||
}""")
|
||||
assert layout["hudHeight"] <= 116
|
||||
assert layout["padding"] >= layout["viewport"] - min(layout["hudTop"], layout["dockTop"])
|
||||
for control in page.locator("[data-mobile-today-hud] button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
open_today_action(page, "[data-today-break-open]")
|
||||
expect(page.locator("#today-break-sheet")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
custom = page.locator("#today-break-custom-minutes")
|
||||
@@ -313,12 +337,12 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
|
||||
page.locator("[data-mobile-today-update]").click()
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
page.locator("#today-progress-body").fill("Blocked waiting for the design owner")
|
||||
page.locator("#post-today-progress").click()
|
||||
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||
page.locator("[data-today-break-open]").click()
|
||||
open_today_action(page, "[data-today-break-open]")
|
||||
page.locator('[data-today-break-minutes="5"]').click()
|
||||
expect(page.locator("#resume-today-break")).to_be_visible()
|
||||
|
||||
@@ -345,7 +369,7 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
|
||||
page.locator("#resume-today-break").click()
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
page.locator("#close-issue-sheet").click()
|
||||
page.locator("[data-mobile-today-update]").click()
|
||||
open_today_action(page, "[data-mobile-today-update]")
|
||||
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||
expect(page.locator("#today-progress-blocker-recovery")).to_be_visible()
|
||||
expect(page.locator("#today-progress-blocker-recovery")).to_contain_text("Blocker queued")
|
||||
|
||||
@@ -855,10 +855,13 @@ async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
|
||||
assert "completeTodayItem(item)" in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert '.mobile-today-hud { display:none;' in html
|
||||
assert 'grid-template-areas:' in html
|
||||
assert '[data-mobile-today-complete] { grid-area:complete;' in html
|
||||
assert 'data-mobile-today-more' in html
|
||||
assert 'class="mobile-today-actions"' in html
|
||||
assert 'grid-template-columns:repeat(6,minmax(0,1fr));' in html
|
||||
assert '[data-mobile-today-complete] { grid-column:1 / 3;' in html
|
||||
assert 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-today-hud button { min-height:44px;' in html
|
||||
assert '.mobile-today-hud button { min-width:0; min-height:44px;' in html
|
||||
assert 'var(--mobile-today-clearance, 166px)' in html
|
||||
assert '.mobile-task-action[hidden] { display:none;' in html
|
||||
assert 'max-width:100%;' in html
|
||||
assert 'overflow:hidden;' in html
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE = Path(__file__).parents[1] / "frontend" / "mobile-today-command-bar.js"
|
||||
FRONTEND = SOURCE.parent
|
||||
|
||||
|
||||
def run_node(body: str) -> dict:
|
||||
script = f"const createCommandBar = require({json.dumps(str(SOURCE))});\n" + body
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_more_sheet_opens_modally_and_restores_focus_after_close():
|
||||
result = run_node(r"""
|
||||
function element() {
|
||||
const listeners = {};
|
||||
return {
|
||||
listeners, open:false, hidden:false, attributes:{}, focusCount:0,
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
dispatch(type, event={}) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this, ...event})); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
showModal() { this.open = true; },
|
||||
close() { this.open = false; this.dispatch('close'); },
|
||||
focus() { this.focusCount += 1; },
|
||||
};
|
||||
}
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
const close = element();
|
||||
const firstAction = element();
|
||||
createCommandBar({more, sheet, close, firstAction});
|
||||
more.dispatch('click');
|
||||
const opened = {open:sheet.open, expanded:more.attributes['aria-expanded'], firstFocused:firstAction.focusCount};
|
||||
close.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({opened, closed:!sheet.open, restored:more.focusCount}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"opened": {"open": True, "expanded": "true", "firstFocused": 1},
|
||||
"closed": True,
|
||||
"restored": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_end_session_action_reuses_existing_session_control_and_closes_sheet():
|
||||
result = run_node(r"""
|
||||
function element() {
|
||||
const listeners = {};
|
||||
return {
|
||||
open:false, clicks:0, attributes:{},
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
dispatch(type, event={}) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this, ...event})); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
showModal() { this.open = true; },
|
||||
close() { this.open = false; this.dispatch('close'); },
|
||||
click() { this.clicks += 1; this.dispatch('click'); },
|
||||
focus() {},
|
||||
};
|
||||
}
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
const endAction = element();
|
||||
const existingEndControl = element();
|
||||
createCommandBar({more, sheet, endAction, existingEndControl});
|
||||
more.dispatch('click');
|
||||
endAction.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({open:sheet.open, endClicks:existingEndControl.clicks}));
|
||||
""")
|
||||
|
||||
assert result == {"open": False, "endClicks": 1}
|
||||
|
||||
|
||||
def test_secondary_action_closes_command_sheet_before_existing_handler_runs():
|
||||
result = run_node(r"""
|
||||
function element() {
|
||||
const listeners = {};
|
||||
return {
|
||||
open:false, attributes:{}, observedOpen:null,
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
dispatch(type) { (listeners[type] || []).forEach(listener => listener({preventDefault(){}, target:this})); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
showModal() { this.open = true; }, close() { this.open = false; this.dispatch('close'); }, focus() {},
|
||||
};
|
||||
}
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
const update = element();
|
||||
createCommandBar({more, sheet, actionButtons:[update]});
|
||||
update.addEventListener('click', () => { update.observedOpen = sheet.open; });
|
||||
more.dispatch('click');
|
||||
update.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({open:sheet.open, handlerSawOpen:update.observedOpen}));
|
||||
""")
|
||||
|
||||
assert result == {"open": False, "handlerSawOpen": False}
|
||||
|
||||
|
||||
def test_rendered_hud_and_dock_height_define_scroll_clearance():
|
||||
result = run_node(r"""
|
||||
const listeners = {};
|
||||
const element = () => ({
|
||||
open:false, attributes:{},
|
||||
addEventListener:(type, listener) => { (listeners[type] ||= []).push(listener); },
|
||||
setAttribute(name, value) { this.attributes[name] = value; },
|
||||
focus() {}, showModal() { this.open = true; }, close() { this.open = false; },
|
||||
});
|
||||
const values = {};
|
||||
let resize;
|
||||
const ResizeObserverImpl = class {
|
||||
constructor(callback) { resize = callback; }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
const more = element();
|
||||
const sheet = element();
|
||||
createCommandBar({
|
||||
more, sheet,
|
||||
hud:{offsetHeight:96}, dock:{offsetHeight:56},
|
||||
style:{setProperty:(name, value) => { values[name] = value; }},
|
||||
ResizeObserverImpl,
|
||||
});
|
||||
resize();
|
||||
process.stdout.write(JSON.stringify(values));
|
||||
""")
|
||||
|
||||
assert result == {"--mobile-today-clearance": "168px"}
|
||||
|
||||
|
||||
def test_mobile_command_bar_keeps_primary_actions_visible_and_secondary_actions_modal():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
hud = html[html.index('<section class="mobile-today-hud"'):html.index('</section>', html.index('<section class="mobile-today-hud"'))]
|
||||
sheet = html[html.index('<dialog class="mobile-today-actions"'):html.index('</dialog>', html.index('<dialog class="mobile-today-actions"'))]
|
||||
|
||||
assert "data-mobile-today-open" in hud
|
||||
assert "data-mobile-today-complete" in hud
|
||||
assert "data-mobile-today-toggle" in hud
|
||||
assert "data-mobile-today-more" in hud
|
||||
for secondary in (
|
||||
"data-work-session-previous",
|
||||
"data-work-session-next",
|
||||
"data-mobile-today-update",
|
||||
"data-mobile-today-blocked",
|
||||
"data-today-break-open",
|
||||
"data-work-session-adjust-plan",
|
||||
"data-mobile-today-end",
|
||||
):
|
||||
assert secondary not in hud
|
||||
assert secondary in sheet
|
||||
assert 'aria-modal="true"' in sheet
|
||||
assert "body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance" in css
|
||||
assert "createMobileTodayCommandBar({" in dashboard
|
||||
assert '<script src="static/mobile-today-command-bar.js"></script>' in html
|
||||
@@ -4771,8 +4771,9 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||
assert 'id="start-work-session"' in html
|
||||
assert html.count('class="work-session-nav"') == 4
|
||||
assert html.count('<span class="small" aria-live="polite" data-work-session-progress>') == 4
|
||||
assert html.count('<button type="button" data-work-session-previous>') == 4
|
||||
assert html.count('<button type="button" data-work-session-next>') == 4
|
||||
assert html.count('<button type="button" data-work-session-previous>') == 5
|
||||
assert html.count('<button type="button" data-work-session-next>') == 5
|
||||
assert 'class="mobile-today-actions"' in html
|
||||
assert '.work-session-nav button { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||
assert 'aria-live="polite" data-work-session-progress' in html
|
||||
|
||||
@@ -1208,6 +1208,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/live-data-status.js",
|
||||
"/dashboard/static/mobile-today-command-bar.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
|
||||
Reference in new issue
Block a user