diff --git a/index.html b/index.html index dca73ca..4eeaa68 100644 --- a/index.html +++ b/index.html @@ -294,11 +294,19 @@ .gbar.gdropover{box-shadow:0 0 0 3px var(--accent)} .pgroup.gdropover{background:var(--accent-soft);border-radius:12px} .gbar{position:absolute;top:50%;transform:translateY(-50%);height:20px;border-radius:999px;min-width:16px; - display:flex;align-items:center;gap:6px;padding:0 12px 0 4px;overflow:hidden; box-shadow:0 2px 7px rgba(20,24,35,.16); - cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none} + cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none; + overflow:hidden} .gbar:hover{box-shadow:0 3px 11px rgba(20,24,35,.24)} .gbar:active{cursor:grabbing} + /* avatar + title cluster โ€” slides within the bar to stay in the scrolled viewport */ + .gpin{position:absolute;left:0;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:6px; + z-index:2;pointer-events:none;padding-left:4px;max-width:calc(100% - 24px)} + .gpin .ttl{font-size:12px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden; + text-overflow:ellipsis;flex:1;min-width:0;text-align:left;padding-left:1px} + .gbar.gsz-s .gpin .ttl{font-size:10.5px}.gbar.gsz-l .gpin .ttl{font-size:13px} + .gbar.gsz-xl .gpin .ttl{font-size:14px} + .gbar.gsub .gpin .ttl{color:inherit;font-size:11px} /* owner avatar fills the round left cap with an even ~4px margin on every side, so the space around it reads the same top, bottom and left regardless of bar height */ .gava{flex:none;position:relative;z-index:2;line-height:0} @@ -308,20 +316,18 @@ .gbar.gsz-l .gava .av{width:36px;height:36px;font-size:14px} .gbar.gsz-xl .gava .av{width:48px;height:48px;font-size:18px} .gbar.gsz-s{height:26px}.gbar.gsz-m{height:34px}.gbar.gsz-l{height:44px}.gbar.gsz-xl{height:56px} - .gbar .ttl{font-size:12px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden; - text-overflow:ellipsis;pointer-events:none;flex:1;min-width:0;text-align:left;padding-left:1px} - .gbar.gsz-s .ttl{font-size:10.5px}.gbar.gsz-l .ttl{font-size:13px}.gbar.gsz-xl .ttl{font-size:14px} - /* title rendered to the right of a too-narrow bar (plain black text, no background) */ - .gttlout{position:absolute;top:50%;transform:translateY(-50%);font-size:12px;font-weight:700; - color:var(--ink);white-space:nowrap;pointer-events:none;z-index:3} - .gdot{flex:none;position:relative;z-index:2;width:16px;height:16px;border-radius:50%;padding:0; + .gdot{position:absolute;right:4px;top:50%;transform:translateY(-50%); + flex:none;z-index:2;width:16px;height:16px;border-radius:50%;padding:0; border:2px solid rgba(255,255,255,.9);color:#fff;font-size:9px; display:flex;align-items:center;justify-content:center; - opacity:0;width:0;margin:0;border-width:0;transition:opacity .12s} - .gbar:hover .gdot{opacity:1;width:16px;border-width:2px;margin-right:0} - .gbar.gsz-s:hover .gdot{width:13px} - .gdot.on{opacity:1;width:16px;border-width:2px;background:#fff;border-color:#fff;color:var(--green)} + opacity:0;border-width:0;transition:opacity .12s} + .gbar:hover .gdot{opacity:1;border-width:2px} + .gbar.gsz-s:hover .gdot{width:13px;height:13px} + .gdot.on{opacity:1;border-width:2px;background:#fff;border-color:#fff;color:var(--green)} .gbar.gsz-s .gdot{width:13px;height:13px} + /* title rendered to the right of a too-narrow bar (plain black text, no background) */ + .gttlout{position:absolute;top:50%;transform:translateY(-50%);font-size:12px;font-weight:700; + color:var(--ink);white-space:nowrap;pointer-events:none;z-index:3} .gavb{flex:none;position:relative;z-index:2;margin-left:auto;width:18px;height:18px;border-radius:50%; background:#fff;font-size:8.5px;font-weight:800;pointer-events:none; display:flex;align-items:center;justify-content:center} @@ -333,7 +339,6 @@ .gexp:hover{color:var(--accent)} /* subtasks are OUTLINED (light fill, coloured border + text) vs solid filled tasks */ .gbar.gsub{box-shadow:0 1px 4px rgba(20,24,35,.12)} - .gbar.gsub .ttl{color:inherit;font-size:11px} .gbar.gsub .gava .av{width:15px;height:15px;font-size:7.5px;box-shadow:0 0 0 2px #fff} /* subtask bars have a WHITE fill, so the white done-dot was invisible on them โ€” give the dot the bar's own colour on hover, and a solid green tick once checked */ @@ -341,7 +346,7 @@ .gbar.gsub .gdot.on{background:var(--green);border-color:var(--green);color:#fff} .pflat{padding:12px 0 6px} .gbar.gdone{opacity:.4} - .gbar.gdone .ttl{text-decoration:line-through} + .gbar.gdone .gpin .ttl{text-decoration:line-through} .ear{position:absolute;top:0;bottom:0;width:13px;cursor:ew-resize;touch-action:none; opacity:0;transition:opacity .15s} .gbar:hover .ear,.gbar.dragging .ear{opacity:1} diff --git a/src/app/main.js b/src/app/main.js index 3efad79..c1c19f6 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -372,8 +372,7 @@ function renderGantt(){ ?`background:#fff;border:2px solid ${col};color:${col}` :fillBg}"> - ${av(n.owner,"xs")} - ${n.title} +
${av(n.owner,"xs")}${n.title}
@@ -457,10 +456,10 @@ function renderGantt(){ (any?"":'
No scheduled tasks for this filter.
')+ ``; const sc=document.querySelector(".gscroll"); - sc.addEventListener("scroll",pinFlags,{passive:true}); + if(!sc._pinBound){ sc._pinBound=true; sc.addEventListener("scroll",pinGanttScroll,{passive:true}); } const gpane=document.querySelector(".gantt"); if(gpane&&!gpane._floatBound){ gpane._floatBound=true; gpane.addEventListener("scroll",placeFloat,{passive:true}); } - pinFlags(); placeFloat(); placeOverflowTitles(); + pinGanttScroll(); placeFloat(); placeOverflowTitles(); // unstick Chromium's hover hit-testing after the DOM swap (otherwise tooltips/hover // stay dead until you move the mouse or switch tabs) if(typeof requestAnimationFrame!=="undefined") @@ -487,7 +486,7 @@ function placeOverflowTitles(){ document.querySelectorAll("#gantt .gttlout").forEach(x=>x.remove()); document.querySelectorAll("#gantt .gtrack").forEach(track=>{ track.querySelectorAll(":scope > .gbar").forEach(bar=>{ - const ttl=bar.querySelector(".ttl"); if(!ttl) return; + const ttl=bar.querySelector(".gpin .ttl")||bar.querySelector(".ttl"); if(!ttl) return; const full=ttl.scrollWidth, vis=ttl.clientWidth; if(full>4 && vis{ + const pin=bar.querySelector(".gpin"); if(!pin) return; + pin.style.left="0px"; + const bl=bar.offsetLeft, br=bl+bar.offsetWidth; + if(br<=v0+pad||bl>=v1-pad) return; + const pinW=pin.offsetWidth; if(pinW<2) return; + const want=Math.max(bl,v0+pad); + const maxL=Math.max(bl,br-pinW-pad); + const abs=Math.min(want,maxL); + if(abs>bl+1) pin.style.left=(abs-bl)+"px"; + }); +} function pinFlags(){ const sc=document.querySelector(".gscroll"); if(!sc) return; const v0=sc.scrollLeft; @@ -515,7 +531,7 @@ function pinFlags(){ lbl.style.left=left+"px"; }); } -window.addEventListener("resize",()=>{ sizeScale(); applyTilt(lastTilt); defer(renderGantt); }); +window.addEventListener("resize",()=>{ sizeScale(); applyTilt(lastTilt); defer(renderGantt); pinBars(); }); /* --- floating "gripped pill" ghost, shared by bar drags and pop-up task drags --- */ function makeGhost(text,color){ const g=document.createElement("div"); @@ -601,6 +617,7 @@ function barMove(ev){ const [cs,ce]=barGeom(G.s,G.e,G.n.done); G.el.style.left=gx(cs)+"%"; G.el.style.width=(gx(ce)-gx(cs))+"%"; + pinBars(); if(G.ghost){ G.ghost.style.display=G.moved?"":"none"; G.ghost.textContent=G.n.title+" ยท due "+fmtD(dayIso(G.e)); placeGhost(G.ghost,ev); } diff --git a/tests/smoke.test.js b/tests/smoke.test.js index a3f9aa0..e1a20e0 100644 --- a/tests/smoke.test.js +++ b/tests/smoke.test.js @@ -9,6 +9,12 @@ describe("project layout", () => { expect(html).toContain('onclick="addProject()"'); }); + it("pins task bar avatar and title during horizontal scroll", () => { + const main = readFileSync("src/app/main.js", "utf8"); + expect(main).toContain("function pinBars()"); + expect(main).toContain('class="gpin"'); + }); + it("exports core lib modules", async () => { const domain = await import("../src/lib/domain.js"); const tree = await import("../src/lib/tree.js");