← Commit history
README.md+6
@@ -64,6 +64,12 @@ A plain on/off toggle. Each lane is answered on its OWN subgraph, so a dependenc  **Dependency arrows.** Teal routes with rounded elbows and arrowheads show task relationships: out of the source bar's end sideways, down, and into the next bar's start sideways again, steering round any bar in the way rather than through it. Click any bar to see incoming ("needs") and outgoing ("blocks") dependencies. Add or remove dependencies inline. +**Links to a row (0.73.0).** Every task and milestone has a permanent ID like `#00042`. Each chart row has a link button at its right end, the Table's first column is the ID (click it), and the item editor has "Copy link #00042". Each copies `#00042 Name: <plan address>#row=00042` for pasting into Chat (and a named link where the paste takes formatting). Opening the link selects that row, opens its section if it was collapsed, scrolls to it and marks it for a moment.++**The owner's view and the team's (0.73.0).** The plan's owner sees everything. Everyone else it is shared with gets the same plan and the same editing, but no Analytics lens and no Sharing or Google Chat settings on Setup (Sections and Tags stay). "See what the team sees" on the Sharing card previews that view until "Back to my view". Only the owner changes how often Chat is posted to.++**Tags fold like sections (0.73.0).** A plan with more than eight tags shows them behind one "N tags" chip in the legend; a chosen tag keeps its chip.+ **Provenance links stay intact.** A row's provenance can be plain text ("Added by Drew") or a link to where it came from (a Chat thread, a document). A plain one is edited like any field. A linked one is shown as the link in the Table and in the item editor and its text cannot be changed there, so it always names what it opens (0.71.0).  **Current and Overdue.** Two chips in the legend, for reviewing a plan with the team. **Current** (0.70.0) shows only what has started (start date today or earlier) and is not complete or cancelled: the work under way, plus anything that should have started and has not. Future and finished rows are hidden, in the chart and the table. **Overdue** shows only what is past its deadline and not done. Turn both on for just the overdue rows. Each counts tasks and milestones separately, and neither changes the plan.
docs/BUILD-NOTES.md+33
@@ -3,6 +3,39 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-10-28 (0.73.0, row links, the team's view, tags fold)++Drew, sharing the plan with the team: "human copyable task ID/links so we can share in gchat+... a clear, easy to access button to copy that link for each task ... in both gantt and+table pages"; "hide analytics page from team"; "tags should collapse/expand like sections";+and whether the Setup cards are visible to everyone, with a way to check what the team sees.++Links: the uid is the ID (#00042) and the link is the plan's address plus `#row=<uid>`. The+copy puts "#00042 Name: link" as text and a named link as HTML (ClipboardItem), falling+back to the existing writeIdText path. Chart rows carry an always-visible quiet link button+in the slot the pencil had (the pencil now shows beside it on hover, over the tags, which are+the part of a row that gives way; reserving room for both cut names at a 270px label+column). The Table gets an ID column (74px, sortable). The editor gets "Copy link #ID".+Opening a link (at load or by hashchange) consumes the fragment, switches Analytics or Setup+to the chart, opens a collapsed section, selects the row, scrolls to it and pulses it, and+says so if a filter hides it or the row is gone.++Team view: the page asks /access once; a viewer who cannot change the plan's settings is+the team: CSS hides the Analytics lens and Setup's Sharing and Chat cards, and setView+refuses Analytics. The owner previews this from the Sharing card (sessionStorage, so it lasts+for the tab), with a banner and "Back to my view". Server side, the Chat cadence and the test+message are now owner-only too (403 for members). This is presentation for Analytics: a+member can read the same plan data through the API, which is all Analytics is made from.++Tags fold: a "N tags" chip like the sections' fold; folded by default above eight tags+(Drew's plan has 17), open below, so small plans and the suite's tag flows are unchanged; a+chosen tag always keeps its chip and the fold says how many are on.++Tests: a row-links flow (19 checks: the button on the chart without hover, the copied text,+the Table ID and its copy, the editor's button, opening a link at load and by hashchange in+the Table, a missing row, the tag fold, the owner's preview, and a real member via a stubbed+/access); server: a member's cadence change is 403, the owner's 200.+ ## Build notes, 2026-10-27 (0.72.0, typing highlights in every list; only Enter or a click picks)  Drew, with a Table section cell open: "I need to be able to type to highlight existing
gantt.html+137−5
@@ -1473,6 +1473,26 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08    name text or changes the row's height. */ .row-edit { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text2); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.12s, color 0.12s, border-color 0.12s; z-index: 11; } .row-edit svg { width: 12px; height: 12px; }+/* Copy a link to the row (0.73.0): always there, quiet until pointed at, so a link for Chat+   is one click on any row. It takes the pencil's reserved slot at the end of the row; the+   pencil appears beside it on hover, over the row's tail (its tags, the part that gives way). */+.row-link { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; background: none; border: 1px solid transparent; border-radius: 4px; color: var(--text3); cursor: pointer; opacity: 0.55; z-index: 11; transition: opacity 0.12s, color 0.12s, border-color 0.12s; }+.row-link svg { width: 12px; height: 12px; }+.label-row:hover .row-link, .row-link:focus-visible { opacity: 1; }+.row-link:hover { color: var(--accent); border-color: var(--accent); background: var(--surface); }+.row-link.copied, .uid-link.copied { color: var(--accent); border-color: var(--accent); }+.label-row .row-edit { right: 28px; }+.data-table td.c-uid { white-space: nowrap; padding-left: 8px; padding-right: 4px; }+.data-table .uid-link { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px solid transparent; border-radius: 999px; padding: 1px 6px; font-family: 'JetBrains Mono', monospace; font-size: calc(10px * var(--fs)); color: var(--text2); cursor: pointer; }+.data-table .uid-link svg { width: 11px; height: 11px; opacity: 0.7; }+.data-table .uid-link:hover { color: var(--accent); border-color: var(--accent); }+/* The row a shared link pointed at, for a moment. */+@keyframes linkPulse { 0%, 45% { background: color-mix(in srgb, var(--accent) 24%, transparent); } 100% { background: transparent; } }+.label-row.linked, .bar-row.linked, .data-table tr.linked td { animation: linkPulse 2.6s ease-out; }+/* The team's view (0.73.0): Analytics and the plan's Sharing and Chat settings are the+   owner's. The owner can preview the team's view from the Sharing card. */+:root[data-role="member"] .vt-btn[data-view="analytics"], :root[data-role="member"] #suShare, :root[data-role="member"] #suChat { display: none !important; }+#memberPreview { position: fixed; top: 0; left: 50%; transform: translateX(-50%); z-index: 100002; display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--elevated); border: 1px solid var(--accent); border-top: none; border-radius: 0 0 8px 8px; font-size: 12px; color: var(--text); box-shadow: 0 6px 18px rgba(0,0,0,0.35); } .label-row:hover .row-edit { opacity: 1; pointer-events: auto; } .row-edit:hover { color: var(--accent); border-color: var(--accent); } .row-edit:focus-visible { opacity: 1; pointer-events: auto; outline: 1px solid var(--accent); }@@ -1683,6 +1703,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va       <div class="row-drop" id="rowDrop"></div>       <table class="data-table">         <thead><tr id="tableHead">+          <th class="sortable" data-sort="uid" title="The row's permanent ID. Click one to copy a link to that row, to paste in Chat">ID</th>           <th class="sortable" data-sort="name">Task</th>           <th class="sortable" data-sort="phase">Phase</th>           <th class="sortable" data-sort="owner">Owner</th>@@ -1742,6 +1763,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va           <select id="shareOrg" aria-label="Which organization" style="display:none"></select>         </div>         <div class="chat-status" id="shareStatus"></div>+        <div class="chat-row"><button type="button" class="de-btn secondary" id="sharePreview" title="See the plan the way your team sees it, until you switch back">See what the team sees</button></div>       </div>       <div class="an-card su-card" id="suChat">         <div class="su-head"><h3>Google Chat</h3><span class="su-n" id="chatState"></span></div>@@ -5068,7 +5090,18 @@ function toggleTagFilter(tag){ // opens them, and that is remembered), and with tags chosen the chips of exactly the // sections on screen come back, a handful, straight after the tags that chose them. // A plan with no tags has no tag chips and no fold: its legend is what it always was.-const secToggle=document.createElement('button'), tagClear=document.createElement('button');+const secToggle=document.createElement('button'), tagClear=document.createElement('button'), tagToggle=document.createElement('button');+// Tags fold like the sections (0.73.0). A plan with more than TAG_FOLD_AT tags starts folded;+// a chosen tag's chip always stays out, so a filter is never hidden behind the fold.+const TAG_FOLD_AT=8;+function tagsOpen(n){ return typeof prefs.legendTags==='boolean'?prefs.legendTags:n<=TAG_FOLD_AT; }+function syncTagToggle(n,chosen){+  const open=tagsOpen(n);+  tagToggle.classList.toggle('open',open);+  tagToggle.setAttribute('aria-expanded',open?'true':'false');+  tagToggle.querySelector('.st-label').textContent=open?'Fewer tags':plural(n,'tag')+(chosen?' · '+chosen+' on':'');+  tagToggle.title=open?'Fold the tag chips away behind one chip.':'Show a chip for each of the '+plural(n,'tag')+', to filter by them.';+} // Owner and Overdue lead the legend, before the tags (0.64.0). The owner pick is a select // because a plan has a dozen people, not four; it is the chart's copy of the table's own // select, so it hides in the table view, where that one sits in the toolbar.@@ -5151,6 +5184,9 @@ function wireLegendTags(){ secToggle.type='button'; secToggle.id='legendSecs'; secToggle.className='legend-item sec-toggle'; secToggle.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg><span class="st-label"></span>'; secToggle.addEventListener('click',()=>{ prefs.legendSecs=!prefs.legendSecs; savePrefs(); rebuildLegend(); });+tagToggle.type='button'; tagToggle.id='legendTags'; tagToggle.className='legend-item sec-toggle tag-toggle';+tagToggle.innerHTML=secToggle.innerHTML;+tagToggle.addEventListener('click',()=>{ prefs.legendTags=!tagsOpen(usedTags().length); savePrefs(); rebuildLegend(); }); tagClear.type='button'; tagClear.id='tagClear'; tagClear.className='legend-item tag-clear'; tagClear.textContent='Clear tags'; tagClear.title='Show every section and row again. Only your view changes, not the plan.';@@ -5183,14 +5219,16 @@ function syncSecToggle(){ } function rebuildLegend(){   legendEl.querySelectorAll('.phase-chip,.tag-chip').forEach(el=>el.remove());-  [secToggle,tagClear,ownerPick,currentChip,overdueChip].forEach(el=>{ if(el.isConnected)el.remove(); });+  [secToggle,tagClear,tagToggle,ownerPick,currentChip,overdueChip].forEach(el=>{ if(el.isConnected)el.remove(); });   const first=collapseAllBtn.isConnected?collapseAllBtn.nextSibling:legendEl.firstChild;   const put=el=>legendEl.insertBefore(el,first);   syncOwnerPick(); put(ownerPick);   syncCurrentChip(); put(currentChip);   syncOverdueChip(); put(overdueChip);   const tags=usedTags(), ks=tagFilterKeys(), filtering=tags.length>0&&tagFilterOn();-  tags.forEach(e=>put(tagChip(e,ks.has(e.key))));+  if(tags.length){ syncTagToggle(tags.length,tags.filter(e=>ks.has(e.key)).length); put(tagToggle); }+  const tOpen=tagsOpen(tags.length);+  tags.forEach(e=>{ if(tOpen||ks.has(e.key))put(tagChip(e,ks.has(e.key))); });   if(filtering)put(tagClear);   const folded=tags.length>0&&!filtering&&!prefs.legendSecs;   if(tags.length&&!filtering)put(secToggle);@@ -5359,6 +5397,62 @@ depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.class // rather than a double-click nobody could reach past the instant editor. // Group rows keep their chevron and get no Edit button: a lane header's only // field is its name, and that is the phase, which is a different thing to edit.+// ── A link to one row (0.73.0) ──+// Every task and milestone has a permanent ID (its uid, "#00042"), and a link to it is the+// plan's address plus #row=<uid>. Copying puts "#00042 Name: <link>" on the clipboard as+// text, and the same as a named link for anything that takes HTML (Google Chat's composer+// does). Opening the link selects the row, scrolls to it and marks it for a moment.+function rowLink(r){ return location.origin+location.pathname+'#row='+encodeURIComponent(r.uid||''); }+function rowRef(r){ return '#'+(r.uid||'?')+' '+(r.name||r.label||''); }+function copyRowLink(r,el){+  if(!r||!r.uid){ flash(saveInd,'this row has no permanent ID yet'); return; }+  const url=rowLink(r), txt=rowRef(r)+': '+url;+  window.__lastRowLink=txt;   // for the suite: headless Chrome does not show its clipboard+  const done=()=>{ flash(saveInd,'Copied a link to '+rowRef(r),2800); if(el){ el.classList.add('copied'); setTimeout(()=>el.classList.remove('copied'),1200); } };+  try{+    if(navigator.clipboard&&navigator.clipboard.write&&window.ClipboardItem){+      const html='<a href="'+esc(url)+'">'+esc(rowRef(r))+'</a>';+      navigator.clipboard.write([new ClipboardItem({'text/plain':new Blob([txt],{type:'text/plain'}),'text/html':new Blob([html],{type:'text/html'})})])+        .then(done).catch(()=>writeIdText(txt,done,false));+      return;+    }+  }catch(e){}+  writeIdText(txt,done,false);+}+function makeLinkBtn(r){+  const b=document.createElement('button');+  b.type='button';b.className='row-link';b.title='Copy a link to this row, to paste in Chat';+  b.setAttribute('aria-label','Copy a link to this row');+  b.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>';+  b.addEventListener('mousedown',e=>e.stopPropagation());+  b.addEventListener('dblclick',e=>e.stopPropagation());+  b.addEventListener('click',e=>{e.stopPropagation();e.preventDefault();hideTip();copyRowLink(r,b);});+  return b;+}+// Opening a link: #row=<uid> in the address, at load or pasted into an open tab.+function linkedUid(){ const m=String(location.hash||'').match(/(?:^#|&)row=([^&]+)/); return m?decodeURIComponent(m[1]):''; }+function openRowLink(){+  const u=linkedUid(); if(!u)return;+  try{ const h=String(location.hash).replace(/^#/,'').split('&').filter(kv=>kv&&!/^row=/.test(kv)).join('&');+    history.replaceState(null,'',location.pathname+location.search+(h?'#'+h:'')); }catch(e){}+  const r=items.find(x=>!isGroup(x)&&x.uid===u);+  if(!r){ flash(saveInd,'There is no row #'+u+' in this plan (it may have been deleted)',4500); return; }+  if(prefs.view!=='gantt'&&prefs.view!=='table')setView('gantt');+  if(r.phase!==undefined&&isCollapsed(r.phase))setCollapsed(r.phase,false);+  const hidden=(r.phase!==undefined&&!phaseVis[r.phase])||!rowShown(r)||(isMs(r)&&!msVis);+  selectSole(r);+  const pulse=el=>{ if(!el)return; el.classList.remove('linked'); void el.offsetWidth; el.classList.add('linked'); setTimeout(()=>el.classList.remove('linked'),2800); };+  if(prefs.view==='table'){+    setFocusedRow(rowKey(r)); renderTable();+    const tr=[...tableBody.querySelectorAll('tr[data-key]')].find(t=>t.dataset.key===rowKey(r));+    if(tr){ tr.scrollIntoView({block:'center'}); pulse(tr); }+  }else{+    const target=isMs(r)?document.querySelector('.ms-hit[data-msuid="'+u+'"]'):(barEls[r.id]||r._lDiv);+    if(target&&target.scrollIntoView)target.scrollIntoView({block:'center',inline:'center'});+    pulse(r._lDiv); pulse(r._bDiv);+  }+  flash(saveInd,rowRef(r)+(hidden?' is hidden by your filters':''),4000);+} function makeEditBtn(r){   const b=document.createElement('button');   b.type='button';b.className='row-edit';b.title='Edit all fields';@@ -5417,6 +5511,7 @@ function buildRowDom(r,idx){     lDiv.className='label-row ms';     lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));     lDiv.appendChild(makeEditBtn(r));+    lDiv.appendChild(makeLinkBtn(r));     r._nameSpan=lDiv.querySelector('.row-name');     r._nameSpan.innerHTML=nameInner(r);     bDiv.className='bar-row ms-row';@@ -5439,6 +5534,7 @@ function buildRowDom(r,idx){   else{lDiv.className='label-row';lDiv.dataset.id=r.id;     lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));     lDiv.appendChild(makeEditBtn(r));+    lDiv.appendChild(makeLinkBtn(r));     r._nameSpan=lDiv.querySelector('.row-name');     r._nameSpan.innerHTML=nameInner(r);     bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.tags=ownTags(r).join(', ');b.dataset.id=r.id;@@ -10090,6 +10186,7 @@ function openItemModal(r,carriedName,opts){       <select id="imAddDep" style="margin-top:4px"></select></div>     <div class="de-actions"><button class="de-btn primary" id="imApply">Apply</button>       <button class="de-btn secondary" id="imCancel">Cancel</button>+      <button class="de-btn secondary" id="imCopyLink" title="Copy a link to this row, to paste in Chat">Copy link</button>       <button class="de-btn danger" id="imDelete" title="Delete this row from the plan. Undoable with Ctrl+Z">Delete</button></div>     <div id="imErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;   // values assigned, never interpolated, so nothing typed here can become markup@@ -10144,6 +10241,7 @@ function openItemModal(r,carriedName,opts){   if(!ms)$('imAddDep').addEventListener('apppick',function(){ if(this.value&&!dNeeds.includes(this.value)){ dNeeds.push(this.value); renderDeps(); $('imAddDep').focus(); } });   $('imStatus').value=statusOf(r);   $('imDesc').value=r.description||'';+  { const cl=$('imCopyLink'); if(cl){ cl.textContent=r.uid?'Copy link #'+r.uid:'Copy link'; cl.addEventListener('click',e=>{ e.preventDefault(); copyRowLink(r,cl); }); } }   if($('imProv'))$('imProv').value=p.label||'';   // The row's OWN tags only: the section's are the section's, edited on Setup.   const tagBox=wireTagBox($('imTags'),ownTags(r),'im');@@ -10976,6 +11074,7 @@ function syncOwnerControls(){ // because a keyboard move has to ask "which rows does the sort consider tied with // this one", and the only safe answer is the one the sort itself used. function sortValueOf(r,key){+  if(key==='uid')return r.uid||'';   if(key==='name')return (r.name||'').toLowerCase();   if(key==='phase')return (phaseNameOf(r)||'').toLowerCase();   if(key==='owner')return ownerOf(r).toLowerCase();@@ -11136,7 +11235,7 @@ function renderTable(){       :ownerFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'This owner has no rows'+(tagFilterOn()?' with these tags':'')+'. Pick another owner, or All owners.'       :tagFilterOn()&&!items.some(r=>!isGroup(r)&&rowShown(r))?'No row carries these tags, on itself or through its section. Pick other tags in the legend, or clear them.'       :'No items match these filters.';-    tableBody.innerHTML='<tr><td colspan="12" class="table-empty">'+why+'</td></tr>';return;}+    tableBody.innerHTML='<tr><td colspan="13" class="table-empty">'+why+'</td></tr>';return;}   tableBody.innerHTML=rows.map(r=>{     const ms=isMs(r), editable=ms||!!r.id;     const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');@@ -11145,6 +11244,7 @@ function renderTable(){     const ed=editable?' editable':'';     const trCls=[ms?'is-ms':'',isCancelled(r)?'cancelled':''].filter(Boolean).join(' ');     return `<tr data-key="${esc(rowKey(r))}"${trCls?` class="${trCls}"`:''}>`+      +`<td class="c-uid">${r.uid?`<button class="uid-link" type="button" tabindex="-1" title="Copy a link to this row, to paste in Chat">#${esc(r.uid)}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg></button>`:''}</td>`       +`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`         +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>`       +`<td class="c-phase${ed}" data-field="phase"${phaseTagsOf(r).length?` title="Tags: ${esc(phaseTagsOf(r).join(', '))}"`:''}>${dot}${esc(phaseNameOf(r))}</td>`@@ -11301,7 +11401,7 @@ function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).lengt // width, and the table is pixel-driven from then on. That is also what Excel does: // before you touch anything the columns are whatever the sheet says, and the moment // you drag one they are numbers you own.-const COL_DEFAULTS={name:'20%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',effort:'5%',deps:'5%',notes:'13%',tags:'8%',prov:'10%',del:'30px'};+const COL_DEFAULTS={uid:'74px',name:'20%',phase:'9%',owner:'8%',start:'7%',deadline:'7%',status:'8%',effort:'5%',deps:'5%',notes:'13%',tags:'8%',prov:'10%',del:'30px'}; function applyColWidths(){   const table=tableHead.closest('table');   if(!table)return;@@ -12272,6 +12372,8 @@ tableHead.addEventListener('click',e=>{   renderTable(); }); tableBody.addEventListener('click',e=>{+  const ul=e.target.closest('.uid-link');+  if(ul){ e.stopPropagation(); const tr=ul.closest('tr'); copyRowLink(rowItem(tr&&tr.dataset.key),ul); return; }   const del=e.target.closest('.row-del');   if(del){     const tr=del.closest('tr'); const r=rowItem(tr&&tr.dataset.key);@@ -12805,6 +12907,30 @@ function wireChatCard(){ // Who can open the plan: only its owner, members of one Adom org, or anyone signed in to // Adom. The server enforces it on every request; this card shows it and, for the owner, // changes it. The orgs offered are the owner's own, as read at their sign-in.+// ── Owner and team views (0.73.0) ──+// The plan's owner (and the server's operators) see everything. Everyone else the plan is+// shared with is the team: same plan, same editing, but no Analytics lens and no Sharing or+// Google Chat settings. The owner can preview the team's view from the Sharing card; the+// preview lasts for this tab until "Back to my view".+let viewerIsOwner=true;     // a snapshot, or a server that cannot say, is the owner's view+const PREVIEW_KEY='adom-gantt-preview-member';+function previewingMember(){ try{ return sessionStorage.getItem(PREVIEW_KEY)==='1'; }catch(e){ return false; } }+function applyRole(){+  const member=!viewerIsOwner||previewingMember();+  document.documentElement.dataset.role=member?'member':'owner';+  let bar=$('memberPreview');+  if(viewerIsOwner&&previewingMember()){+    if(!bar){ bar=document.createElement('div'); bar.id='memberPreview';+      bar.innerHTML='<span>You are seeing the plan the way your team does: no Analytics, no Sharing or Google Chat settings.</span><button type="button" class="de-btn primary" id="previewBack">Back to my view</button>';+      document.body.appendChild(bar);+      $('previewBack').addEventListener('click',()=>{ try{ sessionStorage.removeItem(PREVIEW_KEY); }catch(e){} applyRole(); flash(saveInd,'back to your own view'); }); }+  } else if(bar) bar.remove();+  if(member&&prefs.view==='analytics'){ prefs.view='gantt'; applyView(); applyFilter(); }+}+async function loadRole(){+  if(!SNAPSHOT){ try{ const r=await fetch('access'); if(r.ok){ const a=await r.json(); viewerIsOwner=!!a.canChange; } }catch(e){} }+  applyRole();+} function shareText(a){ return a.mode==='me'?'only the owner':a.mode==='org'?a.org+' members':'anyone signed in to Adom'; } async function renderShareCard(){   const card=$('suShare'); if(!card)return;@@ -12829,6 +12955,8 @@ async function renderShareCard(){ function wireShareCard(){   const mode=$('shareMode'), org=$('shareOrg'); if(!mode||!org)return;   wireAppSelect(mode); wireAppSelect(org);+  const pv=$('sharePreview');+  if(pv)pv.addEventListener('click',()=>{ try{ sessionStorage.setItem(PREVIEW_KEY,'1'); }catch(e){} applyRole(); setView('gantt'); });   const send=async body=>{     try{ const r=await fetch('access',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});       const d=await r.json().catch(()=>({}));@@ -12840,6 +12968,7 @@ function wireShareCard(){ } function setView(v){   if(!VIEWS.includes(v))return;+  if(v==='analytics'&&document.documentElement.dataset.role==='member')return;   if(prefs.view===v)return;   // The marks live on chart rows. Leaving the lens leaves the question behind too,   // rather than carrying an armed delete into a view that never showed it.@@ -12934,6 +13063,9 @@ lastSnap=snapSemantic(); startLive(); wireChatCard(); wireShareCard();+await loadRole();+openRowLink();+window.addEventListener('hashchange',openRowLink); // The page is built and the plan is in. The suite waits for this rather than for the // network to go quiet, which it never does once the live stream is open (0.66.0). document.documentElement.dataset.ready='1';
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.72.0",+  "version": "0.73.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.72.0",+  "version": "0.73.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
server.js+3
@@ -2945,6 +2945,8 @@ const server = http.createServer(async (req, res) => {         else cfg.publicUrl = body.publicUrl.replace(/\/+$/, '');       }       if ('cadence' in body) {+        // The plan's owner manages its Chat posting (0.73.0), like its sharing.+        if (!admin) { jsonResp(res, 403, { ok: false, error: 'only the plan\'s owner can change how often it posts' }); return; }         if (!CHAT_CADENCES.includes(body.cadence)) { jsonResp(res, 400, { ok: false, error: 'cadence is one of ' + CHAT_CADENCES.join(', ') }); return; }         // Turning it back on starts from now: a gap while it was off is not dumped into         // the space all at once.@@ -2953,6 +2955,7 @@ const server = http.createServer(async (req, res) => {       }       chatSave(cfg);       if (body.test) {+        if (!admin) { jsonResp(res, 403, { ok: false, error: 'only the plan\'s owner can send a test message' }); return; }         if (!cfg.webhook || !chatWebhookOk(cfg.webhook)) { jsonResp(res, 409, { ok: false, error: 'no webhook is set' }); return; }         const label = { live: 'as digests, a couple of minutes after each burst of editing', hourly: 'once an hour', daily: 'once a day', off: 'nowhere: posting is off' }[cfg.cadence || 'live'];         try {
test/server_test.mjs+3−1
@@ -585,7 +585,9 @@ async function main() {   r = await asPost(drew, '/notify', { webhook: 'https://example.com/hook' });   ok(r.status === 400, 'the owner\'s webhook is still checked for being a Chat webhook', r.status);   r = await asPost(ana, '/notify', { cadence: 'hourly' });-  ok(r.status === 200, 'anyone the plan is shared with can change how often it posts', r.status);+  ok(r.status === 403, 'a member cannot change how often it posts: the owner manages Chat (0.73.0)', r.status);+  r = await asPost(drew, '/notify', { cadence: 'hourly' });+  ok(r.status === 200, 'the owner can', r.status);   // The live stream of someone the owner stops sharing with is told and closed.   const live = await fetch(S.base + '/live?cid=ana1', { headers: Object.assign({ Cookie: ana.cookie }, XFF) });   const reader = live.body.getReader(); let got = '';
test/ui_test.mjs+84
@@ -848,6 +848,7 @@ async function main() {     if (wantAny('provenance')) await provFlow(browser);     if (wantAny('pick by mouse')) await pickFlow(browser);     if (wantAny('type ahead')) await typeAheadFlow(browser);+    if (wantAny('row links')) await rowLinksFlow(browser);      if (wantAny('viewport')) {       // ── Viewport matrix ──@@ -1163,6 +1164,13 @@ const MANIFEST = [   { sel: '.owner-pick', label: 'legend owner filter (its wrapper)', terminal: true, by: 'owner and overdue' },   { sel: '#overdueChip', label: 'legend: overdue only', safe: true, twice: true },   { sel: '#currentChip', label: 'legend: current only (started, not done)', safe: true, twice: true },+  // 0.73.0: links to rows, the team's view, the tag fold.+  { sel: '.row-link', label: 'chart row: copy a link to the row', terminal: true, by: 'row links' },+  { sel: '.uid-link', label: 'table: the row ID, copies a link', terminal: true, by: 'row links' },+  { sel: '#imCopyLink', label: 'item editor: copy link', terminal: true, by: 'row links' },+  { sel: '#sharePreview', label: 'setup: see what the team sees', terminal: true, by: 'row links' },+  { sel: '#previewBack', label: 'team preview: back to my view', terminal: true, by: 'row links', required: false },+  { sel: '#legendTags', label: 'legend: fold or unfold the tag chips', safe: true, twice: true },   { sel: '.sec-tag-in', label: 'section tag: type to add', terminal: true, by: 'tags' },   { sel: '.sec-tag-x', label: 'section tag: remove one', terminal: true, by: 'tags', key: c => c.ds.tag, pin: ['Launch', 'Tech', 'Process'] },   { sel: '#tagPrefixBtn', label: 'tags from name prefixes', terminal: true, by: 'tags' },@@ -3318,6 +3326,82 @@ async function notifyFlow(browser) {   check('the second click removes it', (await (await fetch(X.url + 'notify')).json()).configured === false && c.state === 'not set up', JSON.stringify(c));   await page.close(); fake.close(); }+// 0.73.0: every row has a link (#row=<uid>) with a copy button on the chart, in the table and+// in the editor; opening a link selects and shows the row; the team does not see Analytics or+// the Sharing and Chat settings, and the owner can preview that; tags fold like sections.+async function rowLinksFlow(browser) {+  section = 'row links';+  const T = (id, name, ph, extra) => ({ type: 'task', id, name, start: '2026-10-05', end: '2026-10-09', phase: ph, owner: 'Drew', ...extra });+  const tags = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliet'];+  const X = await startServer({ project: { title: 'Links Test', appName: 'links-test' }, phases: [{ name: 'Build', start: '2026-10', end: '2026-11' }, { name: 'Ship', start: '2026-10', end: '2026-11' }],+    items: [T('l1', 'Link task', 0, { tags: tags.slice(0, 5) }), T('l2', 'Far task', 1, { tags: tags.slice(5) }), { type: 'milestone', name: 'Link gate', date: '2026-10-20', phase: 1 }] }, 'links');+  const plan = await savedPlan(X.url, { minWait: 200 });+  const u1 = plan.items.find(r => r.id === 'l1').uid, u2 = plan.items.find(r => r.id === 'l2').uid;+  const base = X.url.replace(/\/$/, '') + '/';+  try { await browser.defaultBrowserContext().overridePermissions(X.url.replace(/\/$/, ''), ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); } catch (e) {}+  const page = await openPage(browser, X.url);+  const last = () => page.evaluate(() => window.__lastRowLink || '');+  const want = (u, n) => `#${u} ${n}: ${base}#row=${u}`;+  // Chart: the link button on the row.+  const vis = await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Link task')); const b = e && e.querySelector('.row-link'); if (!b) return null; const r = b.getBoundingClientRect(); return { w: r.width, op: +getComputedStyle(b).opacity }; });+  check('every chart row shows a copy-link button without hovering', !!vis && vis.w > 0 && vis.op > 0.3, JSON.stringify(vis));+  await page.evaluate(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes('Link task')); e.querySelector('.row-link').dataset.t = '1'; });+  await page.click('.row-link[data-t="1"]'); await settle(page, 300);+  check('the chart button copies "#ID Name: link"', (await last()) === want(u1, 'Link task'), await last());+  check('and does not open the editor or rename the row', !(await page.$('.editor.show')) && !(await page.$('.label-row input')));+  // Table: the ID column.+  await view(page, 'table');+  const idTxt = await page.$eval('#tableView tr[data-key="t:l2"] .uid-link', b => b.textContent.trim());+  check('the table has an ID column showing each row\'s permanent ID', idTxt === '#' + u2, idTxt);+  await page.click('#tableView tr[data-key="t:l2"] .uid-link'); await settle(page, 300);+  check('clicking the ID copies the link to that row', (await last()) === want(u2, 'Far task') && !(await page.$('.cell-edit')), await last());+  // The editor.+  await view(page, 'gantt'); await openItemEditor(page, 'Link task');+  check('the item editor has Copy link #ID', await page.$eval('#imCopyLink', b => b.textContent) === 'Copy link #' + u1);+  await page.click('#imCopyLink'); await settle(page, 300);+  check('and it copies the same link, leaving the editor open', (await last()) === want(u1, 'Link task') && !!(await page.$('.editor.show')));+  await page.keyboard.press('Escape'); await settle(page, 200);+  // Opening a link: a collapsed section opens, the row is selected and the fragment is consumed.+  await page.goto(base + '#row=' + u2); await ready(page); await settle(page, 600);+  const opened = await page.evaluate(() => ({ sel: !!document.querySelector('.bar.selected'), hash: location.hash, flash: document.getElementById('saveInd').textContent }));+  check('opening a link selects that row and says which', opened.sel && opened.flash.includes('#' + u2 + ' Far task'), JSON.stringify(opened));+  check('and the link is consumed, so a reload does not jump again', !/row=/.test(opened.hash), opened.hash);+  await view(page, 'table');+  await page.evaluate(u => { location.hash = '#row=' + u; }, u1); await settle(page, 600);+  check('a link pasted into an open table tab focuses that row', await page.evaluate(() => { const t = document.querySelector('#tableView tr.row-focus'); return t ? t.dataset.key : ''; }) === 't:l1');+  await page.evaluate(() => { location.hash = '#row=99999'; }); await settle(page, 400);+  check('a link to a row that is gone says so', /no row #99999/.test(await page.$eval('#saveInd', e => e.textContent)));+  // Tags fold: ten tags start folded; a chosen tag stays out.+  await view(page, 'gantt');+  const legend = () => page.evaluate(() => ({ chips: [...document.querySelectorAll('#legend .tag-chip')].map(c => c.dataset.tag), toggle: (document.getElementById('legendTags') || {}).textContent || '' }));+  let lg = await legend();+  check('with more than eight tags the legend folds them behind one chip', lg.chips.length === 0 && /10 tags/.test(lg.toggle), JSON.stringify(lg));+  await page.click('#legendTags'); await settle(page, 200);+  lg = await legend();+  check('the chip unfolds them', lg.chips.length === 10 && /Fewer tags/.test(lg.toggle), JSON.stringify(lg));+  await page.click('.tag-chip[data-tag="golf"]'); await settle(page, 200); await page.click('#legendTags'); await settle(page, 200);+  lg = await legend();+  check('folded again, a chosen tag keeps its chip and the fold says so', lg.chips.join() === 'golf' && /1 on/.test(lg.toggle), JSON.stringify(lg));+  await page.click('.tag-chip[data-tag="golf"]'); await settle(page, 200);+  // The owner previews the team's view.+  await view(page, 'setup');+  check('the owner sees the Sharing and Chat cards and the Analytics lens', await page.evaluate(() => !!document.querySelector('#suShare').offsetHeight && !!document.querySelector('#suChat').offsetHeight && !!document.querySelector('.vt-btn[data-view="analytics"]').offsetHeight));+  await page.click('#sharePreview'); await settle(page, 300);+  const pv = await page.evaluate(() => ({ role: document.documentElement.dataset.role, an: !!document.querySelector('.vt-btn[data-view="analytics"]').offsetHeight, banner: !!document.getElementById('memberPreview'), view: document.documentElement.dataset.view }));+  check('"See what the team sees" hides Analytics, shows a banner and goes to the chart', pv.role === 'member' && !pv.an && pv.banner && pv.view === 'gantt', JSON.stringify(pv));+  await view(page, 'setup');+  check('in the team view, Setup keeps sections and tags but not Sharing or Chat', await page.evaluate(() => !document.querySelector('#suShare').offsetHeight && !document.querySelector('#suChat').offsetHeight && !!document.querySelector('#suSections').offsetHeight));+  await page.click('#previewBack'); await settle(page, 300);+  check('"Back to my view" restores everything', await page.evaluate(() => document.documentElement.dataset.role === 'owner' && !document.getElementById('memberPreview') && !!document.querySelector('#suShare').offsetHeight));+  await page.close();+  // A real team member (the server says this viewer cannot change the plan's settings).+  const m = await openPage(browser, X.url, { clear: false });+  await m.setRequestInterception(true);+  m.on('request', req => { if (/\/access$/.test(new URL(req.url()).pathname) && req.method() === 'GET') req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, mode: 'org', org: 'adom', owner: { username: 'drew2' }, you: { username: 'ana' }, youAreOwner: false, canChange: false }) }); else req.continue(); });+  await m.reload(); await ready(m); await settle(m, 400);+  check('a team member has no Analytics lens and no banner', await m.evaluate(() => document.documentElement.dataset.role === 'member' && !document.querySelector('.vt-btn[data-view="analytics"]').offsetHeight && !document.getElementById('memberPreview')));+  await m.close();+} // 0.72.0: typing in a list HIGHLIGHTS and never picks; letters typed together build one // search; the arrows move the mark; only Enter or a click picks. In every list. async function typeAheadFlow(browser) {