Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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. <hal@puretensor.ai>"]
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions dashboard/tests/phone_layout.test.mjs
Original file line number Diff line number Diff line change
@@ -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(/<div class="tm"><span class="tmm">[\s\S]*?<span class="acts">/g) || [];
assert.equal(cards.length, 2, "lane card + review card");
for (const c of cards) assert.match(c, /<\/span>\s*<span class="acts">$/, "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\)\}\}/);
});
68 changes: 57 additions & 11 deletions dashboard/www/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -1433,9 +1460,27 @@ <h3 id="compose-h">New task</h3>
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',()=>{
Expand Down Expand Up @@ -2111,8 +2156,8 @@ <h3 style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${esc(t.
<div class="lane-body" data-p="${p}" role="group" aria-label="P${p} ${PNAME[p]} — ${n} task${n===1?'':'s'}">
${n===0?'<div class="ph">none</div>':''}${list.map(t=>`<div class="tcard" data-id="${t.pt_id||t.id}" data-uuid="${t.id}">
<div class="tt" style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${esc(t.title.length>72?t.title.slice(0,70)+'…':t.title)}</div>
<div class="tm"><span class="score">${t.priority_score.toFixed(2)}</span>${dueChip(t)}
${ptrigger(t)}${originTag(t)}${domainTag(t)}
<div class="tm"><span class="tmm"><span class="score">${t.priority_score.toFixed(2)}</span>${dueChip(t)}
${ptrigger(t)}${originTag(t)}${domainTag(t)}</span>
<span class="acts"><button class="btn-act snz" title="snooze 3d (shift: 7d)" aria-label="snooze 3 days" data-act="snooze"><svg class="ic" aria-hidden="true"><use href="#i-zzz"/></svg></button>
<button class="btn-act dis" title="dismiss" aria-label="dismiss" data-act="dismiss"><svg class="ic" aria-hidden="true"><use href="#i-trash"/></svg></button>
<button class="btn-done" title="done" aria-label="mark done" data-act="done"><svg class="ic" aria-hidden="true"><use href="#i-check"/></svg></button></span></div>
Expand Down Expand Up @@ -2367,8 +2412,8 @@ <h3 style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${esc(t.
if(!stale.length){$('#review-list').innerHTML='<div class="ph">nothing pending to review'+inDom()+'</div>';return;}
$('#review-list').innerHTML=stale.map(t=>`<div class="tcard" data-id="${t.pt_id||t.id}" data-uuid="${t.id}" style="margin-bottom:10px">
<div class="tt" style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${esc(t.title)}</div>
<div class="tm">${ptrigger(t)}${originTag(t)}${domainTag(t)}<span class="age">${Math.round(t.age_days)}d old</span>${dueChip(t)}
<span class="score">${(t.priority_score||0).toFixed(2)}</span>
<div class="tm"><span class="tmm">${ptrigger(t)}${originTag(t)}${domainTag(t)}<span class="age">${Math.round(t.age_days)}d old</span>${dueChip(t)}
<span class="score">${(t.priority_score||0).toFixed(2)}</span></span>
<span class="acts"><button class="btn-act snz" title="snooze 3d" aria-label="snooze 3 days" data-act="snooze"><svg class="ic" aria-hidden="true"><use href="#i-zzz"/></svg></button>
<button class="btn-act dis" title="dismiss" aria-label="dismiss" data-act="dismiss"><svg class="ic" aria-hidden="true"><use href="#i-trash"/></svg></button>
<button class="btn-done" title="done" aria-label="mark done" data-act="done"><svg class="ic" aria-hidden="true"><use href="#i-check"/></svg></button></span></div>
Expand All @@ -2394,6 +2439,7 @@ <h3 style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${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
Expand Down Expand Up @@ -2503,7 +2549,7 @@ <h3 style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${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 */
Expand Down
Loading
Loading