diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 41eacab..ab5031a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -469,26 +469,28 @@ textarea { resize: vertical; min-height: 120px; } .issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .issue-sheet-header button { min-height:44px; } -.mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; } +.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav { display:none; } @media (max-width:600px) { - .issue-sheet-panel, .pull-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); } - .mobile-issue-detail-nav, .mobile-pull-detail-nav { + .issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); } + .mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav { position:sticky; top:env(safe-area-inset-top); z-index:6; display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:4px; margin:8px -6px 12px; padding:6px; background:rgba(11,21,38,.98); border-block:1px solid #2a496e; } - .mobile-issue-detail-nav button, .mobile-pull-detail-nav button { + .mobile-issue-detail-nav button, .mobile-pull-detail-nav button, .mobile-update-detail-nav button { min-width:0; min-height:44px; padding:4px; overflow-wrap:anywhere; border-color:transparent; font-size:12px; } - .mobile-issue-detail-nav button[aria-current="location"], .mobile-pull-detail-nav button[aria-current="location"] { + .mobile-issue-detail-nav button[aria-current="location"], .mobile-pull-detail-nav button[aria-current="location"], .mobile-update-detail-nav button[aria-current="location"] { border-color:#60a5fa; background:#17365a; color:#fff; } #issue-overview, #issue-conversation, #issue-comment, #issue-planning { scroll-margin-top:72px; } #pull-overview, #pull-conversation, #pull-comment, #pull-review { scroll-margin-top:72px; } + #update-conversation, #update-subject-context, #update-reply-workspace { scroll-margin-top:72px; } } @media (min-width:601px) { .mobile-issue-detail-nav, .mobile-pull-detail-nav { display:none; } } +@media (min-width:601px) { .mobile-update-detail-nav { display:none; } } .completed-filed-actions { position:fixed; right:0; bottom:0; z-index:57; box-sizing:border-box; width:min(560px,100%); display:grid; grid-template-columns:minmax(0,1fr); align-items:center; gap:8px; margin:0; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:12px 0 0; background:rgba(11,21,38,.98); overflow-wrap:anywhere; } .completed-filed-actions[hidden] { display:none; } .completed-filed-actions button { min-height:44px; min-width:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index fc372c4..b50053b 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1353,6 +1353,19 @@ panel: qs('#update-sheet .update-sheet-panel'), jump: qs('#jump-update-new-activity'), }); + const mobileUpdateDetailNavigation = createMobileUpdateDetailNavigation({ + root:qs('#update-sheet .update-sheet-panel'), + buttons:Object.fromEntries(Array.from(document.querySelectorAll('[data-update-section]')).map(button => [button.dataset.updateSection, button])), + targets:{ + conversation:qs('#update-conversation'), + context:qs('#update-subject-context'), + reply:qs('#update-reply-workspace'), + }, + replyComposer:qs('#update-reply'), + jumpToNewActivity:() => updateReadPosition.jump(), + prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, + }); + mobileUpdateDetailNavigation.start(); const issueDetailPosition = createWorkDetailPosition({ panel: qs('#issue-sheet .issue-sheet-panel') }); const pullDetailPosition = createWorkDetailPosition({ panel: qs('#pull-sheet .pull-sheet-panel') }); const reviewDetailPosition = createWorkDetailPosition({ panel: qs('#review-sheet .review-sheet-panel') }); @@ -1391,6 +1404,7 @@ void updateVoiceReply.open('update:' + item.notification_id); selectedUpdateDetail = null; updateReadPosition.open(String(item.notification_id)); + mobileUpdateDetailNavigation.reset(); updateMentions.dismiss(); qs('#update-sheet').classList.add('open'); qs('.update-more-actions').open = false; diff --git a/frontend/index.html b/frontend/index.html index 3d3a878..9816f55 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1098,6 +1098,12 @@ +
Swipe right to keep unread or left to mark read. Buttons remain available.
Choose an update.
@@ -1105,18 +1111,20 @@ Update -

Full conversation

-
- - -
- - -
+
+

Full conversation

+
+ + +
+ + +
+

Subject context

-
+

Reply

@@ -1549,6 +1557,7 @@ + diff --git a/frontend/mobile-update-detail-nav.js b/frontend/mobile-update-detail-nav.js new file mode 100644 index 0000000..70af14d --- /dev/null +++ b/frontend/mobile-update-detail-nav.js @@ -0,0 +1,83 @@ +function createMobileUpdateDetailNavigation(options) { + const buttons = options.buttons || {}; + const targets = options.targets || {}; + const listeners = new Map(); + const prefersReducedMotion = options.prefersReducedMotion || (() => false); + const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name])); + let observer = null; + + function select(name) { + Object.entries(buttons).forEach(([key, button]) => { + if (!button) return; + if (key === name) button.setAttribute('aria-current', 'location'); + else button.removeAttribute('aria-current'); + }); + } + + function navigate(name) { + if (name === 'activity') { + if (options.jumpToNewActivity) options.jumpToNewActivity(); + select(name); + return true; + } + const target = targets[name]; + if (!target) return false; + if (name === 'context') target.open = true; + target.scrollIntoView({ + block:'start', + behavior:prefersReducedMotion() ? 'auto' : 'smooth', + }); + if (name === 'reply' && options.replyComposer) { + options.replyComposer.focus({ preventScroll:true }); + } + select(name); + return true; + } + + function reset() { + if (targets.context) targets.context.open = false; + select('activity'); + } + + return { + start() { + Object.entries(buttons).forEach(([name, button]) => { + if (!button || listeners.has(button)) return; + const listener = event => { + event.preventDefault(); + navigate(name); + }; + listeners.set(button, listener); + button.addEventListener('click', listener); + }); + reset(); + const observe = options.observe || ((handler, observedTargets) => { + if (typeof IntersectionObserver === 'undefined') return null; + const instance = new IntersectionObserver(handler, { + root:options.root || null, + rootMargin:'-20% 0px -60% 0px', + threshold:[0, 0.25, 0.5, 0.75, 1], + }); + observedTargets.forEach(target => instance.observe(target)); + return instance; + }); + observer = observe(entries => { + const visible = entries + .filter(entry => entry.isIntersecting && targetNames.has(entry.target)) + .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0]; + if (visible) select(targetNames.get(visible.target)); + }, Array.from(targetNames.keys()).filter(Boolean)); + }, + stop() { + listeners.forEach((listener, button) => button.removeEventListener('click', listener)); + listeners.clear(); + if (observer) observer.disconnect(); + observer = null; + }, + navigate, + reset, + select, + }; +} + +if (typeof module !== 'undefined') module.exports = createMobileUpdateDetailNavigation; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 9889541..4d39e7c 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -78,6 +78,7 @@ const SHELL = [ BASE + 'static/issue-filing-review.js', BASE + 'static/issue-sheet.js', BASE + 'static/mobile-issue-detail-nav.js', + BASE + 'static/mobile-update-detail-nav.js', BASE + 'static/checklist-conflict.js', BASE + 'static/voice-transcript-store.js', BASE + 'static/voice-issue-capture.js', diff --git a/tests/test_mobile_update_detail_navigation.py b/tests/test_mobile_update_detail_navigation.py new file mode 100644 index 0000000..3bab92d --- /dev/null +++ b/tests/test_mobile_update_detail_navigation.py @@ -0,0 +1,153 @@ +import json +import subprocess +from pathlib import Path + + +FRONTEND = Path(__file__).resolve().parents[1] / "frontend" +CONTROLLER = FRONTEND / "mobile-update-detail-nav.js" + + +def test_update_navigation_reuses_new_activity_opens_context_and_focuses_reply(): + script = f""" +const createNavigation = require({json.dumps(str(CONTROLLER))}); +class FakeElement {{ + constructor(name) {{ + this.name=name; this.listeners={{}}; this.attributes={{}}; this.open=false; + this.focuses=0; this.scrolls=[]; + }} + addEventListener(name, callback) {{ this.listeners[name]=callback; }} + removeEventListener(name) {{ delete this.listeners[name]; }} + setAttribute(name, value) {{ this.attributes[name]=value; }} + removeAttribute(name) {{ delete this.attributes[name]; }} + focus() {{ this.focuses += 1; }} + scrollIntoView(options) {{ this.scrolls.push({{...options, openWhenScrolled:this.open}}); }} +}} +const names=['activity','conversation','context','reply']; +const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)])); +const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,new FakeElement(name)])); +const replyComposer=new FakeElement('composer'); +let activityJumps=0; +const navigation=createNavigation({{ + buttons, targets, replyComposer, + jumpToNewActivity() {{ activityJumps += 1; }}, + prefersReducedMotion:() => true, +}}); +navigation.start(); +navigation.navigate('conversation'); +navigation.navigate('context'); +const contextOpenAfterNavigation=targets.context.open; +navigation.navigate('reply'); +navigation.navigate('activity'); +const currentAfterActivity=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])); +navigation.reset(); +const contextOpenAfterReset=targets.context.open; +const currentAfterReset=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])); +navigation.stop(); +process.stdout.write(JSON.stringify({{ + activityJumps, + conversation:targets.conversation.scrolls, + context:targets.context.scrolls, + contextOpen:contextOpenAfterNavigation, + contextOpenAfterReset, + reply:targets.reply.scrolls, + replyFocuses:replyComposer.focuses, + currentAfterActivity, + currentAfterReset, + listenersAfterStop:Object.values(buttons).map(button => Object.keys(button.listeners).length), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "activityJumps": 1, + "conversation": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}], + "context": [{"block": "start", "behavior": "auto", "openWhenScrolled": True}], + "contextOpen": True, + "contextOpenAfterReset": False, + "reply": [{"block": "start", "behavior": "auto", "openWhenScrolled": False}], + "replyFocuses": 1, + "currentAfterActivity": { + "activity": "location", + "conversation": None, + "context": None, + "reply": None, + }, + "currentAfterReset": { + "activity": "location", + "conversation": None, + "context": None, + "reply": None, + }, + "listenersAfterStop": [0, 0, 0, 0], + } + + +def test_update_navigation_tracks_visible_sections_and_disconnects_observer(): + script = f""" +const createNavigation = require({json.dumps(str(CONTROLLER))}); +const makeElement=name => ({{ + name, attributes:{{}}, + addEventListener() {{}}, removeEventListener() {{}}, + setAttribute(key,value) {{ this.attributes[key]=value; }}, + removeAttribute(key) {{ delete this.attributes[key]; }}, +}}); +const buttons=Object.fromEntries(['activity','conversation','context','reply'].map(name => [name,makeElement(name)])); +const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,makeElement(name)])); +let callback; +let disconnected=0; +const navigation=createNavigation({{ + buttons, targets, + observe(handler) {{ callback=handler; return {{disconnect() {{ disconnected += 1; }}}}; }}, +}}); +navigation.start(); +callback([ + {{target:targets.conversation,isIntersecting:true,intersectionRatio:.2}}, + {{target:targets.reply,isIntersecting:true,intersectionRatio:.8}}, +]); +const current=Object.fromEntries(Object.entries(buttons).map(([name,button]) => [name,button.attributes['aria-current'] || null])); +navigation.stop(); +process.stdout.write(JSON.stringify({{current,disconnected}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "current": { + "activity": None, + "conversation": None, + "context": None, + "reply": "location", + }, + "disconnected": 1, + } + + +def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail(): + html = (FRONTEND / "index.html").read_text() + css = (FRONTEND / "dashboard.css").read_text() + dashboard_js = (FRONTEND / "dashboard.js").read_text() + service_worker = (FRONTEND / "service-worker.js").read_text() + + assert '