Procházet zdrojové kódy

Hamburger popup: 2-pane layout, sprint flyout, non-admin actions

Drop the inline info icon and ref-arrow icons from the previous commit.
Hamburger now opens a single body-attached popup positioned right of
the trigger and vertically centred on it (cellPopover model). Left
pane carries the menu (Edit task / Move task to sprint / Copy task to
sprint / Move up/down / divider / Delete task); right pane shows
title, description, URL link, and reference chips at the bottom; a
third "flyout" column appears on Move/Copy with the sprint chooser.
Closing matches the Phase-18 cell popover (outside-pointerdown,
Escape, scroll/resize, 250 ms mouseleave grace).

Hamburger trigger renders for every signed-in user; the five task
endpoints (update / delete / move / copy / reorder) drop the admin
gate (`requireAdminJson` -> `requireAuthJson`) so non-admins can
edit / move / copy / pick up / delete a task. Inline title / owner /
priority editing and "+ Add task" stay admin-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa před 2 dny
rodič
revize
31506c6447

+ 49 - 26
CHANGELOG.md

@@ -8,32 +8,55 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### 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.
+- **Task hamburger: two-pane popup with right-anchored sprint flyout;
+  actions opened up to non-admins.** The per-row hamburger trigger now
+  renders for every signed-in user (no more `{% if currentUser.isAdmin %}`
+  on the title cell), and clicking it opens a single body-attached
+  popup positioned to the right of the icon and vertically centred on
+  it (cellPopover positioning model). Three panes:
+    1. *Left* — vertical menu: **Edit task** / **Move task to sprint ▸** /
+       **Copy task to sprint ▸** / **Move up/down** / divider /
+       **Delete task**. The two `▸` items toggle the third pane.
+    2. *Right* — read-only task info: title, description (preformatted,
+       scrollable), URL as a clickable link, and the Phase-22
+       bidirectional reference chips (`← Sprint X` / `→ Sprint Y`,
+       hover title = "Copied from / to: <task title> (<sprint>)") at
+       the bottom.
+    3. *Flyout (third column, hidden by default)* — sprint chooser for
+       Move / Copy. Opens on the right of the info pane; clicking a
+       sprint button fires the existing `POST /tasks/{id}/move` /
+       `/copy` endpoints and closes the popup.
+  Closing mirrors the Phase-18 cell popover: outside-pointerdown,
+  Escape, scroll / resize, and a 250 ms mouseleave grace.
+  Server-side, the five hamburger-action endpoints
+  (`PATCH /tasks/{id}`, `DELETE /tasks/{id}`, `POST /tasks/{id}/move`,
+  `POST /tasks/{id}/copy`, `POST /sprints/{id}/tasks/reorder`) now
+  use `SessionGuard::requireAuthJson` instead of `requireAdminJson` —
+  any signed-in user can edit / move / copy / pick up / delete a task,
+  matching the explicit operator decision to give non-admins the same
+  hamburger affordances. Inline title / owner / priority editing and
+  `+ Add task` stay admin-only as today (still gated by
+  `{% if currentUser.isAdmin %}` in `_task_list.twig` and by
+  `requireAdminJson` on `POST /sprints/{id}/tasks` and
+  `PATCH /tasks/{id}/assignments`). Touchpoints:
+  `src/Controllers/TaskController.php` (5 guard swaps),
+  `views/sprints/_task_list.twig` (drop the admin gate around the
+  hamburger button; revert the title cell to the simple
+  `flex` layout that ships the URL link affordance only),
+  `public/assets/js/sprint-planner.js` (replaces the old single-column
+  `task-menu` with the wider `task-menu-inner` flex container —
+  `buildTaskMenu`, `fillTaskMenuFlyout`, `fillTaskMenuInfo`,
+  `positionTaskMenu` (right + vcenter, falls back to left flip),
+  `openTaskMenu` / `closeTaskMenu` with `cancelTaskMenuGrace` /
+  `scheduleTaskMenuGrace`; retires the brief info-popover from the
+  prior commit and its `renderTaskRefs` helper); and
+  `assets/css/input.css` (new `.task-menu-list`, `.task-menu-info`,
+  `.task-menu-flyout`, `.task-menu-info-ref-chip`, `.task-menu-divider`;
+  retires the unused `.task-title-grid`, `.task-info-popover`,
+  `.task-desc-popover`). No migration / new endpoint — the existing
+  `data-description`, `data-url`, and `linkedMap` keep driving the
+  popup; new `data-task-title` / `data-links` row attributes feed the
+  info pane without an extra round-trip.
 
 ## [0.23.0] — 2026-05-07
 

