feat: compact active Today controls on mobile (Closes #1074)
CI / lint (pull_request) Successful in 2m46s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m17s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-18 09:36:34 +00:00
1 parent fb7bfe0a5d
commit b77be4f5b0
11 files changed
+308 -31

No files matched your search

+31 -7
View File
@@ -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")
+6 -3
View File
@@ -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
+158
View File
@@ -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
+3 -2
View File
@@ -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
+1
View File
@@ -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",