Photo evidence Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each
@@ -1537,6 +1544,7 @@
+
diff --git a/frontend/mobile-create-issue-nav.js b/frontend/mobile-create-issue-nav.js
new file mode 100644
index 0000000..5d69516
--- /dev/null
+++ b/frontend/mobile-create-issue-nav.js
@@ -0,0 +1,115 @@
+function createMobileCreateIssueNavigation(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 filingAvailable = !options.filing?.hidden;
+ 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 setFilingAvailable(available) {
+ filingAvailable = Boolean(available);
+ const button = buttons.file;
+ if (!button) return;
+ if (filingAvailable) button.removeAttribute('aria-disabled');
+ else button.setAttribute('aria-disabled', 'true');
+ }
+
+ function navigate(name) {
+ if (name === 'file') setFilingAvailable(!options.filing?.hidden);
+ if (name === 'file' && !filingAvailable) return false;
+ const target = targets[name];
+ if (!target) return false;
+ target.scrollIntoView({
+ block: 'start',
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
+ });
+ select(name);
+ return true;
+ }
+
+ function reset(available = !options.filing?.hidden) {
+ setFilingAvailable(available);
+ select('describe');
+ }
+
+ 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, root) => {
+ if (typeof IntersectionObserver === 'undefined') return null;
+ const instance = new IntersectionObserver(handler, {
+ root,
+ 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))
+ .filter(entry => targetNames.get(entry.target) !== 'file' || filingAvailable)
+ .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
+ if (visible) select(targetNames.get(visible.target));
+ }, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
+ },
+ stop() {
+ listeners.forEach((listener, button) => button.removeEventListener('click', listener));
+ listeners.clear();
+ if (observer) observer.disconnect();
+ observer = null;
+ },
+ navigate,
+ reset,
+ select,
+ setFilingAvailable,
+ };
+}
+
+function attachMobileCreateIssueNavigation({ document, window }) {
+ const bySection = name => document.querySelector('[data-create-issue-section="' + name + '"]');
+ const navigation = createMobileCreateIssueNavigation({
+ root:document.querySelector('.create-issue-panel'),
+ buttons:{describe:bySection('describe'), evidence:bySection('evidence'), file:bySection('file')},
+ targets:{
+ describe:document.getElementById('create-issue-describe'),
+ evidence:document.getElementById('create-issue-evidence'),
+ file:document.getElementById('create-issue-filing'),
+ },
+ filing:document.getElementById('create-issue-filing'),
+ prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+ });
+ navigation.start();
+ document.getElementById('file-new-issue').addEventListener('click', () => {
+ navigation.navigate('file');
+ });
+ new MutationObserver(() => {
+ if (document.getElementById('create-issue-sheet').classList.contains('open')) {
+ navigation.reset(!document.getElementById('create-issue-filing').hidden);
+ }
+ }).observe(document.getElementById('create-issue-sheet'), {
+ attributes:true, attributeFilter:['class'],
+ });
+ return navigation;
+}
+
+if (typeof module !== 'undefined') module.exports = createMobileCreateIssueNavigation;
+else attachMobileCreateIssueNavigation({document, window});
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 59c56f8..e8e9636 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -85,6 +85,7 @@ const SHELL = [
BASE + 'static/voice-issue-capture.js',
BASE + 'static/voice-conversation-capture.js',
BASE + 'static/create-issue-sheet.js',
+ BASE + 'static/mobile-create-issue-nav.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
BASE + 'static/filed-claim.js',
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index a9ac6b8..e724cf7 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -23,7 +23,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"issue-capture": (
- "static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
+ "static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py
index 1af90bb..ae20ea2 100644
--- a/tests/e2e/test_mobile_offline_issue_release.py
+++ b/tests/e2e/test_mobile_offline_issue_release.py
@@ -188,6 +188,23 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
new_action.click()
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
+ create_navigation = page.locator(".mobile-create-issue-nav")
+ expect(create_navigation).to_be_visible()
+ create_navigation_buttons = create_navigation.locator("button")
+ assert create_navigation_buttons.count() == 3
+ for control in create_navigation_buttons.all():
+ bounds = control.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ expect(create_navigation.locator('[data-create-issue-section="describe"]')).to_have_attribute(
+ "aria-current", "location"
+ )
+ expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
+ "aria-disabled", "true"
+ )
+ create_navigation.locator('[data-create-issue-section="evidence"]').click()
+ expect(create_navigation.locator('[data-create-issue-section="evidence"]')).to_have_attribute(
+ "aria-current", "location"
+ )
for control in page.locator(".photo-evidence-actions .issue-attachment-trigger").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
@@ -206,6 +223,12 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
expect(page.locator("#create-issue-body")).to_have_value(BODY)
page.locator("#file-new-issue").click()
expect(page.locator("#create-issue-filing")).to_be_visible()
+ expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
+ "aria-current", "location"
+ )
+ expect(create_navigation.locator('[data-create-issue-section="file"]')).not_to_have_attribute(
+ "aria-disabled", "true"
+ )
page.locator("#create-issue-repository").select_option("acme/mobile")
expect(page.locator("#submit-new-issue")).to_be_enabled()
diff --git a/tests/test_mobile_create_issue_navigation.py b/tests/test_mobile_create_issue_navigation.py
new file mode 100644
index 0000000..71ed1cf
--- /dev/null
+++ b/tests/test_mobile_create_issue_navigation.py
@@ -0,0 +1,105 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-create-issue-nav.js"
+FRONTEND = CONTROLLER.parent
+
+
+def run_navigation(scenario: str) -> dict:
+ script = f"""
+const createNavigation = require({json.dumps(str(CONTROLLER))});
+class FakeElement {{
+ constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.scrolls=[]; this.hidden=false; }}
+ addEventListener(name, callback) {{ this.listeners[name]=callback; }}
+ removeEventListener(name) {{ delete this.listeners[name]; }}
+ click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
+ setAttribute(name,value) {{ this.attributes[name]=value; }}
+ removeAttribute(name) {{ delete this.attributes[name]; }}
+ scrollIntoView(options) {{ this.scrolls.push(options); }}
+}}
+{scenario}
+"""
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def test_mobile_create_issue_navigation_gates_file_and_navigates_with_reduced_motion():
+ result = run_navigation("""
+const buttons=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
+const targets=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
+targets.file.hidden=true;
+const navigation=createNavigation({buttons,targets,filing:targets.file,prefersReducedMotion:()=>true});
+navigation.start();
+const initiallyDisabled=buttons.file.attributes['aria-disabled'];
+buttons.file.click();
+const beforeReveal=targets.file.scrolls.length;
+targets.file.hidden=false;
+navigation.setFilingAvailable(true);
+buttons.file.click();
+const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
+navigation.stop();
+process.stdout.write(JSON.stringify({initiallyDisabled,beforeReveal,scrolls:targets.file.scrolls,selected,listeners:Object.values(buttons).map(button=>Object.keys(button.listeners).length)}));
+""")
+
+ assert result == {
+ "initiallyDisabled": "true",
+ "beforeReveal": 0,
+ "scrolls": [{"block": "start", "behavior": "auto"}],
+ "selected": {"describe": None, "evidence": None, "file": "location"},
+ "listeners": [0, 0, 0],
+ }
+
+
+def test_mobile_create_issue_navigation_tracks_visible_panel_sections_and_resets():
+ result = run_navigation("""
+const buttons=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
+const targets=Object.fromEntries(['describe','evidence','file'].map(name=>[name,new FakeElement(name)]));
+let callback; let observedRoot; let disconnected=0;
+const root=new FakeElement('panel');
+const navigation=createNavigation({buttons,targets,root,filing:targets.file,observe(handler,observed,rootOption){callback=handler; observedRoot=rootOption; return {disconnect(){disconnected++;}};}});
+navigation.start();
+callback([{target:targets.describe,isIntersecting:true,intersectionRatio:.2},{target:targets.evidence,isIntersecting:true,intersectionRatio:.8}]);
+navigation.reset(false);
+const selected=Object.fromEntries(Object.entries(buttons).map(([name,button])=>[name,button.attributes['aria-current']||null]));
+navigation.stop();
+process.stdout.write(JSON.stringify({selected,fileDisabled:buttons.file.attributes['aria-disabled'],observedRoot:observedRoot.name,disconnected}));
+""")
+
+ assert result == {
+ "selected": {"describe": "location", "evidence": None, "file": None},
+ "fileDisabled": "true",
+ "observedRoot": "panel",
+ "disconnected": 1,
+ }
+
+
+def test_new_issue_sheet_ships_mobile_navigation_in_lazy_offline_feature():
+ html = (FRONTEND / "index.html").read_text()
+ css = (FRONTEND / "dashboard.css").read_text()
+ dashboard_js = (FRONTEND / "dashboard.js").read_text()
+ navigation_js = CONTROLLER.read_text()
+ bundle = (FRONTEND.parent / "src" / "frontend_bundle.py").read_text()
+
+ assert '