feat: compact active Today controls on mobile (Closes #1074)
This commit is contained in:
parent
fb7bfe0a5d
commit
b77be4f5b0
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -1533,14 +1533,27 @@
|
|||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-mobile-today-more type="button" aria-controls="mobile-today-actions" aria-haspopup="dialog">More</button>
|
||||
</section>
|
||||
<dialog class="mobile-today-actions" id="mobile-today-actions" aria-modal="true" aria-labelledby="mobile-today-actions-title">
|
||||
<section class="mobile-today-actions-panel">
|
||||
<header><h2 id="mobile-today-actions-title">Today session</h2><button data-mobile-today-actions-close type="button">Close</button></header>
|
||||
<nav class="mobile-today-navigation" aria-label="Choose active Today item">
|
||||
<button type="button" data-work-session-previous>Previous item</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
<div class="mobile-today-secondary-actions">
|
||||
<button data-mobile-today-update type="button" hidden>Add update</button>
|
||||
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Blocked</button>
|
||||
<button class="mobile-today-blocked" data-mobile-today-blocked type="button" hidden>Report blocker</button>
|
||||
<button data-today-break-open type="button">Take break</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
</section>
|
||||
<button data-mobile-today-end type="button">End session</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
<dialog class="today-progress-sheet" id="today-progress-sheet" aria-labelledby="today-progress-title">
|
||||
<section class="today-progress-panel">
|
||||
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
|
||||
|
|
@ -1785,6 +1798,7 @@
|
|||
<script src="static/task-overlay-history.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/live-data-status.js"></script>
|
||||
<script src="static/mobile-today-command-bar.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
|
|
|
|||
56
frontend/mobile-today-command-bar.js
Normal file
56
frontend/mobile-today-command-bar.js
Normal file
|
|
@ -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() };
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
158
tests/test_mobile_today_command_bar.py
Normal file
158
tests/test_mobile_today_command_bar.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user