Просмотр исходного кода

Task title row: inline reference icons + per-row info popover

Replace the description-only marker with a circled-i info trigger that
opens a body-attached popover (right of the icon, vertically centred)
listing task title, description, URL link, and linked-task references.
Render the Phase-22 "Copied from / Copied to" chips inline as small
arrow icons whose hover title carries the full label. Title cell is
now a three-column grid so info icons line up across rows.

Closing model matches the Phase-18 cell popover: outside-pointerdown,
Escape, scroll/resize, focus loss, and a 250 ms mouseleave grace.
Pure twig + JS + CSS — no PHP / endpoint / migration changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 2 дней назад
Родитель
Сommit
8c72e7a231
4 измененных файлов с 401 добавлено и 107 удалено
  1. 28 1
      CHANGELOG.md
  2. 100 7
      assets/css/input.css
  3. 225 64
      public/assets/js/sprint-planner.js
  4. 48 35
      views/sprints/_task_list.twig

+ 28 - 1
CHANGELOG.md

@@ -6,7 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
-Nothing scheduled.
+### Changed
+
+- **Task title row: inline reference icons + per-row info popover.** The
+  task table now lays out the title cell as a three-column grid
+  (`title input | URL + ref icons | info icon`) so the info icon ends up
+  at the same horizontal offset across every row, regardless of how
+  many `linked_task_id` references the row has or whether a task URL is
+  set. The bidirectional "Copied from / Copied to: X (Sprint Y)" chips
+  introduced in Phase 22 are now rendered inline as small ←/→ arrow
+  icons that anchor to the linked sprint; the full chip text is kept
+  on the hover `title=` (and `aria-label`). A new gray/white circled-i
+  trigger replaces the old book-shaped description marker — clicking
+  it opens a body-attached popover positioned to the right of the icon
+  and vertically centred on it, listing the task title, full
+  description, the URL as a clickable link, and the linked-task list.
+  The popover closes on outside-pointerdown, Escape, scroll/resize,
+  focus loss, and a 250 ms mouseleave grace — same model as the
+  Phase 18 cell popover. Touchpoints: `views/sprints/_task_list.twig`,
+  `public/assets/js/sprint-planner.js` (`renderTaskRefs`,
+  `buildInfoPopover` / `openInfoPopover` / `closeInfoPopover` —
+  replaces the `descPopover` block; `buildTaskRow` mirrors the new
+  DOM shape so the JS-built "+ Add task" path stays in sync), and
+  `assets/css/input.css` (`.task-title-grid`, `.task-title-mid`,
+  `.task-info-popover`, retires the unused `.task-desc-popover`).
+  No PHP / migration / endpoint changes — the existing `data-description`,
+  `data-url`, and `linkedMap` data still drive the row; the new
+  `data-task-title` and `data-links` row attributes feed the popover
+  for newly added rows without an extra round-trip.
 
 ## [0.23.0] — 2026-05-07
 

+ 100 - 7
assets/css/input.css

@@ -342,22 +342,115 @@
         box-shadow: 0 0 0 1px rgb(255 255 255 / 0.5);
     }
 
-    .task-desc-popover {
+    /* Title cell layout — three columns so the info icon ends up at the
+       same horizontal offset across rows, regardless of how many ref
+       icons or whether the URL icon are present. The middle column is
+       fixed-width and clips overflow so its width never bleeds into the
+       info-icon column. */
+    .task-title-grid {
+        display: grid;
+        grid-template-columns: minmax(0, 1fr) 4.25rem auto;
+        gap: 0.375rem;
+        align-items: center;
+    }
+    .task-title-mid {
+        display: flex;
+        align-items: center;
+        gap: 0.125rem;
+        justify-content: flex-end;
+        overflow: hidden;
+        min-width: 0;
+    }
+    .task-refs {
+        flex-wrap: nowrap;
+        overflow: hidden;
+    }
+
+    /* The reference / info icons stay quiet (slate-400) until hovered.
+       Matches the existing URL-link icon weight. */
+    .task-info-trigger:focus-visible,
+    .task-ref-icon:focus-visible {
+        outline: 2px solid theme('colors.slate.400');
+        outline-offset: 1px;
+    }
+
+    /* Info popover — body-attached, opens to the right of the trigger,
+       vertically centred on it (positioning set by JS). Mirrors the
+       cellPopover close model: outside-click, Escape, scroll/resize,
+       and mouseleave-grace 250 ms. */
+    .task-info-popover {
         position: absolute;
         z-index: 55;
-        max-width: 24rem;
-        padding: 0.5rem 0.75rem;
-        border-radius: 0.4rem;
+        width: 22rem;
+        max-width: calc(100vw - 1rem);
+        padding: 0.75rem 0.875rem;
+        border-radius: 0.5rem;
         border: 1px solid theme('colors.slate.200');
         background-color: theme('colors.white');
         color: theme('colors.slate.700');
         font-size: 0.825rem;
-        white-space: pre-wrap;
-        box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
+        box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.12), 0 4px 6px -4px rgb(0 0 0 / 0.1);
     }
