From b77be4f5b0d88515c51e63a31d5377cd0730e827 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 18 Aug 2026 09:36:34 +0000 Subject: [PATCH] feat: compact active Today controls on mobile (Closes #1074) --- frontend/dashboard.css | 30 ++-- frontend/dashboard.js | 15 ++ frontend/index.html | 24 ++- frontend/mobile-today-command-bar.js | 56 +++++++ frontend/service-worker.js | 1 + src/frontend_bundle.py | 2 +- .../e2e/test_mobile_today_handoff_release.py | 38 ++++- tests/test_mobile_task_dock.py | 9 +- tests/test_mobile_today_command_bar.py | 158 ++++++++++++++++++ tests/test_my_work.py | 5 +- tests/test_service_worker.py | 1 + 11 files changed, 308 insertions(+), 31 deletions(-) create mode 100644 frontend/mobile-today-command-bar.js create mode 100644 tests/test_mobile_today_command_bar.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e147e83..536f7ed 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -981,6 +981,14 @@ textarea { resize: vertical; min-height: 120px; } .mobile-today-hud { display:none; } .mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; } +.mobile-today-actions { box-sizing:border-box; width:min(520px,100%); max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; } +.mobile-today-actions::backdrop { background:rgba(3,9,18,.76); } +.mobile-today-actions-panel { box-sizing:border-box; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); } +.mobile-today-actions-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.mobile-today-actions-panel h2 { margin:0; } +.mobile-today-navigation, .mobile-today-secondary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } +.mobile-today-actions-panel button { min-width:0; min-height:44px; } +.mobile-today-secondary-actions [data-mobile-today-end] { grid-column:1 / -1; border-color:#b45309; } .today-session-handoff { position:fixed; left:12px; right:12px; bottom:calc(68px + env(safe-area-inset-bottom)); z-index:45; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:12px; border:1px solid #60a5fa; border-radius:12px; background:rgba(16,38,65,.98); box-shadow:0 8px 28px rgba(0,0,0,.35); } .today-session-handoff[hidden] { display:none; } .today-session-handoff button { min-height:44px; } @@ -994,7 +1002,7 @@ textarea { resize: vertical; min-height: 120px; } .attention-interruption button { min-height:44px; flex:0 0 auto; } @media (max-width: 600px) { body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); } - body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); } + body.mobile-today-active { padding-bottom:calc(var(--mobile-today-clearance, 166px) + env(safe-area-inset-bottom)); } header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); } .app-brand .muted, #clock { display:none; } .app-live-status { margin-left:auto; } @@ -1119,18 +1127,14 @@ textarea { resize: vertical; min-height: 120px; } .mobile-update-outcome { margin-top:12px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; } .mobile-update-outcome p { margin:0 0 10px; } .mobile-update-outcome button { width:100%; min-height:44px; } - .mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); } - .mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; } - .mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .mobile-today-hud [data-mobile-today-complete] { grid-area:complete; } - .mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; } - .mobile-today-hud button { min-height:44px; max-width:100%; } - .mobile-today-hud [data-mobile-today-update] { grid-column:1; width:100%; } - .mobile-today-hud [data-mobile-today-blocked] { grid-column:2; width:100%; } - .mobile-today-hud [data-today-break-open] { grid-column:1 / -1; } - - .mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; } - .today-completion-undo { bottom:calc(272px + env(safe-area-inset-bottom)); } + .mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:4px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); } + .mobile-today-summary { grid-column:1 / 4; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; } + .mobile-today-hud [data-work-session-progress] { grid-column:4 / 7; align-self:center; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .mobile-today-hud [data-mobile-today-complete] { grid-column:1 / 3; } + .mobile-today-hud [data-mobile-today-toggle] { grid-column:3 / 5; } + .mobile-today-hud [data-mobile-today-more] { grid-column:5 / 7; } + .mobile-today-hud button { min-width:0; min-height:44px; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-inline:6px; } + .today-completion-undo { bottom:calc(var(--mobile-today-clearance, 166px) + 8px + env(safe-area-inset-bottom)); } } @media (min-width:701px) { .update-gesture-status { display:none; } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8dffcfb..c521ca2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -235,6 +235,21 @@ }, }); mobileTaskDock.start(); + const mobileTodayActions = qs('#mobile-today-actions'); + createMobileTodayCommandBar({ + more:qs('[data-mobile-today-more]'), + sheet:mobileTodayActions, + close:qs('[data-mobile-today-actions-close]'), + firstAction:qs('[data-work-session-previous]', mobileTodayActions), + actionButtons:Array.from(mobileTodayActions.querySelectorAll( + '[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]' + )), + endAction:qs('[data-mobile-today-end]', mobileTodayActions), + existingEndControl:qs('#end-today-session'), + hud:qs('[data-mobile-today-hud]'), + dock:qs('#mobile-task-dock'), + style:document.documentElement.style, + }); const mobileInsights = createMobileInsights({ root: qs('#insights-sheet'), launcher: qs('#open-insights'), diff --git a/frontend/index.html b/frontend/index.html index 875c249..6e4c17e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1533,14 +1533,27 @@ + +
+

Today session

+ +
+ + + + + +
+
+

Active Today item

Add progress update

@@ -1785,6 +1798,7 @@ + diff --git a/frontend/mobile-today-command-bar.js b/frontend/mobile-today-command-bar.js new file mode 100644 index 0000000..38abb7c --- /dev/null +++ b/frontend/mobile-today-command-bar.js @@ -0,0 +1,56 @@ +(function (root, factory) { + if (typeof module !== 'undefined' && module.exports) module.exports = factory; + else root.createMobileTodayCommandBar = factory; +})(typeof window !== 'undefined' ? window : this, function createMobileTodayCommandBar({ + more, + sheet, + close, + firstAction, + endAction, + existingEndControl, + actionButtons = [], + hud, + dock, + style, + ResizeObserverImpl = typeof ResizeObserver === 'undefined' ? null : ResizeObserver, +}) { + if (!more || !sheet) return null; + + const dismiss = () => { + if (sheet.open) sheet.close(); + }; + const open = () => { + if (sheet.open) return; + more.setAttribute('aria-expanded', 'true'); + sheet.showModal(); + firstAction?.focus(); + }; + + more.setAttribute('aria-expanded', 'false'); + more.addEventListener('click', open); + close?.addEventListener('click', dismiss); + actionButtons.forEach(button => button?.addEventListener('click', dismiss)); + endAction?.addEventListener('click', () => { + dismiss(); + existingEndControl?.click(); + }); + sheet.addEventListener('cancel', event => { + event.preventDefault(); + dismiss(); + }); + sheet.addEventListener('close', () => { + more.setAttribute('aria-expanded', 'false'); + more.focus(); + }); + + const measure = () => { + if (!style || !hud || !dock) return; + style.setProperty('--mobile-today-clearance', `${hud.offsetHeight + dock.offsetHeight + 16}px`); + }; + const observer = ResizeObserverImpl && hud && dock ? new ResizeObserverImpl(measure) : null; + observer?.observe(hud); + observer?.observe(dock); + measure(); + + return { open, close:dismiss, measure, destroy:() => observer?.disconnect() }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index eb6a4eb..0c5e2dc 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -107,6 +107,7 @@ const SHELL = [ BASE + 'static/task-overlay-history.js', BASE + 'static/context-poller.js', BASE + 'static/live-data-status.js', + BASE + 'static/mobile-today-command-bar.js', BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-queue-launcher.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 7b1c1d5..8df0a97 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -34,7 +34,7 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", - "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", + "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", "static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index 9f8f512..f588cbe 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -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") diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index 49a2a20..a35123e 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -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 diff --git a/tests/test_mobile_today_command_bar.py b/tests/test_mobile_today_command_bar.py new file mode 100644 index 0000000..1f56b3f --- /dev/null +++ b/tests/test_mobile_today_command_bar.py @@ -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('
', html.index('
', html.index('' in html diff --git a/tests/test_my_work.py b/tests/test_my_work.py index f6df2ae..5541ce7 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -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('') == 4 - assert html.count('