diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ccc9f9..bb52039 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: - run: python -m unittest discover -s tests -v working-directory: dashboard - name: open-access + markup contract tests - run: node --test tests/domains_markup.test.mjs tests/open_access.test.mjs tests/notes_markup.test.mjs tests/review_pr122_drawer.test.mjs tests/flux_markup.test.mjs tests/review_pr125.test.mjs tests/criteria_markup.test.mjs tests/review_pr127_drawer.test.mjs + run: node --test tests/domains_markup.test.mjs tests/open_access.test.mjs tests/notes_markup.test.mjs tests/review_pr122_drawer.test.mjs tests/flux_markup.test.mjs tests/review_pr125.test.mjs tests/criteria_markup.test.mjs tests/review_pr127_drawer.test.mjs tests/phone_layout.test.mjs working-directory: dashboard schema-check: diff --git a/Cargo.lock b/Cargo.lock index 6ccf80a..567b7a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2708,7 +2708,7 @@ dependencies = [ [[package]] name = "ptask-bot" -version = "3.51.3" +version = "3.51.4" dependencies = [ "anyhow", "jiff", @@ -2724,7 +2724,7 @@ dependencies = [ [[package]] name = "ptask-cli" -version = "3.51.3" +version = "3.51.4" dependencies = [ "anyhow", "axum", @@ -2752,7 +2752,7 @@ dependencies = [ [[package]] name = "ptask-core" -version = "3.51.3" +version = "3.51.4" dependencies = [ "hex", "interim", @@ -2773,7 +2773,7 @@ dependencies = [ [[package]] name = "ptask-distill" -version = "3.51.3" +version = "3.51.4" dependencies = [ "anyhow", "candle-core", @@ -2797,7 +2797,7 @@ dependencies = [ [[package]] name = "ptask-notify" -version = "3.51.3" +version = "3.51.4" dependencies = [ "lettre", "ptask-core", @@ -2810,7 +2810,7 @@ dependencies = [ [[package]] name = "ptask-server" -version = "3.51.3" +version = "3.51.4" dependencies = [ "anyhow", "axum", @@ -2842,7 +2842,7 @@ dependencies = [ [[package]] name = "ptask-tui" -version = "3.51.3" +version = "3.51.4" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 5541a5d..53f38ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.51.3" +version = "3.51.4" edition = "2024" license-file = "LICENSE" authors = ["PureTensor, Inc. "] @@ -52,12 +52,12 @@ rmcp = { version = "3.3.0", features = ["server", "macros", "schemars", "transpo futures-util = { version = "0.3", default-features = false } # Internal crates -ptask-core = { path = "crates/ptask-core", version = "3.51.3" } -ptask-tui = { path = "crates/ptask-tui", version = "3.51.3" } -ptask-server = { path = "crates/ptask-server", version = "3.51.3" } -ptask-bot = { path = "crates/ptask-bot", version = "3.51.3" } -ptask-distill = { path = "crates/ptask-distill", version = "3.51.3" } -ptask-notify = { path = "crates/ptask-notify", version = "3.51.3" } +ptask-core = { path = "crates/ptask-core", version = "3.51.4" } +ptask-tui = { path = "crates/ptask-tui", version = "3.51.4" } +ptask-server = { path = "crates/ptask-server", version = "3.51.4" } +ptask-bot = { path = "crates/ptask-bot", version = "3.51.4" } +ptask-distill = { path = "crates/ptask-distill", version = "3.51.4" } +ptask-notify = { path = "crates/ptask-notify", version = "3.51.4" } # HMAC for outbound webhook signing (v0.3.5). sha2 = "0.10" diff --git a/dashboard/README.md b/dashboard/README.md index 07b1f5b..c8b8fe9 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -278,6 +278,19 @@ The canonical `pt serve` and `tasks.db` are never modified — nothing to revert ## Version +- **v0.26.1** (pt 3.51.4) — iPhone 18 Pro Max home-screen app fit, checked in + the iOS 27 simulator (440×956, 62px status-bar inset). A cold launch no + longer leaves the board under a doubled 124px gap (iOS parks the page at + scrollY -62 about 2s in; the cockpit puts it back at 0), and the brand + + counts row starts below the status-bar scrim that iOS 26+ lays over web + apps. Every pane can scroll its last ~108px clear of the tab bar (the body + was pinned to 100% height, so its tab-bar padding added no scroll room and + a 5-card lane could not scroll at all). The lane picker sticks under the + real header height, lane and review cards keep snooze · dismiss · done on + the right while their chips wrap, the task sheet scrolls without moving the + board behind it, the composer's Deadline field stays inside the card, the + timeline's NOW tag no longer collides with a nearby date, and the hidden + skip link no longer peeks out from behind the status bar. - **v0.26.0** (pt 3.51.0) — Superadmin delete. The task drawer, on desktop and as the phone bottom sheet, has **Delete permanently** for a task in any status (open, done or dismissed). It confirms first, then diff --git a/dashboard/server.py b/dashboard/server.py index 39c7782..0c162bb 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -147,7 +147,7 @@ def resolve_default_domain(domains: list[dict], requested: str | None) -> str | # the dashboard exposes the same task data. Production sets PTASK_DASH_BIND. BIND = os.environ.get("PTASK_DASH_BIND", "127.0.0.1:9510") -VERSION = "0.26.0" +VERSION = "0.26.1" def _allowed_host_entry(entry: str) -> str: diff --git a/dashboard/tests/phone_layout.test.mjs b/dashboard/tests/phone_layout.test.mjs new file mode 100644 index 0000000..7d85acf --- /dev/null +++ b/dashboard/tests/phone_layout.test.mjs @@ -0,0 +1,83 @@ +// iPhone 18 Pro Max home-screen app fit (dashboard 0.26.1), found in the iOS +// 27 simulator: 440x956 CSS px, a 62px status-bar inset. Runs the shipped +// launch-offset handler against fake scroll/touch events, and pins the CSS and +// markup contracts the phone layout depends on. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const shell = readFileSync(new URL("../www/index.html", import.meta.url), "utf8"); + +/* the launch-offset handler: from `let _touch` to the scroll listener's close */ +const snap = shell.match(/\nlet _touch=false, _restT=0;[\s\S]*?\n\},\{passive:true\}\);\n/); +assert.ok(snap, "the launch-offset handler is in index.html"); + +function harness() { + const on = {}, timers = [], scrolls = []; + const win = { + scrollY: 0, + addEventListener: (type, fn) => { (on[type] ||= []).push(fn); }, + setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, + clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].fn = null; }, + scrollTo: (opts) => { scrolls.push(opts); win.scrollY = opts.top; }, + }; + new Function("win", `with (win) {${snap[0]}}`)(win); + const fire = (type) => (on[type] || []).forEach((fn) => fn()); + const flush = () => { const due = timers.splice(0); due.forEach((t) => t.fn && t.fn()); }; + return { win, fire, flush, scrolls }; +} + +test("a launch parked at -62 with no finger down is put back at 0", () => { + const h = harness(); + h.win.scrollY = -62; h.fire("scroll"); h.flush(); + assert.deepEqual(h.scrolls, [{ top: 0, behavior: "instant" }]); +}); + +test("a pull held under the finger is left alone", () => { + const h = harness(); + h.fire("touchstart"); + h.win.scrollY = -80; h.fire("scroll"); h.flush(); + assert.deepEqual(h.scrolls, []); +}); + +test("a rubber-band bounce that settles at 0 on its own never trips it", () => { + const h = harness(); + h.fire("touchstart"); h.fire("touchend"); + for (const y of [-90, -60, -30, -8, 0]) { h.win.scrollY = y; h.fire("scroll"); } + h.flush(); + assert.deepEqual(h.scrolls, []); +}); + +test("ordinary scrolling is never touched", () => { + const h = harness(); + for (const y of [0, 120, 447]) { h.win.scrollY = y; h.fire("scroll"); } + h.flush(); + assert.deepEqual(h.scrolls, []); +}); + +test("the body grows with its content, so the tab-bar padding adds scroll room", () => { + assert.doesNotMatch(shell, /html,body\{height:100%\}/); + assert.match(shell, /html\{height:100%\} body\{min-height:100%\}/); +}); + +test("the lane picker offset follows the header's border box", () => { + assert.match(shell, /\.observe\(document\.querySelector\('header'\),\{box:'border-box'\}\)/); +}); + +test("lane and review cards keep their actions outside the wrapping chip column", () => { + const cards = shell.match(/
[\s\S]*?/g) || []; + assert.equal(cards.length, 2, "lane card + review card"); + for (const c of cards) assert.match(c, /<\/span>\s*$/, "chips close before the actions"); + assert.match(shell, /\.tcard \.tmm\{display:contents\}/, "desktop row unchanged"); +}); + +test("an open sheet locks the board behind it, and closing releases it", () => { + assert.match(shell, /dr\.classList\.add\('open'\);[\s\S]{0,120}classList\.add\('sheet-open'\)/); + assert.match(shell, /function closeDrawer\(\)\{[^}]*classList\.remove\('sheet-open'\)/); + assert.match(shell, /html\.sheet-open,html\.sheet-open body\{overflow:hidden\}/); +}); + +test("the home-screen header starts below the status-bar scrim", () => { + assert.match(shell, /@media\(display-mode:standalone\)\{header\{padding-top:calc\(env\(safe-area-inset-top,0px\) \+ 16px\)\}\}/); +}); diff --git a/dashboard/www/index.html b/dashboard/www/index.html index 443fa30..9acf649 100644 --- a/dashboard/www/index.html +++ b/dashboard/www/index.html @@ -109,7 +109,10 @@ } *{box-sizing:border-box;margin:0;padding:0} -html,body{height:100%} +/* min-height, not height: a body pinned to 100% kept its bottom padding (the + phone tab bar's 74px + home indicator) inside the viewport, so the last + ~108px of every pane sat under the tab bar with no scroll room to reach it */ +html{height:100%} body{min-height:100%} body{ background:var(--bg); color:var(--text); font-family:var(--font); font-size:14px; line-height:1.45; -webkit-font-smoothing:antialiased; @@ -395,15 +398,18 @@ @media(pointer:coarse){.tcard .btn-act,.tcard .btn-done{opacity:1}} .tcard .tt{line-height:1.3;margin-bottom:6px;min-height:24px;display:flex;align-items:center} .tcard .tm{display:flex;gap:6px;align-items:center;flex-wrap:wrap} +.tcard .tmm{display:contents} /* snooze · dismiss · done as one unbreakable group at the row's right end */ .tcard .acts{display:inline-flex;gap:6px;align-items:center;margin-left:auto;flex:none} .tcard .acts .btn-done{margin-left:0} /* timeline */ .timeline{background:var(--card-bg);border:var(--card-border);border-radius:var(--radius);padding:18px 16px;backdrop-filter:var(--backdrop);box-shadow:var(--shadow);overflow-x:auto} -.tl-track{position:relative;height:8px;background:var(--panel2);border-radius:4px;margin:30px 10px 44px} +.tl-track{position:relative;height:8px;background:var(--panel2);border-radius:4px;margin:42px 10px 44px} .tl-now{position:absolute;top:-6px;bottom:-6px;width:2px;background:var(--accent);box-shadow:var(--glow)} -.tl-now::after{content:'NOW';position:absolute;top:-18px;left:-12px;font-size:9px;color:var(--accent);font-weight:700} +/* NOW gets its own row above the date labels: on the top row it ran into + any date cluster near today ("09-21 +1NOW" on a 440px phone) */ +.tl-now::after{content:'NOW';position:absolute;top:-38px;left:-12px;font-size:9px;color:var(--accent);font-weight:700} .tl-pt{position:absolute;top:50%;transform:translate(-50%,-50%);width:14px;height:14px;border-radius:50%;border:2px solid var(--bg);cursor:pointer;transition:transform .15s;display:block} .tl-pt::after{content:'';position:absolute;inset:-6px} /* 26px hit/touch target around the 14px dot */ .tl-pt:hover{transform:translate(-50%,-50%) scale(1.5);z-index:5} @@ -760,6 +766,10 @@ .dtag{min-height:28px;padding:2px 8px} } .skip{top:calc(14px + env(safe-area-inset-top,0px))} +/* parked fully above the screen: -160% of its own height only lifted it ~55px, + and a home-screen app starts it 76px down (14px + 62px inset), so it + peeked out from behind the status bar */ +.skip:not(:focus){transform:translateY(calc(-100% - 20px - env(safe-area-inset-top,0px)))} .drawer{padding-top:calc(20px + env(safe-area-inset-top,0px))} /* phone-only chrome, hidden everywhere else */ .tabbar,.phone-only,.lane-tabs{display:none} @@ -779,6 +789,10 @@ body{padding-bottom:calc(74px + env(safe-area-inset-bottom,0px))} /* header row 1: brand · counts · [+] row 2: full-width domain switch */ .hdr{padding:8px 12px 6px;gap:8px;flex-wrap:nowrap} + /* home-screen app only: iOS 26+ lays a dark legibility scrim over the status + bar that fades out ~28px below it (62px inset → ~90px on an iPhone 18 Pro + Max), greying the brand + counts row. Start the row below the fade. */ + @media(display-mode:standalone){header{padding-top:calc(env(safe-area-inset-top,0px) + 16px)}} .cube{width:28px;height:28px} .brandtxt b{font-size:16px} .brandtxt small{display:none} @@ -845,6 +859,11 @@ .tcard{padding:11px 12px;font-size:13px;margin-bottom:8px} .tcard .tt{font-size:13px;line-height:1.35} .tcard .tm{gap:6px 8px} + /* the chips wrap inside their own column and snooze · dismiss · done hold + the right edge: a deadline chip used to push all three onto a row of their + own, an empty 50px band under every dated card on a 440px-wide phone */ + .tcard .tm{flex-wrap:nowrap} + .tcard .tmm{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;flex:1 1 auto;min-width:0} /* recent: page scroll + 2-line titles instead of a nested 340px box */ .recent{max-height:none;overflow:visible;padding:2px 10px} .rrow{padding:10px 0;font-size:13px} @@ -880,7 +899,11 @@ .toast{bottom:calc(70px + env(safe-area-inset-bottom,0px));max-width:calc(100vw - 24px);text-align:center} /* detail drawer → bottom sheet */ .drawer{top:auto;left:0;right:0;bottom:0;width:100%;max-height:88dvh;border-left:none;border-top:var(--card-border); - border-radius:18px 18px 0 0;transform:translateY(105%);padding:22px 16px calc(18px + env(safe-area-inset-bottom,0px))} + border-radius:18px 18px 0 0;transform:translateY(105%);padding:22px 16px calc(18px + env(safe-area-inset-bottom,0px)); + overscroll-behavior:contain} + /* the board stays put under an open sheet: a fast flick inside the sheet + used to scroll the page behind it instead of (or as well as) the sheet */ + html.sheet-open,html.sheet-open body{overflow:hidden} .drawer::before{content:"";position:absolute;top:8px;left:50%;width:36px;height:4px;border-radius:2px;background:var(--border);transform:translateX(-50%)} .drawer.open{transform:none} .drawer h3{font-size:16px;line-height:1.35;overflow-wrap:anywhere} @@ -897,6 +920,10 @@ #compose .modal-card{max-height:calc(100dvh - env(safe-area-inset-top,0px) - 20px);padding:18px 16px} .modal-card.compose{max-width:none} .cform input[type=text],.cform textarea,.cform input[type=date]{font-size:16px} + /* iOS draws a native date field at its own intrinsic width and ignores + width:100%, so Deadline ran past the composer's right edge */ + .cform input[type=date]{-webkit-appearance:none;appearance:none;display:block;min-width:0;max-width:100%;min-height:44px} + .cform input[type=date]::-webkit-date-and-time-value{text-align:left} .cform{gap:12px} .sev button{min-width:0;flex:1 1 30%;padding:10px 4px} .modal-actions{gap:8px} @@ -1433,9 +1460,27 @@

New task

else { $('.hdr-sub').appendChild(th); if(_review&&_view==='review'){} } } PHONE_MQ.addEventListener('change',applyPhone); -/* the lane picker sticks just under the header — export its live height */ +/* the lane picker sticks just under the header — export its live height. + Watch the border box: iOS only fills env(safe-area-inset-top) in after + launch, which grows the header's padding but not its content box, so a + content-box observer kept a stale height and the picker slid under it. */ new ResizeObserver(()=>document.documentElement.style.setProperty('--hdr-h',document.querySelector('header').offsetHeight+'px')) - .observe(document.querySelector('header')); + .observe(document.querySelector('header'),{box:'border-box'}); +/* iOS 26+ home-screen app: ~2s after a cold launch the system parks the page + at scrollY -62 (the iPhone 18 Pro Max status-bar inset), though the sticky + header already pads itself by env(safe-area-inset-top), so the board sat + under a doubled 124px gap until the first scroll. Put it back at 0 once it + rests below 0 with no finger down; a real pull-to-bounce keeps firing scroll + events until it settles at 0 on its own, so it never trips this. */ +let _touch=false, _restT=0; +addEventListener('touchstart',()=>{_touch=true},{passive:true}); +addEventListener('touchend',()=>{_touch=false},{passive:true}); +addEventListener('touchcancel',()=>{_touch=false},{passive:true}); +addEventListener('scroll',()=>{ + clearTimeout(_restT); + if(scrollY>=0) return; + _restT=setTimeout(()=>{ if(!_touch&&scrollY<0) scrollTo({top:0,behavior:'instant'}); },200); +},{passive:true}); /* home-screen app resumed from the background: refresh now, and re-open the SSE stream iOS quietly killed while we were suspended */ document.addEventListener('visibilitychange',()=>{ @@ -2111,8 +2156,8 @@

${esc(t.
${n===0?'
none
':''}${list.map(t=>`
${esc(t.title.length>72?t.title.slice(0,70)+'…':t.title)}
-
${t.priority_score.toFixed(2)}${dueChip(t)} - ${ptrigger(t)}${originTag(t)}${domainTag(t)} +
${t.priority_score.toFixed(2)}${dueChip(t)} + ${ptrigger(t)}${originTag(t)}${domainTag(t)}
@@ -2367,8 +2412,8 @@

${esc(t. if(!stale.length){$('#review-list').innerHTML='
nothing pending to review'+inDom()+'
';return;} $('#review-list').innerHTML=stale.map(t=>`
${esc(t.title)}
-
${ptrigger(t)}${originTag(t)}${domainTag(t)}${Math.round(t.age_days)}d old${dueChip(t)} - ${(t.priority_score||0).toFixed(2)} +
${ptrigger(t)}${originTag(t)}${domainTag(t)}${Math.round(t.age_days)}d old${dueChip(t)} + ${(t.priority_score||0).toFixed(2)}
@@ -2394,6 +2439,7 @@

${esc(t. _drawerUuid=uuid; dr.classList.add('open'); $('#drawer-scrim').classList.add('show'); + document.documentElement.classList.add('sheet-open'); $('#d-close').focus({preventScroll:true}); try{ /* the notes route is a dedicated read (an old note outlives the events @@ -2503,7 +2549,7 @@

${esc(t. finally{btn.disabled=false;} }; function drawerOpen(){ return $('#drawer').classList.contains('open'); } -function closeDrawer(){ $('#drawer').classList.remove('open'); $('#drawer-scrim').classList.remove('show'); restoreInvoker(); } +function closeDrawer(){ $('#drawer').classList.remove('open'); $('#drawer-scrim').classList.remove('show'); document.documentElement.classList.remove('sheet-open'); restoreInvoker(); } $('#d-close').onclick=closeDrawer; $('#drawer-scrim').onclick=closeDrawer; // click the dimmed board to dismiss /* focus trap — the drawer is now aria-modal; Tab must not reach the board */ diff --git a/docs/gen/pt.1 b/docs/gen/pt.1 index fc4991a..0dbbe36 100644 --- a/docs/gen/pt.1 +++ b/docs/gen/pt.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH pt 1 "pt 3.51.3" +.TH pt 1 "pt 3.51.4" .SH NAME pt \- Sovereign task manager for PureTensor .SH SYNOPSIS @@ -208,4 +208,4 @@ Generate shell completions (bash/zsh/fish) to stdout pt\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION -v3.51.3 +v3.51.4