-    .dark .task-desc-popover {
+    .dark .task-info-popover {
         border-color: theme('colors.slate.700');
         background-color: theme('colors.slate.800');
         color: theme('colors.slate.100');
     }
+    .task-info-title {
+        font-weight: 600;
+        font-size: 0.9rem;
+        color: theme('colors.slate.800');
+        margin-bottom: 0.4rem;
+        word-break: break-word;
+    }
+    .dark .task-info-title {
+        color: theme('colors.slate.100');
+    }
+    .task-info-desc {
+        white-space: pre-wrap;
+        word-break: break-word;
+        max-height: 12rem;
+        overflow-y: auto;
+        margin-bottom: 0.5rem;
+    }
+    .task-info-url {
+        margin-bottom: 0.5rem;
+        font-size: 0.8rem;
+        word-break: break-all;
+    }
+    .task-info-url a {
+        color: theme('colors.blue.700');
+        text-decoration: underline;
+    }
+    .dark .task-info-url a {
+        color: theme('colors.blue.300');
+    }
+    .task-info-refs-head {
+        font-size: 0.7rem;
+        text-transform: uppercase;
+        letter-spacing: 0.05em;
+        color: theme('colors.slate.500');
+        margin-bottom: 0.25rem;
+    }
+    .dark .task-info-refs-head {
+        color: theme('colors.slate.400');
+    }
+    .task-info-refs {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 0.15rem;
+    }
+    .task-info-refs a {
+        color: theme('colors.slate.700');
+        text-decoration: none;
+    }
+    .task-info-refs a:hover {
+        text-decoration: underline;
+    }
+    .dark .task-info-refs a {
+        color: theme('colors.slate.200');
+    }
 }

+ 225 - 64
public/assets/js/sprint-planner.js

@@ -356,6 +356,41 @@
         return out;
     }
 
