app
Adom Gantt
Public Made by Adomby adom
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, push
← Commit history
0.15.0: fix the stray/lagging drop indicator, add a hover Edit button, resizable table columns
The stray teal fragment and the lagging line were one bug. The drop indicator is
absolutely positioned with left:0;right:0 and .labels-col had no position:
relative, so it laid out against a container further up the tree: it spanned past
the label column, and its top was measured in a different coordinate space than
the cursor maths that placed it, which read as lag. One declaration fixes both.
Measured off-by-0 from the cursor-nearest slot boundary at scroll 0, 300 and 700
and at 150% text scale, with the line inside the column (271px against 272px).
Cleanup got a choke point: hideDropIndicators() runs on drop, on Escape, when a
drag is over no valid slot, and on a mouseup safety net. The chart's task reorder
also gained an Escape path, which it never had.
The double-click upgrade was unreachable because the single click mounts the name
editor instantly, which is the behaviour the user wants kept. Each task and
milestone row now carries an Edit button at its right edge, visible only under
the cursor, absolutely positioned so it cannot move the name or change the row
height. The dblclick path stays as a shortcut. Group rows get no Edit button: a
lane header's only field is its name, and that name is the phase.
Table columns are draggable, double-click fits the widest value, widths persist.
The first resize snapshots current widths and switches to fixed layout, because
auto layout treats a width as a suggestion and would shove the neighbours.
Truncation then follows the column instead of a hardcoded cap.
6 files changed
+223−12
README.md+4@@ -10,6 +10,10 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Four statuses.** Not started, in progress, blocked, complete. Plans written against the older "live" and "planned" still read correctly without being changed. +**Edit any row.** Hover a row in the chart's label column and an Edit button appears at its right edge: one click opens the full editor for every field. Single-clicking the name still edits it in place.++**Resizable table columns.** Drag the divider between any two headers, or double-click it to fit the widest value. Widths persist.+ **Collapse a lane.** The chevron at the right of each phase header hides that lane's rows and shows how many. Collapsing hides rows, not time: the axis does not move and Fit still fits the whole plan. This is your view only, it is not part of the plan. **Rename in place.** Click a name in the chart's label column to edit it right there; double-click for the full editor with every field. Drag a task across a lane boundary and it changes lane, one undo for the whole move.
SKILL.md+4@@ -228,6 +228,10 @@ named in its own column. | Notes | `description`, truncated | same | | Provenance | `provenance` as a chip, linked when the url is http(s) | same | +**Full editor.** Hovering a task or milestone row in the chart's label column reveals an Edit button that opens every field. Group rows have a collapse chevron instead. The table intentionally has no modal: its cells are all editable in place.++**Table column widths** are draggable and persist in `prefs.tableCols`; the table switches to fixed layout once any width is user-set.+ **Collapse.** Each phase header carries a chevron and a row count. Collapsed lanes are view state in `localStorage` (`prefs.collapsed`), not plan state, so they are per-person and never written to the data file or the sidecar. Collapsing hides rows only: the date axis and Fit are unaffected. **Label editing.** A single click on a chart label edits the name inline; a double-click opens the full item editor. Dragging a task across a group header changes its phase.
docs/BUILD-NOTES.md+43@@ -3,6 +3,49 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.15.0)++**The stray teal line and the lagging line were the same bug.** The drop+indicator is absolutely positioned with `left:0;right:0`, and `.labels-col` had+no `position: relative`, so it was laid out against a container further up the+tree. That is why it spanned far past the label column as a stray fragment, and+why its top was measured in a different coordinate space than the cursor maths+that placed it, which read as lag. One declaration fixes both: the rows and the+indicator now share the label column as their offset parent. Measured off-by-0+from the cursor-nearest slot boundary at scroll 0, 300 and 700, and at 150% text+scale, with the line always inside the column (271px wide against a 272px column,+where before it ran across the app).++Cleanup got its own choke point. `hideDropIndicators()` is called from the drop,+from Escape, from a drag that ends over no valid slot, and from a safety net on+mouseup. The chart's task reorder also gained an Escape path, which it never had:+it could only be ended by releasing the mouse.++**Edit button instead of the double-click.** The double-click upgrade was+unreachable in practice because the single click mounts the name editor instantly,+which is the behaviour the user wants kept. So each task and milestone row now+carries a small Edit button at its right edge, visible only under the cursor,+absolutely positioned so it cannot move the name text or change the row height+(asserted). The dblclick path is kept because it costs nothing and is now a+shortcut rather than the only way in.++Group rows deliberately get no Edit button. A lane header's only field is its+name, and that name is the phase, which is a different thing to edit than an item.+They keep their chevron.++**Resizable table columns.** Same idiom as the name-column divider: drag to+resize, double-click to fit the widest value, widths in prefs. The first resize+snapshots every column's current width and switches the table to fixed layout,+because auto layout treats a width as a suggestion and re-solves the whole row,+so dragging one column would shove its neighbours. Fixed layout makes the number+you dragged to the number you get.++Truncation follows the column once widths are user-set: the `.trunc` spans drop+their fixed px cap for `max-width: 100%`, so Notes and Provenance ellipsis at+whatever width the user chose rather than at a hardcoded one. Minimum 56px,+maximum 720px, and double-click-to-fit clamps to that maximum, which the Notes+column reaches.+ ## Build notes, 2026-08-07 (0.14.0) **The label column edits the name; the bar still owns the schedule.** A single
gantt.html+169−9@@ -186,7 +186,13 @@ header #btnTheme { min-width: 56px; justify-content: center; } /* ── Chart ── */ .chart-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }-.labels-col { width: var(--label-w, clamp(150px, 24vw, 272px)); flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); }+/* position:relative matters more than it looks. The drop indicator is absolutely+ positioned with left:0;right:0, and without a positioned ancestor here it was+ laid out against a container further up: it spanned far past the label column+ (a stray teal line across the app) and its top was measured in a different+ coordinate space than the cursor maths, which is what made it lag the pointer.+ Now the rows and the indicator share this element as their offset parent. */+.labels-col { position: relative; width: var(--label-w, clamp(150px, 24vw, 272px)); flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); } /* Drag handle sitting on the divider. Wider than the 1px border so it is grabbable, and it keeps its own cursor so the row underneath still behaves normally. */ .col-resizer { width: 7px; margin-left: -4px; margin-right: -3px; flex-shrink: 0; cursor: col-resize; position: relative; z-index: 30; }@@ -471,6 +477,24 @@ a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08 name column never resizes, same rule the table cells follow. */ .label-edit { position: absolute; inset: 3px 6px; width: auto; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); padding: 0 6px; outline: none; z-index: 12; box-sizing: border-box; } .label-row { position: relative; }+/* Appears only under the cursor, absolutely positioned so it never moves the+ 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; }+.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); }+.label-row .row-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 22px; }+/* ---- resizable table columns ---- */+.data-table th { position: relative; }+.col-grip { position: absolute; top: 0; right: -3px; width: 7px; height: 100%; cursor: col-resize; z-index: 3; }+.col-grip::after { content: ''; position: absolute; inset: 4px 3px; background: transparent; transition: background 0.15s; }+.col-grip:hover::after, .col-grip.dragging::after { background: var(--accent); }+body.col-sizing { cursor: col-resize; user-select: none; }+table.data-table.fixed-cols { table-layout: fixed; }+/* Under user-set widths the column decides the truncation, not a fixed px cap. */+.data-table.fixed-cols .trunc { max-width: 100%; }+.data-table.fixed-cols td, .data-table.fixed-cols th { overflow: hidden; } .drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; } .drop-indicator.show { display: block; }@@ -1147,6 +1171,12 @@ document.addEventListener('keydown',e=>{ if(e.key!=='Escape')return; // Innermost first. A native select or datalist popup swallows its own Escape // before this ever runs, which is exactly the first rung of the stack.+ if(reorderState&&reorderState.active){+ reorderState.moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);+ if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});+ hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;didDrag=true;+ return;+ } if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; } if(tblEditing){ tblEditing.cancel(); return; } if(editorEl.classList.contains('show')){ closeEditor(); return; }@@ -1487,6 +1517,22 @@ depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.class +// The row's own way into the full editor. A single click on the label edits the+// name (which the user wanted kept), so the modal needed a target of its own+// 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.+function makeEditBtn(r){+ const b=document.createElement('button');+ b.type='button';b.className='row-edit';b.title='Edit all fields';+ b.setAttribute('aria-label','Edit all fields');+ 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="M4 20h4L19 9a2.1 2.1 0 0 0-3-3L5 17v3z"/></svg>';+ b.addEventListener('mousedown',e=>e.stopPropagation());+ b.addEventListener('dblclick',e=>e.stopPropagation());+ b.addEventListener('click',e=>{e.stopPropagation();e.preventDefault();hideTip();openItemModal(r);});+ return b;+}+ // Extracted from the initial build loop so a row created at runtime is built by // exactly the same code as a row from the data file, rather than a second // almost-identical path that drifts.@@ -1502,7 +1548,12 @@ function buildRowDom(r,idx){ bDiv.className='bar-row group'; } else if(r.type==='milestone'){- lDiv.className='label-row ms';lDiv.textContent=r.name;bDiv.className='bar-row ms-row';+ lDiv.className='label-row ms';+ lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));+ lDiv.appendChild(makeEditBtn(r));+ r._nameSpan=lDiv.querySelector('.row-name');+ r._nameSpan.textContent=r.name;+ bDiv.className='bar-row ms-row'; const x=toX(r.date); const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px'; hit.dataset.msi=String(items.filter(q=>q.type==='milestone').indexOf(r));@@ -1519,7 +1570,13 @@ function buildRowDom(r,idx){ ln.appendChild(cap);msLineEls.push(ln); r._msEl=d;r._msHit=hit;r._msLine=ln; }- else{lDiv.className='label-row';lDiv.dataset.id=r.id;const sk=statusOf(r);const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:'';lDiv.innerHTML=esc(r.name)+tg;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.id=r.id;b.dataset.idx=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));+ r._nameSpan=lDiv.querySelector('.row-name');+ const sk=statusOf(r);const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:'';+ r._nameSpan.innerHTML=esc(r.name)+tg;+ 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.id=r.id;b.dataset.idx=idx; const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt; b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};} r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);@@ -1733,7 +1790,7 @@ function updateSelCount(){if(selected.size>1){selCountEl.textContent=`${selected // Multi-select modifiers keep their old meaning and never start an edit. labelsEl.addEventListener('click',e=>{ if(reorderState||laneDrag)return;- if(e.target.closest('.lane-toggle'))return;+ if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit'))return; const row=e.target.closest('.label-row[data-id], .label-row.ms'); if(!row)return; const r=items.find(x=>x._lDiv===row);@@ -1803,6 +1860,13 @@ let reorderState=null, gesture=null; const labelDropInd=document.createElement('div');labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd); const barDropInd=document.createElement('div');barDropInd.className='drop-indicator';bars.appendChild(barDropInd); function rowYCenter(el){return el.offsetTop+el.offsetHeight/2;}+// One place to put the indicators away, called from every exit: drop, Escape,+// a drag that ends outside any list, and the safety net on mouseup. An indicator+// left showing is a promise about a drop that is not going to happen.+function hideDropIndicators(){+ labelDropInd.classList.remove('show');+ barDropInd.classList.remove('show');+} labelsEl.addEventListener('mousedown',e=>{ const row=e.target.closest('.label-row[data-id]');@@ -1957,7 +2021,8 @@ function snapshotMs(r){ // dashed rule's tag all restate the milestone, so they move together. function refreshMsChrome(r){ if(!r)return;- if(r._lDiv)r._lDiv.textContent=r.name;+ if(r._nameSpan)r._nameSpan.textContent=r.name;+ else if(r._lDiv)r._lDiv.textContent=r.name; if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);} if(r._msLine){const cap=r._msLine.querySelector('.ms-cap'); if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);}}@@ -2080,20 +2145,27 @@ document.addEventListener('mousemove',e=>{ } if(targetIdx>=0){ const ref=items[targetIdx];+ // Both columns are measured against their own offset parent, and the cursor+ // was converted into that same space above, so the line sits on the slot+ // boundary the pointer is actually nearest. labelDropInd.style.top=(insertBefore?ref._lDiv.offsetTop:ref._lDiv.offsetTop+ref._lDiv.offsetHeight)+'px';labelDropInd.classList.add('show'); barDropInd.style.top=(insertBefore?ref._bDiv.offsetTop:ref._bDiv.offsetTop+ref._bDiv.offsetHeight)+'px';barDropInd.classList.add('show'); reorderState.targetIdx=targetIdx;reorderState.insertBefore=insertBefore;+ } else {+ hideDropIndicators();+ reorderState.targetIdx=null; } } }); document.addEventListener('mouseup',()=>{ gesture=null;+ if(!reorderState&&!laneDrag)hideDropIndicators(); if(dragState){scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));dragState=null;document.body.style.cursor='';} if(reorderState&&reorderState.active){- labelDropInd.classList.remove('show');barDropInd.classList.remove('show');+ hideDropIndicators(); reorderState.moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}}); document.body.style.cursor=''; if(reorderState.targetIdx!=null){@@ -2791,7 +2863,9 @@ function refreshLabelRow(r){ if(!r||!r._lDiv||!r.id)return; const sk=statusOf(r); const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:'';- r._lDiv.innerHTML=esc(r.name)+tg;+ // into the name span only: rewriting the row would take the edit button with it+ if(r._nameSpan)r._nameSpan.innerHTML=esc(r.name)+tg;+ else r._lDiv.innerHTML=esc(r.name)+tg; } function distinctOwners(){@@ -2879,6 +2953,7 @@ function renderTable(){ syncOwnerControls(); syncSortHeaders(); syncSortLock();+ applyColWidths(); const rows=tableRows(); const total=items.filter(r=>!isGroup(r)).length; countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;@@ -2937,7 +3012,7 @@ function endLaneDrag(commit){ const d=laneDrag; laneDrag=null; restoreTransientCollapse(); document.body.classList.remove('row-dragging');- labelDropInd.classList.remove('show');barDropInd.classList.remove('show');+ hideDropIndicators(); (d.block?d.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.remove('dragging');if(r._bDiv)r._bDiv.classList.remove('dragging');}); if(!commit||!d.armed||d.targetGroup==null||d.targetGroup===d.g)return; const blk=laneBlock(d.g); if(!blk)return;@@ -3000,7 +3075,7 @@ document.addEventListener('mousemove',e=>{ target=g;before=false; } laneDrag.targetGroup=target;laneDrag.before=before;- if(!target){labelDropInd.classList.remove('show');barDropInd.classList.remove('show');return;}+ if(!target){hideDropIndicators();return;} const blk=laneBlock(target); const anchor=before?target:blk.rows[blk.rows.length-1]; const top=before?anchor._lDiv.offsetTop:anchor._lDiv.offsetTop+anchor._lDiv.offsetHeight;@@ -3011,6 +3086,91 @@ document.addEventListener('mousemove',e=>{ document.addEventListener('mouseup',()=>endLaneDrag(true)); document.addEventListener('keydown',e=>{if(e.key==='Escape'&&laneDrag)endLaneDrag(false);},true); +// ── Resizable table columns ──+//+// Same idiom as the name-column divider: drag to resize, double-click to fit the+// longest content. Widths persist in prefs, because a column width is how one+// person reads the table rather than a fact about the plan.+//+// The first resize snapshots every column's current width and switches the table+// to fixed layout. Auto layout treats a width as a suggestion and re-solves the+// whole row, so one column being dragged would shove its neighbours around;+// fixed layout makes the number you dragged to the number you get.+const COL_MIN=56;+function colKeys(){ return [...tableHead.querySelectorAll('th')].map(th=>th.dataset.sort||'del'); }+function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).length>0; }+function applyColWidths(){+ const table=tableHead.closest('table');+ if(!table)return;+ const on=fixedCols();+ table.classList.toggle('fixed-cols',on);+ if(!on){ tableHead.querySelectorAll('th').forEach(th=>{th.style.width='';}); return; }+ const w=prefs.tableCols||{};+ tableHead.querySelectorAll('th').forEach(th=>{+ const k=th.dataset.sort||'del';+ if(w[k])th.style.width=w[k]+'px';+ });+}+function snapshotColWidths(){+ if(fixedCols())return;+ const w={};+ tableHead.querySelectorAll('th').forEach(th=>{+ w[th.dataset.sort||'del']=Math.round(th.getBoundingClientRect().width);+ });+ prefs.tableCols=w;+}+// Measure the widest rendered cell in a column, the way the name column's+// double-click already does, using the column's own font so the number is real.+function fitColumn(key){+ const idx=colKeys().indexOf(key);+ if(idx<0)return;+ const probe=document.createElement('span');+ const sample=tableBody.querySelector('tr td:nth-child('+(idx+1)+')');+ const cs=sample?getComputedStyle(sample):null;+ probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;'++ (cs?`font:${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize}/1 ${cs.fontFamily};`:'');+ document.body.appendChild(probe);+ let max=0;+ probe.textContent=tableHead.querySelectorAll('th')[idx].textContent;+ max=probe.offsetWidth;+ tableBody.querySelectorAll('tr td:nth-child('+(idx+1)+')').forEach(td=>{+ probe.textContent=td.textContent;+ max=Math.max(max,probe.offsetWidth);+ });+ probe.remove();+ snapshotColWidths();+ prefs.tableCols[key]=Math.max(COL_MIN,Math.min(720,Math.round(max)+28));+ savePrefs();applyColWidths();+}+(function(){+ let grip=null;+ tableHead.querySelectorAll('th').forEach((th,i,all)=>{+ if(i===all.length-1)return; // nothing to drag past the last edge+ const g=document.createElement('div');+ g.className='col-grip';+ g.title='Drag to resize this column. Double-click to fit the widest value.';+ g.addEventListener('mousedown',e=>{+ e.preventDefault();e.stopPropagation();+ snapshotColWidths();+ grip={key:th.dataset.sort||'del',x:e.clientX,w:Math.round(th.getBoundingClientRect().width),el:g};+ g.classList.add('dragging');document.body.classList.add('col-sizing');+ });+ g.addEventListener('click',e=>e.stopPropagation()); // never sorts+ g.addEventListener('dblclick',e=>{e.preventDefault();e.stopPropagation();fitColumn(th.dataset.sort||'del');});+ th.appendChild(g);+ });+ document.addEventListener('mousemove',e=>{+ if(!grip)return;+ prefs.tableCols[grip.key]=Math.max(COL_MIN,Math.min(720,grip.w+(e.clientX-grip.x)));+ applyColWidths();+ });+ document.addEventListener('mouseup',()=>{+ if(!grip)return;+ grip.el.classList.remove('dragging');document.body.classList.remove('col-sizing');+ grip=null;document.body.classList.remove('col-sizing');savePrefs();+ });+})();+ // ── Drag a row to reorder it ── // // Arming is a 350ms click-and-hold. Everything the table already does on click
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.14.1",+ "version": "0.15.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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+2−2@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.14.1",+ "version": "0.15.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view 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. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. 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": {@@ -63,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}