+ 140 - 124
assets/css/input.css

@@ -197,11 +197,13 @@
        reorder indicator, description popover. All single body-attached
        nodes; the JS positions them with absolute coordinates. */
 
+    /* Task hamburger popup — three flex panes:
+         [ menu | info | flyout (hidden by default) ]
+       Body-attached, positioned by JS to the right of the trigger and
+       vertically centred on it (cellPopover positioning model). */
     .task-menu {
         position: absolute;
         z-index: 60;
-        min-width: 12rem;
-        padding: 0.25rem;
         border-radius: 0.5rem;
         border: 1px solid theme('colors.slate.200');
         background-color: theme('colors.white');
@@ -211,6 +213,21 @@
         border-color: theme('colors.slate.700');
         background-color: theme('colors.slate.800');
     }
+    .task-menu-inner {
+        display: flex;
+        align-items: stretch;
+    }
+    .task-menu-list {
+        display: flex;
+        flex-direction: column;
+        gap: 0.1rem;
+        padding: 0.4rem;
+        min-width: 12rem;
+        border-right: 1px solid theme('colors.slate.100');
+    }
+    .dark .task-menu-list {
+        border-right-color: theme('colors.slate.700');
+    }
     .task-menu-item,
     .task-menu-sub-item {
         display: flex;
@@ -225,6 +242,7 @@
         color: theme('colors.slate.700');
         background-color: transparent;
         cursor: pointer;
+        white-space: nowrap;
     }
     .dark .task-menu-item,
     .dark .task-menu-sub-item {
@@ -238,28 +256,137 @@
     .dark .task-menu-sub-item:hover {
         background-color: theme('colors.slate.700');
     }
+    .task-menu-item-active {
+        background-color: theme('colors.slate.100');
+    }
+    .dark .task-menu-item-active {
+        background-color: theme('colors.slate.700');
+    }
     .task-menu-item-danger {
         color: theme('colors.red.600');
     }
     .dark .task-menu-item-danger {
         color: theme('colors.red.400');
     }
-    .task-menu-subwrap {
-        position: relative;
+    .task-menu-divider {
+        height: 1px;
+        margin: 0.25rem 0.25rem;
+        background-color: theme('colors.slate.100');
+    }
+    .dark .task-menu-divider {
+        background-color: theme('colors.slate.700');
+    }
+
+    /* Right pane: read-only task info. */
+    .task-menu-info {
+        display: flex;
+        flex-direction: column;
+        gap: 0.4rem;
+        padding: 0.6rem 0.75rem;
+        width: 18rem;
+        max-width: 22rem;
+        font-size: 0.825rem;
+        color: theme('colors.slate.700');
+    }
+    .dark .task-menu-info {
+        color: theme('colors.slate.100');
+    }
+    .task-menu-info-title {
+        font-weight: 600;
+        font-size: 0.9rem;
+        color: theme('colors.slate.800');
+        word-break: break-word;
+    }
+    .dark .task-menu-info-title {
+        color: theme('colors.slate.100');
+    }
+    .task-menu-info-desc {
+        white-space: pre-wrap;
+        word-break: break-word;
+        max-height: 12rem;
+        overflow-y: auto;
+    }
+    .task-menu-info-url {
+        font-size: 0.8rem;
+        word-break: break-all;
+    }
+    .task-menu-info-url-link {
+        color: theme('colors.blue.700');
+        text-decoration: underline;
+    }
+    .dark .task-menu-info-url-link {
+        color: theme('colors.blue.300');
+    }
+    .task-menu-info-refs-head {
+        margin-top: 0.2rem;
+        font-size: 0.7rem;
+        text-transform: uppercase;
+        letter-spacing: 0.05em;
+        color: theme('colors.slate.500');
+        margin-bottom: 0.2rem;
+    }
+    .dark .task-menu-info-refs-head {
+        color: theme('colors.slate.400');
+    }
+    .task-menu-info-refs-list {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 0.25rem;
     }
-    .task-menu-sub {
-        margin-top: 0.15rem;
-        margin-left: 0.5rem;
-        padding: 0.25rem;
-        border-radius: 0.4rem;
+    .task-menu-info-ref-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 0.25rem;
+        padding: 0.1rem 0.4rem;
+        border-radius: 9999px;
         border: 1px solid theme('colors.slate.200');
         background-color: theme('colors.slate.50');
-        max-height: 14rem;
-        overflow-y: auto;
+        color: theme('colors.slate.600');
+        font-size: 0.75rem;
+        text-decoration: none;
     }
-    .dark .task-menu-sub {
-        border-color: theme('colors.slate.700');
+    .task-menu-info-ref-chip:hover {
+        background-color: theme('colors.slate.100');
+    }
+    .dark .task-menu-info-ref-chip {
+        border-color: theme('colors.slate.600');
         background-color: theme('colors.slate.700');
+        color: theme('colors.slate.300');
+    }
+    .dark .task-menu-info-ref-chip:hover {
+        background-color: theme('colors.slate.600');
+    }
+
+    /* Flyout — third column for the sprint chooser when Move/Copy is
+       picked. Slides in to the right of the info pane. */
+    .task-menu-flyout {
+        display: flex;
+        flex-direction: column;
+        padding: 0.4rem;
+        width: 12rem;
+        border-left: 1px solid theme('colors.slate.100');
+        background-color: theme('colors.slate.50');
+    }
+    .dark .task-menu-flyout {
+        border-left-color: theme('colors.slate.700');
+        background-color: theme('colors.slate.700');
+    }
+    .task-menu-flyout-head {
+        font-size: 0.7rem;
+        text-transform: uppercase;
+        letter-spacing: 0.05em;
+        color: theme('colors.slate.500');
+        padding: 0.2rem 0.5rem 0.4rem;
+    }
+    .dark .task-menu-flyout-head {
+        color: theme('colors.slate.300');
+    }
+    .task-menu-flyout-list {
+        display: flex;
+        flex-direction: column;
+        gap: 0.1rem;
+        max-height: 16rem;
+        overflow-y: auto;
     }
     .task-menu-sub-empty {
         padding: 0.4rem 0.6rem;
@@ -342,115 +469,4 @@
         box-shadow: 0 0 0 1px rgb(255 255 255 / 0.5);
     }
 
-    /* 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;
-        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;
-        box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.12), 0 4px 6px -4px rgb(0 0 0 / 0.1);
-    }
-    .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');
-    }
 }

+ 220 - 328
public/assets/js/sprint-planner.js

@@ -356,40 +356,6 @@
         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) {
@@ -424,21 +390,19 @@
         tdMenu.appendChild(trig);
         tr.appendChild(tdMenu);
 
-        // title cell — grid layout: [title input] [URL + ref icons] [info icon]
+        // title cell — title input + small "open URL" anchor; description /
+        // refs / actions all live in the hamburger popup.
         const tdTitle = document.createElement('td');
         tdTitle.className = 'px-2 py-1 min-w-[14rem]';
         const titleWrap = document.createElement('div');
-        titleWrap.className = 'task-title-grid';
+        titleWrap.className = 'flex items-center gap-1.5';
         const title = document.createElement('input');
         title.type = 'text';
         title.setAttribute('data-title', '');
         title.value = task.title || '';
-        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';
+        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';
         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';
@@ -453,32 +417,7 @@
             + '<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>';
-        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(infoBtn);
+        titleWrap.appendChild(urlLink);
 
         tdTitle.appendChild(titleWrap);
         tr.appendChild(tdTitle);
@@ -629,7 +568,9 @@
         const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
         if (!tr) { return; }
         const titleInp = qs('[data-title]', tr);
-        const title = titleInp ? titleInp.value : '(untitled)';
+        const title = titleInp
+            ? String(titleInp.value || '')
+            : String(tr.getAttribute('data-task-title') || '(untitled)');
         if (!window.confirm('Delete task "' + title + '"?')) { return; }
         request('DELETE', '/tasks/' + taskId)
             .then(function (data) {
@@ -946,8 +887,21 @@
 
     // --- Task hamburger menu + details modal + click-pickup reorder -----
     //
-    // Replaces SortableJS for tasks. The menu, modal, description popover,
-    // and pickup overlay are all single body-attached nodes built lazily.
+    // Single body-attached popup. Opens to the right of the trigger,
+    // vertically centred on it (cellPopover positioning model). Three
+    // panes:
+    //   - Left:  vertical menu (Edit task / Move task to sprint /
+    //            Copy task to sprint / Move up/down / divider /
+    //            Delete task).
+    //   - Right: read-only task info — title, description, URL link,
+    //            and reference chips at the bottom.
+    //   - Flyout (third column, hidden by default): sprint chooser
+    //            for the Move / Copy actions.
+    // Closing mirrors the cell popover: outside-pointerdown, Escape,
+    // scroll / resize, and a 250 ms mouseleave grace. The popup is
+    // shown to non-admins too — the corresponding TaskController
+    // endpoints (update / delete / move / copy / reorder) accept any
+    // signed-in user.
 
     const sprintChoices = (function () {
         if (!taskSection) { return []; }
@@ -961,16 +915,38 @@
         } catch (_) { return []; }
     })();
 
-    let taskMenu       = null;     // root container
-    let taskMenuTaskId = null;
-    let taskMenuTrigger = null;    // the button it was opened from
-    let taskMenuOpenAt = 0;
+    let taskMenu        = null;   // root container (popup)
+    let taskMenuFlyout  = null;   // third-column sprint chooser
+    let taskMenuListEl  = null;   // left vertical menu
+    let taskMenuInfoEl  = null;   // right info pane
+    let taskMenuTaskId  = null;
+    let taskMenuTrigger = null;   // the hamburger button it was opened from
+    let taskMenuAction  = null;   // 'move' | 'copy' when flyout is open
+    let taskMenuOpenAt  = 0;
+    let taskMenuGrace   = null;
+
+    function cancelTaskMenuGrace() {
+        if (taskMenuGrace) { clearTimeout(taskMenuGrace); taskMenuGrace = null; }
+    }
+    function scheduleTaskMenuGrace() {
+        cancelTaskMenuGrace();
+        taskMenuGrace = setTimeout(closeTaskMenu, 250);
+    }
 
     function buildTaskMenu() {
         if (taskMenu) { return taskMenu; }
         const r = document.createElement('div');
         r.className = 'task-menu hidden';
-        r.setAttribute('role', 'menu');
+        r.setAttribute('role', 'dialog');
+        r.setAttribute('aria-label', 'Task actions');
+
+        const inner = document.createElement('div');
+        inner.className = 'task-menu-inner';
+
+        // Left column: vertical menu list.
+        const list = document.createElement('div');
+        list.className = 'task-menu-list';
+        list.setAttribute('role', 'menu');
 
         function item(label, action, opts) {
             const b = document.createElement('button');
@@ -979,70 +955,171 @@
             b.setAttribute('data-task-menu-item', action);
             b.className = 'task-menu-item';
             if (opts && opts.danger) { b.classList.add('task-menu-item-danger'); }
-            b.textContent = label;
+            if (opts && opts.withSub) {
+                b.classList.add('task-menu-item-with-sub');
+                b.setAttribute('data-task-submenu-trigger', action);
+                b.innerHTML = '<span>' + label + '</span><span class="opacity-60">▸</span>';
+            } else {
+                b.textContent = label;
+            }
             return b;
         }
 
-        function submenu(label, action) {
-            const wrap = document.createElement('div');
-            wrap.className = 'task-menu-subwrap';
-            wrap.setAttribute('data-task-submenu', action);
-            const b = document.createElement('button');
-            b.type = 'button';
-            b.setAttribute('role', 'menuitem');
-            b.setAttribute('data-task-submenu-trigger', action);
-            b.className = 'task-menu-item task-menu-item-with-sub';
-            b.innerHTML = '<span>' + label + '</span><span class="opacity-60">▸</span>';
-            wrap.appendChild(b);
-
-            const list = document.createElement('div');
-            list.className = 'task-menu-sub hidden';
-            list.setAttribute('data-task-submenu-list', action);
-            wrap.appendChild(list);
-            return wrap;
-        }
+        list.appendChild(item('Edit task',              'edit'));
+        list.appendChild(item('Move task to sprint',    'move', { withSub: true }));
+        list.appendChild(item('Copy task to sprint',    'copy', { withSub: true }));
+        list.appendChild(item('Move up/down',           'pickup'));
+        const divider = document.createElement('div');
+        divider.className = 'task-menu-divider';
+        list.appendChild(divider);
+        list.appendChild(item('Delete task',            'delete', { danger: true }));
+        inner.appendChild(list);
+        taskMenuListEl = list;
+
+        // Right column: read-only task info pane.
+        const info = document.createElement('div');
+        info.className = 'task-menu-info';
+        info.innerHTML =
+              '<div class="task-menu-info-title" data-info-title></div>'
+            + '<div class="task-menu-info-desc"  data-info-desc></div>'
+            + '<div class="task-menu-info-url    hidden" data-info-url-wrap><a class="task-menu-info-url-link" data-info-url target="_blank" rel="noopener noreferrer"></a></div>'
+            + '<div class="task-menu-info-refs   hidden" data-info-refs-wrap>'
+            +   '<div class="task-menu-info-refs-head">References</div>'
+            +   '<div class="task-menu-info-refs-list" data-info-refs></div>'
+            + '</div>';
+        inner.appendChild(info);
+        taskMenuInfoEl = info;
+
+        // Third column (flyout): hidden until "Move" or "Copy" picked.
+        const flyout = document.createElement('div');
+        flyout.className = 'task-menu-flyout hidden';
+        flyout.innerHTML =
+              '<div class="task-menu-flyout-head" data-flyout-head></div>'
+            + '<div class="task-menu-flyout-list" data-task-submenu-list></div>';
+        inner.appendChild(flyout);
+        taskMenuFlyout = flyout;
+
+        r.appendChild(inner);
+        document.body.appendChild(r);
 
-        r.appendChild(item('Edit details…',     'edit'));
-        r.appendChild(submenu('Move to sprint',  'move'));
-        r.appendChild(submenu('Copy to sprint',  'copy'));
-        r.appendChild(item('Move (pick up)',     'pickup'));
-        r.appendChild(item('Delete',             'delete', { danger: true }));
+        // Mouseleave-grace identical to cellPopover.
+        r.addEventListener('mouseenter', cancelTaskMenuGrace);
+        r.addEventListener('mouseleave', scheduleTaskMenuGrace);
 
-        document.body.appendChild(r);
         taskMenu = r;
         return r;
     }
 
-    function refreshTaskMenuChoices(currentSprintId) {
-        const moveList = taskMenu.querySelector('[data-task-submenu-list="move"]');
-        const copyList = taskMenu.querySelector('[data-task-submenu-list="copy"]');
-        [moveList, copyList].forEach((el) => { if (el) { el.innerHTML = ''; } });
-        if (sprintChoices.length === 0) {
-            [moveList, copyList].forEach(function (el) {
-                if (!el) { return; }
-                const empty = document.createElement('div');
-                empty.className = 'task-menu-sub-empty';
-                empty.textContent = 'No other sprints';
-                el.appendChild(empty);
-            });
+    function fillTaskMenuFlyout(action, currentSprintId) {
+        if (!taskMenuFlyout) { return; }
+        const headEl = taskMenuFlyout.querySelector('[data-flyout-head]');
+        const listEl = taskMenuFlyout.querySelector('[data-task-submenu-list]');
+        if (!headEl || !listEl) { return; }
+
+        listEl.setAttribute('data-task-submenu-list', action);
+        headEl.textContent = action === 'move' ? 'Move to sprint' : 'Copy to sprint';
+        listEl.innerHTML = '';
+
+        const others = sprintChoices.filter((s) => Number(s.id) !== Number(currentSprintId));
+        if (others.length === 0) {
+            const empty = document.createElement('div');
+            empty.className = 'task-menu-sub-empty';
+            empty.textContent = 'No other sprints';
+            listEl.appendChild(empty);
             return;
         }
-        sprintChoices.forEach(function (s) {
-            if (Number(s.id) === Number(currentSprintId)) { return; }
-            ['move', 'copy'].forEach(function (action) {
-                const list = action === 'move' ? moveList : copyList;
-                if (!list) { return; }
-                const b = document.createElement('button');
-                b.type = 'button';
-                b.setAttribute('role', 'menuitem');
-                b.setAttribute('data-task-' + action + '-target', String(s.id));
-                b.className = 'task-menu-sub-item';
-                b.textContent = s.name;
-                list.appendChild(b);
-            });
+        others.forEach(function (s) {
+            const b = document.createElement('button');
+            b.type = 'button';
+            b.setAttribute('role', 'menuitem');
+            b.setAttribute('data-task-' + action + '-target', String(s.id));
+            b.className = 'task-menu-sub-item';
+            b.textContent = s.name;
+            listEl.appendChild(b);
+        });
+    }
+
+    function setFlyoutActiveTrigger(action) {
+        if (!taskMenuListEl) { return; }
+        qsa('[data-task-submenu-trigger]', taskMenuListEl).forEach(function (b) {
+            b.classList.toggle('task-menu-item-active',
+                b.getAttribute('data-task-submenu-trigger') === action);
         });
     }
 
+    function openTaskMenuFlyout(action) {
+        if (!taskMenu) { return; }
+        taskMenuAction = action;
+        fillTaskMenuFlyout(action, sprintId);
+        setFlyoutActiveTrigger(action);
+        taskMenuFlyout.classList.remove('hidden');
+        positionTaskMenu();
+    }
+
+    function closeTaskMenuFlyout() {
+        if (taskMenuFlyout) { taskMenuFlyout.classList.add('hidden'); }
+        setFlyoutActiveTrigger(null);
+        taskMenuAction = null;
+    }
+
+    function fillTaskMenuInfo(tr) {
+        if (!taskMenuInfoEl) { return; }
+        const titleEl    = taskMenuInfoEl.querySelector('[data-info-title]');
+        const descEl     = taskMenuInfoEl.querySelector('[data-info-desc]');
+        const urlWrap    = taskMenuInfoEl.querySelector('[data-info-url-wrap]');
+        const urlLink    = taskMenuInfoEl.querySelector('[data-info-url]');
+        const refsWrap   = taskMenuInfoEl.querySelector('[data-info-refs-wrap]');
+        const refsList   = taskMenuInfoEl.querySelector('[data-info-refs]');
+
+        const titleAttr = tr.getAttribute('data-task-title') || '';
+        const titleInp  = qs('input[data-title]', tr);
+        const liveTitle = titleInp ? String(titleInp.value || '') : titleAttr;
+        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 */ }
+
+        if (titleEl) { titleEl.textContent = liveTitle; }
+        if (descEl) {
+            descEl.textContent = desc;
+            descEl.classList.toggle('hidden', desc === '');
+        }
+        if (urlWrap && urlLink) {
+            if (url) {
+                urlLink.href = url;
+                urlLink.textContent = url;
+                urlWrap.classList.remove('hidden');
+            } else {
+                urlLink.removeAttribute('href');
+                urlLink.textContent = '';
+                urlWrap.classList.add('hidden');
+            }
+        }
+        if (refsWrap && refsList) {
+            refsList.innerHTML = '';
+            if (links.length > 0) {
+                refsWrap.classList.remove('hidden');
+                links.forEach(function (l) {
+                    if (!l || typeof l !== 'object') { return; }
+                    const dirLabel = l.direction === 'source' ? 'Copied from' : 'Copied to';
+                    const arrow    = l.direction === 'source' ? '←' : '→';
+                    const a = document.createElement('a');
+                    a.href = '/sprints/' + l.sprint_id;
+                    a.className = 'task-menu-info-ref-chip';
+                    a.title = dirLabel + ': ' + (l.title || '') + ' (' + (l.sprint_name || '') + ')';
+                    a.innerHTML = '<span class="opacity-60">' + arrow + '</span> '
+                        + '<span>' + (l.sprint_name ? String(l.sprint_name) : '') + '</span>';
+                    refsList.appendChild(a);
+                });
+            } else {
+                refsWrap.classList.add('hidden');
+            }
+        }
+    }
+
     function positionTaskMenu() {
         if (!taskMenu || !taskMenuTrigger) { return; }
         const rect = taskMenuTrigger.getBoundingClientRect();
@@ -1051,14 +1128,15 @@
         const vw   = document.documentElement.clientWidth;
         const vh   = document.documentElement.clientHeight;
 
-        let top  = window.scrollY + rect.bottom + 4;
-        let left = window.scrollX + rect.left;
+        let top  = window.scrollY + rect.top + rect.height / 2 - mh / 2;
+        let left = window.scrollX + rect.right + 8;
         if (left + mw > window.scrollX + vw - 8) {
-            left = window.scrollX + vw - mw - 8;
+            left = window.scrollX + rect.left - mw - 8;
         }
+        if (left < window.scrollX + 8) { left = window.scrollX + 8; }
+        if (top  < window.scrollY + 8) { top  = window.scrollY + 8; }
         if (top + mh > window.scrollY + vh - 8) {
-            top = window.scrollY + rect.top - mh - 4;
-            if (top < window.scrollY + 8) { top = window.scrollY + 8; }
+            top = window.scrollY + vh - mh - 8;
         }
         taskMenu.style.top  = top + 'px';
         taskMenu.style.left = left + 'px';
@@ -1072,27 +1150,22 @@
         if (!Number.isFinite(taskId)) { return; }
 
         buildTaskMenu();
-        refreshTaskMenuChoices(sprintId);
-        closeAllTaskSubmenus();
+        closeTaskMenuFlyout();
+        fillTaskMenuInfo(tr);
         taskMenuTaskId  = taskId;
         taskMenuTrigger = trigger;
         trigger.setAttribute('aria-expanded', 'true');
+        cancelTaskMenuGrace();
         taskMenu.classList.remove('hidden');
         positionTaskMenu();
         taskMenuOpenAt = Date.now();
     }
 
-    function closeAllTaskSubmenus() {
-        if (!taskMenu) { return; }
-        qsa('[data-task-submenu-list]', taskMenu).forEach(function (el) {
-            el.classList.add('hidden');
-        });
-    }
-
     function closeTaskMenu() {
+        cancelTaskMenuGrace();
         if (taskMenu) { taskMenu.classList.add('hidden'); }
         if (taskMenuTrigger) { taskMenuTrigger.setAttribute('aria-expanded', 'false'); }
-        closeAllTaskSubmenus();
+        closeTaskMenuFlyout();
         taskMenuTaskId  = null;
         taskMenuTrigger = null;
     }
@@ -1121,6 +1194,7 @@
         }
     });
 