+    // Render the inline reference icons (one per linked task) into `host`.
+    // Each link gets a small arrow icon whose hover title carries the
+    // "Copied from / Copied to: <title> (<sprint>)" detail. The icon
+    // anchors to the linked sprint page.
+    function renderTaskRefs(host, links) {
+        host.innerHTML = '';
+        if (!Array.isArray(links) || links.length === 0) {
+            host.classList.add('hidden');
+            return;
+        }
+        host.classList.remove('hidden');
+        links.forEach(function (l) {
+            if (!l || typeof l !== 'object') { return; }
+            const dir = l.direction === 'source' ? 'source' : 'copy';
+            const a = document.createElement('a');
+            a.href = '/sprints/' + l.sprint_id;
+            a.className = 'task-ref-icon inline-flex items-center justify-center w-5 h-5 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700';
+            const label = (dir === 'source' ? 'Copied from' : 'Copied to')
+                + ': ' + (l.title || '') + ' (' + (l.sprint_name || '') + ')';
+            a.title = label;
+            a.setAttribute('aria-label', label);
+            const svg = dir === 'source'
+                ? '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
+                    + '<path d="M10 4L4 8l6 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
+                    + '<line x1="4" y1="8" x2="13" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>'
+                    + '</svg>'
+                : '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
+                    + '<path d="M6 4l6 4-6 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
+                    + '<line x1="3" y1="8" x2="12" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>'
+                    + '</svg>';
+            a.innerHTML = svg;
+            host.appendChild(a);
+        });
+    }
+
     // Build a task <tr> from an object — vanilla JS DOM construction.
     function buildTaskRow(task, assignments) {
         assignments = assignments || {};
@@ -368,6 +403,8 @@
         tr.setAttribute('data-sort-order', String(task.sort_order));
         tr.setAttribute('data-description', task.description || '');
         tr.setAttribute('data-url',         task.url || '');
+        tr.setAttribute('data-task-title',  task.title || '');
+        tr.setAttribute('data-links',       JSON.stringify(Array.isArray(task.links) ? task.links : []));
 
         // hamburger trigger
         const tdMenu = document.createElement('td');
@@ -387,22 +424,25 @@
         tdMenu.appendChild(trig);
         tr.appendChild(tdMenu);
 
-        // title cell with URL + description affordances
+        // title cell — grid layout: [title input] [URL + ref icons] [info icon]
         const tdTitle = document.createElement('td');
         tdTitle.className = 'px-2 py-1 min-w-[14rem]';
         const titleWrap = document.createElement('div');
-        titleWrap.className = 'flex items-center gap-1.5';
+        titleWrap.className = 'task-title-grid';
         const title = document.createElement('input');
         title.type = 'text';
         title.setAttribute('data-title', '');
         title.value = task.title || '';
-        title.className = 'flex-1 rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
+        title.className = 'rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500 min-w-0';
         titleWrap.appendChild(title);
 
+        const midSlot = document.createElement('div');
+        midSlot.className = 'task-title-mid';
+
         const urlLink = document.createElement('a');
         urlLink.setAttribute('data-task-url-link', '');
         urlLink.target = '_blank';
-        urlLink.rel = 'noopener';
+        urlLink.rel = 'noopener noreferrer';
         urlLink.href = task.url || '';
         urlLink.title = 'Open task link';
         urlLink.setAttribute('aria-label', 'Open task link');
@@ -413,21 +453,32 @@
             + '<path d="M14 2L7 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
             + '<path d="M12 9v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
             + '</svg>';
-        titleWrap.appendChild(urlLink);
-
-        const descBtn = document.createElement('button');
-        descBtn.type = 'button';
-        descBtn.setAttribute('data-task-desc-trigger', '');
-        descBtn.className = 'task-desc-trigger inline-flex items-center justify-center w-5 h-5 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700';
-        descBtn.title = 'View description';
-        descBtn.setAttribute('aria-label', 'View description');
-        if (!task.description) { descBtn.classList.add('hidden'); }
-        descBtn.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
-            + '<rect x="2" y="3" width="12" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>'
-            + '<line x1="4" y1="6"  x2="12" y2="6"  stroke="currentColor" stroke-width="1.5"/>'
-            + '<line x1="4" y1="9"  x2="10" y2="9"  stroke="currentColor" stroke-width="1.5"/>'
+        midSlot.appendChild(urlLink);
+
+        const refsWrap = document.createElement('span');
+        refsWrap.setAttribute('data-task-refs', '');
+        refsWrap.className = 'task-refs inline-flex items-center gap-0.5';
+        renderTaskRefs(refsWrap, Array.isArray(task.links) ? task.links : []);
+        midSlot.appendChild(refsWrap);
+
+        titleWrap.appendChild(midSlot);
+
+        const infoBtn = document.createElement('button');
+        infoBtn.type = 'button';
+        infoBtn.setAttribute('data-task-info-trigger', '');
+        infoBtn.className = 'task-info-trigger inline-flex items-center justify-center w-5 h-5 rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-700 dark:hover:text-slate-200';
+        infoBtn.title = 'Task info';
+        infoBtn.setAttribute('aria-label', 'Task info');
+        infoBtn.setAttribute('aria-haspopup', 'true');
+        infoBtn.setAttribute('aria-expanded', 'false');
+        const hasInfo = !!(task.description || task.url || (Array.isArray(task.links) && task.links.length > 0));
+        if (!hasInfo) { infoBtn.classList.add('hidden'); }
+        infoBtn.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">'
+            + '<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/>'
+            + '<circle cx="8" cy="4.6" r="0.85" fill="currentColor"/>'
+            + '<line x1="8" y1="7" x2="8" y2="11.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>'
             + '</svg>';
-        titleWrap.appendChild(descBtn);
+        titleWrap.appendChild(infoBtn);
 
         tdTitle.appendChild(titleWrap);
         tr.appendChild(tdTitle);
@@ -1222,13 +1273,18 @@
                     tr.setAttribute('data-description', desc);
                     tr.setAttribute('data-url', url);
                     const link    = qs('[data-task-url-link]', tr);
-                    const descBtn = qs('[data-task-desc-trigger]', tr);
+                    const infoBtn = qs('[data-task-info-trigger]', tr);
                     if (link) {
                         link.href = url;
                         link.classList.toggle('hidden', url === '');
                     }
-                    if (descBtn) {
-                        descBtn.classList.toggle('hidden', desc === '');
+                    if (infoBtn) {
+                        const refsRaw = tr.getAttribute('data-links') || '[]';
+                        let hasLinks = false;
+                        try { hasLinks = Array.isArray(JSON.parse(refsRaw)) && JSON.parse(refsRaw).length > 0; }
+                        catch (_) { hasLinks = false; }
+                        const showInfo = desc !== '' || url !== '' || hasLinks;
+                        infoBtn.classList.toggle('hidden', !showInfo);
                     }
                 }
                 closeDetailsModal();
@@ -1243,76 +1299,181 @@
         }
     });
 
