diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 9785be9..d80e4b2 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -280,6 +280,15 @@ textarea { resize: vertical; min-height: 120px; } .event { padding: 8px 0; border-bottom: 1px solid #1b2d45; } .event:last-child { border-bottom: 0; } .my-work { grid-column: 1 / -1; } +.progressive-work-detail { position:fixed; inset:0; z-index:120; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } +.progressive-work-detail[hidden] { display:none; } +.progressive-work-detail-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:calc(18px + env(safe-area-inset-top)) 18px calc(18px + env(safe-area-inset-bottom)); border:1px solid #55d6be; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; } +.progressive-work-detail-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.progressive-work-detail-panel h2, .progressive-work-detail-panel p { margin-top:0; } +.progressive-work-detail-panel button, .progressive-work-detail-panel .button-link { box-sizing:border-box; min-height:44px; } +.progressive-work-detail-panel .button-link { display:flex; align-items:center; justify-content:center; width:100%; } +@media(max-width:320px) { .progressive-work-detail-panel { width:100%; padding-inline:14px; } } + .plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); } .plan-today-sheet[hidden] { display:none; } .plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index ecc47dd..5e10907 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8453,6 +8453,16 @@ adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise); } if (!adoptedProgressiveSnapshot) await load(); + if (progressiveWorkHandoff?.openWork) { + const progressiveItem = lastMyWork.find(item => + item.repository === progressiveWorkHandoff.openWork.repository && + Number(item.number) === Number(progressiveWorkHandoff.openWork.number)); + if (progressiveItem) { + openRoutedWork(progressiveItem, null); + qs('#progressive-work-detail').hidden = true; + } + } + await appShortcut.run(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); diff --git a/frontend/index.html b/frontend/index.html index d015189..1ad952b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -375,6 +375,19 @@ + +

Exact merge evidence

Release watchlist

diff --git a/frontend/progressive-my-work.js b/frontend/progressive-my-work.js index e286227..ef376fc 100644 --- a/frontend/progressive-my-work.js +++ b/frontend/progressive-my-work.js @@ -3,6 +3,12 @@ function createProgressiveMyWork({ }) { const list = document.querySelector('#my-work-list'); const status = document.querySelector('#my-work-status'); + const detail = document.querySelector('#progressive-work-detail'); + const detailTitle = document.querySelector('#progressive-work-detail-title'); + const detailMeta = document.querySelector('#progressive-work-detail-meta'); + const detailReason = document.querySelector('#progressive-work-detail-reason'); + const closeDetail = document.querySelector('#close-progressive-work-detail'); + const openGitea = document.querySelector('#open-progressive-work-gitea'); const filters = Array.from(document.querySelectorAll('[data-work-filter]')); const listeners = []; const lifecycleListeners = []; @@ -14,6 +20,8 @@ function createProgressiveMyWork({ let liveSnapshotPromise = null; let confirmedLogin = ''; let poller = null; + let detailTrigger = null; + let openWork = null; const deferredQueues = { today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts', }; @@ -35,6 +43,28 @@ function createProgressiveMyWork({ filter === 'authored' ? item.is_authored : filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter; const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active)); + const closeProgressiveDetail = ({ restoreFocus = true } = {}) => { + if (!detail || detail.hidden) return; + detail.hidden = true; + if (restoreFocus) detailTrigger?.focus?.(); + detailTrigger = null; + openWork = null; + }; + const openProgressiveDetail = (item, trigger) => { + if (!detail || !item) return; + detailTrigger = trigger; + openWork = item; + detailTitle.textContent = item.title || item.key || 'Untitled work'; + detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' + + (item.kind === 'pull' ? 'Pull request' : 'Issue'); + detailReason.textContent = item.reason || 'Assigned to you'; + const href = safeUrl(item.url); + openGitea.hidden = !href; + if (href) openGitea.href = href; + else openGitea.removeAttribute?.('href'); + detail.hidden = false; + closeDetail?.focus?.(); + }; const updateCounts = () => { ['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => { const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length; @@ -46,15 +76,14 @@ function createProgressiveMyWork({ if (stopped || !list) return; const visible = visibleItems(); list.innerHTML = deferredQueues[active] ? - '
' + deferredQueues[active] + ' is still loading…
' : visible.length ? visible.map(item => { - const href = safeUrl(item.url); + '
' + deferredQueues[active] + ' is still loading…
' : visible.length ? visible.map((item, index) => { const title = escapeHtml(item.title || item.key || 'Untitled work'); const context = escapeHtml(item.key || ''); const reason = escapeHtml(item.reason || 'Assigned to you'); return ''; + ''; }).join('') : '
No work in this queue.
'; filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active))); }; @@ -63,6 +92,23 @@ function createProgressiveMyWork({ button.addEventListener('click', listener); listeners.push([button, listener]); }); + const openListener = event => { + const trigger = event.target?.closest?.('[data-progressive-work-index]'); + if (!trigger) return; + const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)]; + if (!item) return; + event.preventDefault?.(); + openProgressiveDetail(item, trigger); + }; + const closeListener = () => closeProgressiveDetail(); + const keyListener = event => { + if (event.key !== 'Escape' || detail?.hidden) return; + event.preventDefault?.(); + closeProgressiveDetail(); + }; + list?.addEventListener?.('click', openListener); + closeDetail?.addEventListener?.('click', closeListener); + lifecycleTarget.addEventListener?.('keydown', keyListener); const applySnapshot = snapshot => { if (stopped) return false; @@ -102,6 +148,13 @@ function createProgressiveMyWork({ login() { return confirmedLogin; }, handoff() { const state = { selectedFilter:selectedByUser ? active : null }; + if (openWork) { + state.openWork = { + kind:openWork.kind, key:openWork.key, number:openWork.number, + repository:openWork.repository, + }; + openWork = null; + } if (liveSnapshot) { state.liveSnapshot = liveSnapshot; liveSnapshot = null; @@ -142,6 +195,9 @@ function createProgressiveMyWork({ stopped = true; poller?.stop(); listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener)); + list?.removeEventListener?.('click', openListener); + closeDetail?.removeEventListener?.('click', closeListener); + lifecycleTarget.removeEventListener?.('keydown', keyListener); lifecycleListeners.forEach(([eventName, listener]) => lifecycleTarget.removeEventListener?.(eventName, listener)); }, diff --git a/tests/test_progressive_my_work.py b/tests/test_progressive_my_work.py index e7f9245..1143dd2 100644 --- a/tests/test_progressive_my_work.py +++ b/tests/test_progressive_my_work.py @@ -6,6 +6,8 @@ from pathlib import Path MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js" MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" +INDEX = Path(__file__).parents[1] / "frontend" / "index.html" +CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace(): @@ -35,7 +37,8 @@ const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{ state = json.loads(result.stdout) assert state["status"] == "1 assigned work item ready." assert "Fix mobile queue" in state["rendered"] - assert 'href="https://forge.example/issues/7"' in state["rendered"] + assert 'data-progressive-work-index="0"' in state["rendered"] + assert "href=" not in state["rendered"] assert state["pressed"] == ["false", "true"] @@ -292,7 +295,7 @@ const flow=context.module.exports({{ "reconnecting": "Assigned work is reconnecting…", "attempts": 2, "status": "1 assigned work item ready.", - "html": '', + "html": '
', } @@ -363,5 +366,92 @@ const flow=context.module.exports({{ "hasSignal": True, } assert state["latestTitle"] == "New assignment" - assert state["removed"] == ["online", "visibilitychange"] + assert state["removed"] == ["keydown", "online", "visibilitychange"] assert state["status"] == "1 assigned work item ready." + + +def test_progressive_card_opens_safe_in_app_detail_and_hands_it_off_once(): + harness = f""" +const fs=require('fs'); const vm=require('vm'); +function element(extra={{}}) {{ + return Object.assign({{ + hidden:false,textContent:'',attrs:{{}},listeners:{{}},focusCount:0, + addEventListener(name,cb){{this.listeners[name]=cb;}}, + removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}}, + setAttribute(name,value){{this.attrs[name]=String(value);}}, + removeAttribute(name){{delete this.attrs[name];}}, + focus(){{this.focusCount += 1;}}, + }},extra); +}} +const list=element({{innerHTML:''}}); const status=element(); +const sheet=element({{hidden:true}}); const title=element(); const meta=element(); const reason=element(); +const close=element(); const gitea=element({{hidden:true,href:''}}); +const elements={{ + '#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':sheet, + '#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta, + '#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close, + '#open-progressive-work-gitea':gitea, +}}; +const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>[]}}; +const lifecycleTarget=element(); +const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context); +context.buildMyWork=context.module.exports; context.module={{exports:{{}}}}; +vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context); +const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{ + user:{{login:'timmy'}},issues:[{{number:7,title:'',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[] +}})}}); +(async()=>{{ + await flow.start(); + const trigger=element({{dataset:{{progressiveWorkIndex:'0'}},closest:()=>trigger}}); + list.listeners.click({{target:trigger,preventDefault(){{}}}}); + const opened={{sheetHidden:sheet.hidden,title:title.textContent,meta:meta.textContent,reason:reason.textContent,giteaHidden:gitea.hidden,giteaHref:gitea.href,html:list.innerHTML}}; + lifecycleTarget.listeners.keydown({{key:'Escape',preventDefault(){{}}}}); + list.listeners.click({{target:trigger,preventDefault(){{}}}}); + const first=flow.handoff(); const second=flow.handoff(); + console.log(JSON.stringify({{opened,closed:sheet.hidden,focusCount:trigger.focusCount,first,second}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True) + state = json.loads(result.stdout) + + assert state["opened"] == { + "sheetHidden": False, + "title": "", + "meta": "stackchain/dashboard#7 · Issue", + "reason": "Assigned to you", + "giteaHidden": False, + "giteaHref": "https://forge.example/issues/7", + "html": '
', + } + assert state["closed"] is False + assert state["focusCount"] == 1 + assert state["first"]["openWork"] == { + "kind": "issue", + "key": "stackchain/dashboard#7", + "number": 7, + "repository": "stackchain/dashboard", + } + assert "openWork" not in state["second"] + + +def test_progressive_detail_is_mobile_safe_and_adopted_by_hydrated_workspace(): + html = INDEX.read_text() + css = CSS.read_text() + dashboard = DASHBOARD.read_text() + + assert 'id="progressive-work-detail" role="dialog" aria-modal="true"' in html + assert 'aria-labelledby="progressive-work-detail-title" hidden' in html + assert 'id="close-progressive-work-detail"' in html + assert 'id="open-progressive-work-gitea"' in html + assert ".progressive-work-detail[hidden] { display:none; }" in css + assert "max-height:100dvh" in css + assert "overflow-x:hidden" in css + assert "env(safe-area-inset-top)" in css + assert "env(safe-area-inset-bottom)" in css + assert ".progressive-work-detail-panel button, .progressive-work-detail-panel .button-link" in css + assert "min-height:44px" in css + assert "@media(max-width:320px)" in css + assert "progressiveWorkHandoff?.openWork" in dashboard + assert "openRoutedWork(progressiveItem, null)" in dashboard + assert "qs('#progressive-work-detail').hidden = true" in dashboard