+    // Any scroll / resize closes — don't try to follow a moving anchor.
     window.addEventListener('scroll', function () {
         if (taskMenu && !taskMenu.classList.contains('hidden')) { closeTaskMenu(); }
     }, true);
@@ -1135,10 +1209,11 @@
         if (subTrig && taskMenu.contains(subTrig)) {
             ev.preventDefault();
             const action = subTrig.getAttribute('data-task-submenu-trigger');
-            const list = taskMenu.querySelector('[data-task-submenu-list="' + action + '"]');
-            const wasHidden = list && list.classList.contains('hidden');
-            closeAllTaskSubmenus();
-            if (list && wasHidden) { list.classList.remove('hidden'); }
+            if (taskMenuAction === action && !taskMenuFlyout.classList.contains('hidden')) {
+                closeTaskMenuFlyout();
+            } else {
+                openTaskMenuFlyout(action);
+            }
             return;
         }
 
@@ -1179,7 +1254,7 @@
         }
 
         const it = ev.target.closest('[data-task-menu-item]');
-        if (it && taskMenu.contains(it)) {
+        if (it && taskMenu.contains(it) && !it.hasAttribute('data-task-submenu-trigger')) {
             ev.preventDefault();
             const action = it.getAttribute('data-task-menu-item');
             const taskId = taskMenuTaskId;
@@ -1272,20 +1347,11 @@
                 if (tr) {
                     tr.setAttribute('data-description', desc);
                     tr.setAttribute('data-url', url);
-                    const link    = qs('[data-task-url-link]', tr);
-                    const infoBtn = qs('[data-task-info-trigger]', tr);
+                    const link = qs('[data-task-url-link]', tr);
                     if (link) {
                         link.href = url;
                         link.classList.toggle('hidden', url === '');
                     }
-                    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();
                 flash('Saved');
@@ -1299,180 +1365,6 @@
         }
     });
 