-    // --- Description popover (click on the description marker) ----------
+    // --- Task info popover (click on the per-row info icon) -------------
     //
-    // Read-only popover so non-admins can see the description, and admins
-    // can peek without opening the full edit modal.
+    // Single body-attached panel that opens to the right of the trigger,
+    // vertically centred on it. Read-only — shows the task title,
+    // description, URL link, and linked-task references. Closing
+    // mirrors the cellPopover: outside-pointerdown, Escape, scroll /
+    // resize, and a 250 ms mouseleave grace.
 
-    let descPopover = null;
-    let descPopoverAnchor = null;
-    let descPopoverOpenAt = 0;
+    let infoPopover       = null;
+    let infoPopoverAnchor = null;
+    let infoPopoverGrace  = null;
+    let infoPopoverOpenAt = 0;
 
-    function buildDescPopover() {
-        if (descPopover) { return descPopover; }
+    function cancelInfoPopoverGrace() {
+        if (infoPopoverGrace) { clearTimeout(infoPopoverGrace); infoPopoverGrace = null; }
+    }
+    function scheduleInfoPopoverGrace() {
+        cancelInfoPopoverGrace();
+        infoPopoverGrace = setTimeout(closeInfoPopover, 250);
+    }
+
+    function buildInfoPopover() {
+        if (infoPopover) { return infoPopover; }
         const r = document.createElement('div');
-        r.className = 'task-desc-popover hidden';
-        r.setAttribute('role', 'tooltip');
+        r.className = 'task-info-popover hidden';
+        r.setAttribute('role', 'dialog');
+        r.setAttribute('aria-label', 'Task info');
         document.body.appendChild(r);
-        descPopover = r;
+        r.addEventListener('mouseenter', cancelInfoPopoverGrace);
+        r.addEventListener('mouseleave', scheduleInfoPopoverGrace);
+        infoPopover = r;
         return r;
     }
 
-    function openDescPopover(anchor, text) {
-        if (!text) { return; }
-        buildDescPopover();
-        descPopoverAnchor = anchor;
-        descPopover.textContent = text;
-        descPopover.classList.remove('hidden');
-        const rect = anchor.getBoundingClientRect();
-        const ph = descPopover.offsetHeight;
-        const pw = descPopover.offsetWidth;
+    function renderInfoPopoverContent(tr) {
+        if (!infoPopover) { return; }
+        const title = tr.getAttribute('data-task-title') || '';
+        const desc  = tr.getAttribute('data-description') || '';
+        const url   = tr.getAttribute('data-url') || '';
+        let links = [];
+        try {
+            const parsed = JSON.parse(tr.getAttribute('data-links') || '[]');
+            if (Array.isArray(parsed)) { links = parsed; }
+        } catch (_) { /* ignore */ }
+
+        infoPopover.innerHTML = '';
+
+        const head = document.createElement('div');
+        head.className = 'task-info-title';
+        head.textContent = title;
+        infoPopover.appendChild(head);
+
+        if (desc) {
+            const body = document.createElement('div');
+            body.className = 'task-info-desc';
+            body.textContent = desc;
+            infoPopover.appendChild(body);
+        }
+
+        if (url) {
+            const p = document.createElement('div');
+            p.className = 'task-info-url';
+            const a = document.createElement('a');
+            a.href = url;
+            a.target = '_blank';
+            a.rel = 'noopener noreferrer';
+            a.textContent = url;
+            p.appendChild(a);
+            infoPopover.appendChild(p);
+        }
+
+        if (links.length > 0) {
+            const refsHead = document.createElement('div');
+            refsHead.className = 'task-info-refs-head';
+            refsHead.textContent = 'Referenced tasks';
+            infoPopover.appendChild(refsHead);
+
+            const list = document.createElement('ul');
+            list.className = 'task-info-refs';
+            links.forEach(function (l) {
+                if (!l || typeof l !== 'object') { return; }
+                const li = document.createElement('li');
+                const dir = l.direction === 'source' ? 'Copied from' : 'Copied to';
+                const arr = l.direction === 'source' ? '←' : '→';
+                const a = document.createElement('a');
+                a.href = '/sprints/' + l.sprint_id;
+                a.textContent = arr + ' ' + (l.title || '') + ' (' + (l.sprint_name || '') + ')';
+                a.title = dir + ': ' + (l.title || '') + ' (' + (l.sprint_name || '') + ')';
+                li.appendChild(a);
+                list.appendChild(li);
+            });
+            infoPopover.appendChild(list);
+        }
+    }
+
+    function positionInfoPopover() {
+        if (!infoPopover || !infoPopoverAnchor) { return; }
+        const rect = infoPopoverAnchor.getBoundingClientRect();
+        const ph = infoPopover.offsetHeight;
+        const pw = infoPopover.offsetWidth;
         const vw = document.documentElement.clientWidth;
-        let top  = window.scrollY + rect.bottom + 4;
-        let left = window.scrollX + rect.left;
+        const vh = document.documentElement.clientHeight;
+        let top  = window.scrollY + rect.top + rect.height / 2 - ph / 2;
+        let left = window.scrollX + rect.right + 8;
         if (left + pw > window.scrollX + vw - 8) {
-            left = window.scrollX + vw - pw - 8;
+            left = window.scrollX + rect.left - pw - 8;
         }
-        if (top + ph > window.scrollY + document.documentElement.clientHeight - 8) {
-            top = window.scrollY + rect.top - ph - 4;
+        if (top < window.scrollY + 8) { top = window.scrollY + 8; }
+        if (top + ph > window.scrollY + vh - 8) {
+            top = window.scrollY + vh - ph - 8;
         }
-        descPopover.style.top  = top + 'px';
-        descPopover.style.left = left + 'px';
-        descPopoverOpenAt = Date.now();
+        infoPopover.style.top  = top + 'px';
+        infoPopover.style.left = left + 'px';
+    }
+
+    function openInfoPopover(trigger) {
+        const tr = trigger.closest('tr[data-task-row]');
+        if (!tr) { return; }
+        buildInfoPopover();
+        infoPopoverAnchor = trigger;
+        renderInfoPopoverContent(tr);
+        cancelInfoPopoverGrace();
+        infoPopover.classList.remove('hidden');
+        trigger.setAttribute('aria-expanded', 'true');
+        positionInfoPopover();
+        infoPopoverOpenAt = Date.now();
     }
 
-    function closeDescPopover() {
-        if (descPopover) { descPopover.classList.add('hidden'); }
-        descPopoverAnchor = null;
+    function closeInfoPopover() {
+        cancelInfoPopoverGrace();
+        if (infoPopover) { infoPopover.classList.add('hidden'); }
+        if (infoPopoverAnchor) { infoPopoverAnchor.setAttribute('aria-expanded', 'false'); }
+        infoPopoverAnchor = null;
     }
 
-    on(root, 'click', '[data-task-desc-trigger]', function (ev) {
+    on(root, 'click', '[data-task-info-trigger]', function (ev) {
         ev.preventDefault();
         ev.stopPropagation();
-        const tr = this.closest('tr[data-task-row]');
-        if (!tr) { return; }
-        const text = tr.getAttribute('data-description') || '';
-        openDescPopover(this, text);
+        if (infoPopoverAnchor === this && infoPopover && !infoPopover.classList.contains('hidden')) {
+            closeInfoPopover();
+        } else {
+            openInfoPopover(this);
+        }
     });
 
     document.addEventListener('pointerdown', function (ev) {
-        if (!descPopover || descPopover.classList.contains('hidden')) { return; }
-        if (Date.now() - descPopoverOpenAt < 50) { return; }
-        if (descPopover.contains(ev.target)) { return; }
-        if (descPopoverAnchor && descPopoverAnchor.contains(ev.target)) { return; }
-        closeDescPopover();
+        if (!infoPopover || infoPopover.classList.contains('hidden')) { return; }
+        if (Date.now() - infoPopoverOpenAt < 50) { return; }
+        if (infoPopover.contains(ev.target)) { return; }
+        if (infoPopoverAnchor && infoPopoverAnchor.contains(ev.target)) { return; }
+        closeInfoPopover();
     }, true);
 
     document.addEventListener('keydown', function (ev) {
-        if (ev.key === 'Escape' && descPopover && !descPopover.classList.contains('hidden')) {
-            closeDescPopover();
+        if (ev.key === 'Escape' && infoPopover && !infoPopover.classList.contains('hidden')) {
+            closeInfoPopover();
         }
     });
 
+    // Focus loss on the trigger or links inside the popover closes after a
+    // short grace (matches the "focus lost" close hint in the spec).
+    document.addEventListener('focusout', function (ev) {
+        if (!infoPopover || infoPopover.classList.contains('hidden')) { return; }
+        const next = ev.relatedTarget;
+        if (next && (infoPopover.contains(next) || (infoPopoverAnchor && infoPopoverAnchor.contains(next)))) { return; }
+        scheduleInfoPopoverGrace();
+    }, true);
+
+    window.addEventListener('scroll', function () {
+        if (infoPopover && !infoPopover.classList.contains('hidden')) { closeInfoPopover(); }
+    }, true);
+    window.addEventListener('resize', function () {
+        if (infoPopover && !infoPopover.classList.contains('hidden')) { closeInfoPopover(); }
+    });
+
     // --- Click-pickup reorder -------------------------------------------
     //
     // The user picks "Move (pick up)" from the menu, the row tracks the

+ 48 - 35
views/sprints/_task_list.twig

@@ -178,6 +178,7 @@
                         {% set tot = 0 %}
                         {% for v in assign %}{% set tot = tot + v %}{% endfor %}
                         {% set links = linkedMap[t.id]|default([]) %}
+                    {% set hasInfo = t.description != '' or t.url != '' or links is not empty %}
                         <tr data-task-row
                             class="hover:bg-slate-50 dark:hover:bg-slate-700"
                             data-task-id="{{ t.id }}"
@@ -185,7 +186,9 @@
                             data-owner="{{ t.ownerWorkerId is not null ? t.ownerWorkerId : '' }}"
                             data-sort-order="{{ t.sortOrder }}"
                             data-description="{{ t.description }}"
-                            data-url="{{ t.url }}">
+                            data-url="{{ t.url }}"
+                            data-task-title="{{ t.title }}"
+                            data-links="{{ links|json_encode|e('html_attr') }}">
                             <td class="px-2 py-1">
                                 {% if currentUser.isAdmin %}
                                     <button type="button" data-task-menu-trigger
@@ -201,49 +204,59 @@
                                 {% endif %}
                             </td>
                             <td class="px-2 py-1 min-w-[14rem]">
-                                <div class="flex items-center gap-1.5">
+                                <div class="task-title-grid">
                                     {% if currentUser.isAdmin %}
                                         <input type="text" data-title value="{{ t.title }}"
-                                               class="flex-1 rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+                                               class="rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500 min-w-0">
                                     {% else %}
-                                        <span class="flex-1">{{ t.title }}</span>
+                                        <span class="min-w-0 truncate">{{ t.title }}</span>
                                     {% endif %}
 
-                                    <a data-task-url-link href="{{ t.url }}"
-                                       target="_blank" rel="noopener noreferrer"
-                                       class="task-url-link inline-flex items-center justify-center w-5 h-5 rounded text-blue-600 hover:bg-slate-100 dark:text-blue-400 dark:hover:bg-slate-700{% if t.url == '' %} hidden{% endif %}"
-                                       title="Open task link"
-                                       aria-label="Open task link">
-                                        <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
-                                            <path d="M9 2h5v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-                                            <path d="M14 2L7 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-                                            <path d="M12 9v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-                                        </svg>
-                                    </a>
+                                    <div class="task-title-mid">
+                                        <a data-task-url-link href="{{ t.url }}"
+                                           target="_blank" rel="noopener noreferrer"
+                                           class="task-url-link inline-flex items-center justify-center w-5 h-5 rounded text-blue-600 hover:bg-slate-100 dark:text-blue-400 dark:hover:bg-slate-700{% if t.url == '' %} hidden{% endif %}"
+                                           title="Open task link"
+                                           aria-label="Open task link">
+                                            <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
+                                                <path d="M9 2h5v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                                <path d="M14 2L7 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                                <path d="M12 9v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                            </svg>
+                                        </a>
+
+                                        <span data-task-refs class="task-refs inline-flex items-center gap-0.5{% if links is empty %} hidden{% endif %}">
+                                            {% for l in links %}
+                                                <a href="/sprints/{{ l.sprint_id }}"
+                                                   class="task-ref-icon inline-flex items-center justify-center w-5 h-5 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700"
+                                                   title="{{ l.direction == 'source' ? 'Copied from' : 'Copied to' }}: {{ l.title }} ({{ l.sprint_name }})"
+                                                   aria-label="{{ l.direction == 'source' ? 'Copied from' : 'Copied to' }} {{ l.title }} ({{ l.sprint_name }})">
+                                                    <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
+                                                        {% if l.direction == 'source' %}
+                                                            <path d="M10 4L4 8l6 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                                            <line x1="4" y1="8" x2="13" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+                                                        {% else %}
+                                                            <path d="M6 4l6 4-6 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                                                            <line x1="3" y1="8" x2="12" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
+                                                        {% endif %}
+                                                    </svg>
+                                                </a>
+                                            {% endfor %}
+                                        </span>
+                                    </div>
 
-                                    <button type="button" data-task-desc-trigger
-                                            class="task-desc-trigger inline-flex items-center justify-center w-5 h-5 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700{% if t.description == '' %} hidden{% endif %}"
-                                            title="View description"
-                                            aria-label="View description">
-                                        <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
-                                            <rect x="2" y="3" width="12" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>
-                                            <line x1="4" y1="6"  x2="12" y2="6"  stroke="currentColor" stroke-width="1.5"/>
-                                            <line x1="4" y1="9"  x2="10" y2="9"  stroke="currentColor" stroke-width="1.5"/>
+                                    <button type="button" data-task-info-trigger
+                                            class="task-info-trigger inline-flex items-center justify-center w-5 h-5 rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-700 dark:hover:text-slate-200{% if not hasInfo %} hidden{% endif %}"
+                                            title="Task info"
+                                            aria-label="Task info"
+                                            aria-haspopup="true" aria-expanded="false">
+                                        <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
+                                            <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
+                                            <circle cx="8" cy="4.6" r="0.85" fill="currentColor"/>
+                                            <line x1="8" y1="7" x2="8" y2="11.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
                                         </svg>
                                     </button>
                                 </div>
-                                {% if links is not empty %}
-                                    <div class="mt-0.5 flex flex-wrap gap-1 text-[10px]">
-                                        {% for l in links %}
-                                            <a href="/sprints/{{ l.sprint_id }}"
-                                               class="inline-flex items-center gap-1 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
-                                               title="{{ l.direction == 'source' ? 'Copied from' : 'Copied to' }}: {{ l.title }} ({{ l.sprint_name }})">
-                                                <span class="opacity-60">{{ l.direction == 'source' ? '←' : '→' }}</span>
-                                                <span>{{ l.sprint_name }}</span>
-                                            </a>
-                                        {% endfor %}
-                                    </div>
-                                {% endif %}
                             </td>
                             <td class="px-2 py-1" data-col="owner">
                                 {% if currentUser.isAdmin %}