-    // --- Task info popover (click on the per-row info icon) -------------
-    //
-    // 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 infoPopover       = null;
-    let infoPopoverAnchor = null;
-    let infoPopoverGrace  = null;
-    let infoPopoverOpenAt = 0;
-
-    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-info-popover hidden';
-        r.setAttribute('role', 'dialog');
-        r.setAttribute('aria-label', 'Task info');
-        document.body.appendChild(r);
-        r.addEventListener('mouseenter', cancelInfoPopoverGrace);
-        r.addEventListener('mouseleave', scheduleInfoPopoverGrace);
-        infoPopover = r;
-        return r;
-    }
-
-    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;
-        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 + rect.left - pw - 8;
-        }
-        if (top < window.scrollY + 8) { top = window.scrollY + 8; }
-        if (top + ph > window.scrollY + vh - 8) {
-            top = window.scrollY + vh - ph - 8;
-        }
-        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 closeInfoPopover() {
-        cancelInfoPopoverGrace();
-        if (infoPopover) { infoPopover.classList.add('hidden'); }
-        if (infoPopoverAnchor) { infoPopoverAnchor.setAttribute('aria-expanded', 'false'); }
-        infoPopoverAnchor = null;
-    }
-
-    on(root, 'click', '[data-task-info-trigger]', function (ev) {
-        ev.preventDefault();
-        ev.stopPropagation();
-        if (infoPopoverAnchor === this && infoPopover && !infoPopover.classList.contains('hidden')) {
-            closeInfoPopover();
-        } else {
-            openInfoPopover(this);
-        }
-    });
-
-    document.addEventListener('pointerdown', function (ev) {
-        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' && 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 -------------------------------------------
     //

+ 5 - 5
src/Controllers/TaskController.php

@@ -116,7 +116,7 @@ final class TaskController
     /** PATCH /tasks/{id} — edit title / owner / priority. */
     public function update(Request $req, array $params): Response
     {
-        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        $actor = SessionGuard::requireAuthJson($req, $this->users);
         if ($actor instanceof Response) {
             return $actor;
         }
@@ -217,7 +217,7 @@ final class TaskController
     /** DELETE /tasks/{id} — delete a task; audits each assignment before cascade. */
     public function delete(Request $req, array $params): Response
     {
-        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        $actor = SessionGuard::requireAuthJson($req, $this->users);
         if ($actor instanceof Response) {
             return $actor;
         }
@@ -268,7 +268,7 @@ final class TaskController
     /** POST /sprints/{id}/tasks/reorder — apply an ordering. */
     public function reorder(Request $req, array $params): Response
     {
-        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        $actor = SessionGuard::requireAuthJson($req, $this->users);
         if ($actor instanceof Response) {
             return $actor;
         }
@@ -542,7 +542,7 @@ final class TaskController
      */
     public function moveToSprint(Request $req, array $params): Response
     {
-        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        $actor = SessionGuard::requireAuthJson($req, $this->users);
         if ($actor instanceof Response) {
             return $actor;
         }
@@ -607,7 +607,7 @@ final class TaskController
      */
     public function copyToSprint(Request $req, array $params): Response
     {
-        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        $actor = SessionGuard::requireAuthJson($req, $this->users);
         if ($actor instanceof Response) {
             return $actor;
         }

+ 23 - 59
views/sprints/_task_list.twig

@@ -178,7 +178,6 @@
                         {% 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 }}"
@@ -190,72 +189,37 @@
                             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
-                                            class="task-menu-trigger inline-flex items-center justify-center w-6 h-6 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400"
-                                            aria-haspopup="true" aria-expanded="false"
-                                            aria-label="Task actions">
-                                        <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
-                                            <line x1="2" y1="4"  x2="14" y2="4"  stroke="currentColor" stroke-width="2"/>
-                                            <line x1="2" y1="8"  x2="14" y2="8"  stroke="currentColor" stroke-width="2"/>
-                                            <line x1="2" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="2"/>
-                                        </svg>
-                                    </button>
-                                {% endif %}
+                                <button type="button" data-task-menu-trigger
+                                        class="task-menu-trigger inline-flex items-center justify-center w-6 h-6 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400"
+                                        aria-haspopup="true" aria-expanded="false"
+                                        aria-label="Task actions">
+                                    <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
+                                        <line x1="2" y1="4"  x2="14" y2="4"  stroke="currentColor" stroke-width="2"/>
+                                        <line x1="2" y1="8"  x2="14" y2="8"  stroke="currentColor" stroke-width="2"/>
+                                        <line x1="2" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="2"/>
+                                    </svg>
+                                </button>
                             </td>
                             <td class="px-2 py-1 min-w-[14rem]">
-                                <div class="task-title-grid">
+                                <div class="flex items-center gap-1.5">
                                     {% if currentUser.isAdmin %}
                                         <input type="text" data-title value="{{ t.title }}"
-                                               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">
+                                               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">
                                     {% else %}
-                                        <span class="min-w-0 truncate">{{ t.title }}</span>
+                                        <span class="flex-1">{{ t.title }}</span>
                                     {% endif %}
 
-                                    <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-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"/>
+                                    <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>
-                                    </button>
+                                    </a>
                                 </div>
                             </td>
                             <td class="px-2 py-1" data-col="owner">