diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index dbadc29..ba8256b 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -349,7 +349,7 @@ def _render_readme_card(
 		)
 		if blob_dir_attr:
 			body += ' data-blob-dir="' + blob_dir_attr + '"'
-		body += '></div><script src="/static/readme-render.js"></script>'
+		body += '></div><script type="module" src="/static/readme-render.js"></script>'
 		return _render_collapsible_section("README", body)
 	body = '<pre class="readme-plain"><code>' + (jinja_escape(sanitize(readme_content)) or "") + "</code></pre>"
 	return _render_collapsible_section("README", body)
@@ -692,7 +692,7 @@ def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTM
 	body_parts.append(_render_collapsible_section("Files", files_body))
 	body_parts.append(f'<div id="summary-readme-container" data-project="{jinja_escape(project)}">')
 	body_parts.append(f"{initial_ref_state['readme_html']}</div>")
-	body_parts.append('<script src="/static/summary-ref-switcher.js"></script>')
+	body_parts.append('<script type="module" src="/static/summary-ref-switcher.js"></script>')
 	body_parts.append('<script src="/static/hook-install.js"></script>')
 
 	body_parts.append(POSTAMBLE)
@@ -748,7 +748,7 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 		f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
 		f"</div>"
 	)
-	blob_script = '<script src="/static/blob-view.js"></script>'
+	blob_script = '<script type="module" src="/static/blob-view.js"></script>'
 	pre = PREAMBLE.render(title=f"{f} - {project}", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")
 
@@ -858,7 +858,7 @@ def git_blame(project: str, h: str | None, f: str | None) -> HTMLResponse:
 		f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
 		f"</div>"
 	)
-	blame_script = '<script src="/static/blob-view.js"></script>'
+	blame_script = '<script type="module" src="/static/blob-view.js"></script>'
 	pre = PREAMBLE.render(title=f"Blame {f} - {project}", site_name=settings.SITE_NAME)
 	return HTMLResponse(f"{pre}{blame_html}{blame_script}{POSTAMBLE}")
 
diff --git a/pygitweb/static/addproject.js b/pygitweb/static/addproject.js
new file mode 100644
index 0000000..93dfffc
--- /dev/null
+++ b/pygitweb/static/addproject.js
@@ -0,0 +1,119 @@
+(function() {
+  var projectNameInput = document.getElementById('project_name');
+  var nameValidTimeout = null;
+  if (!projectNameInput) return;
+
+  function setProjectNameBorder(valid) {
+    projectNameInput.classList.remove('border', 'border-success', 'border-danger');
+    projectNameInput.classList.add('border');
+    if (valid === true) {
+      projectNameInput.classList.add('border-success');
+    } else if (valid === false) {
+      projectNameInput.classList.add('border-danger');
+    } else {
+      projectNameInput.classList.remove('border');
+    }
+  }
+
+  function checkProjectName() {
+    var name = (projectNameInput.value || '').trim();
+    if (!name) {
+      setProjectNameBorder(null);
+      return;
+    }
+    var url = '/projectnamevalid?name=' + encodeURIComponent(name);
+    fetch(url, { credentials: 'same-origin' })
+      .then(function(res) {
+        setProjectNameBorder(res.ok);
+      })
+      .catch(function() {
+        setProjectNameBorder(null);
+      });
+  }
+
+  projectNameInput.addEventListener('blur', function() {
+    checkProjectName();
+  });
+  projectNameInput.addEventListener('input', function() {
+    clearTimeout(nameValidTimeout);
+    var name = (projectNameInput.value || '').trim();
+    if (!name) {
+      setProjectNameBorder(null);
+      return;
+    }
+    nameValidTimeout = setTimeout(checkProjectName, 400);
+  });
+})();
+
+(function() {
+  var sourceRadios = Array.prototype.slice.call(document.querySelectorAll('input[name="project_source_type"]'));
+  var emptyTab = document.getElementById('tab-empty-repo');
+  var zipTab = document.getElementById('tab-upload-zip');
+  var remoteInput = document.getElementById('pull_from_remote');
+  var zipInput = document.getElementById('repo_zip');
+  if (!emptyTab || !zipTab || !remoteInput || !zipInput) return;
+
+  function getSelectedSource() {
+    for (var i = 0; i < sourceRadios.length; i++) {
+      if (sourceRadios[i].checked) {
+        return sourceRadios[i].value;
+      }
+    }
+    return 'empty';
+  }
+
+  function updateSourceTabs() {
+    var selected = getSelectedSource();
+    var isZip = selected === 'zip';
+    emptyTab.classList.toggle('d-none', isZip);
+    zipTab.classList.toggle('d-none', !isZip);
+    if (isZip) {
+      remoteInput.value = '';
+    } else {
+      zipInput.value = '';
+      zipInput.dispatchEvent(new Event('change'));
+    }
+  }
+
+  sourceRadios.forEach(function(radio) {
+    radio.addEventListener('change', updateSourceTabs);
+  });
+  updateSourceTabs();
+})();
+
+(function() {
+  var dropzone = document.getElementById('dropzone');
+  var input = document.getElementById('repo_zip');
+  if (!dropzone || !input) return;
+  var placeholder = dropzone.querySelector('[data-dropzone-placeholder]');
+  var filenameEl = dropzone.querySelector('[data-dropzone-filename]');
+  if (!placeholder || !filenameEl) return;
+
+  function showFile(name) {
+    if (name) {
+      placeholder.classList.add('d-none');
+      filenameEl.classList.remove('d-none');
+      filenameEl.textContent = 'Selected: ' + name;
+    } else {
+      placeholder.classList.remove('d-none');
+      filenameEl.classList.add('d-none');
+      filenameEl.textContent = '';
+    }
+  }
+
+  dropzone.addEventListener('click', function() { input.click(); });
+  dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.classList.add('border-primary'); });
+  dropzone.addEventListener('dragleave', function() { dropzone.classList.remove('border-primary'); });
+  dropzone.addEventListener('drop', function(e) {
+    e.preventDefault();
+    dropzone.classList.remove('border-primary');
+    var files = e.dataTransfer.files;
+    if (files.length && files[0].name.toLowerCase().endsWith('.zip')) {
+      input.files = files;
+      showFile(files[0].name);
+    }
+  });
+  input.addEventListener('change', function() {
+    showFile(input.files.length ? input.files[0].name : null);
+  });
+})();
diff --git a/pygitweb/static/blob-view.js b/pygitweb/static/blob-view.js
index 9b6d96f..0601a89 100644
--- a/pygitweb/static/blob-view.js
+++ b/pygitweb/static/blob-view.js
@@ -1,5 +1,6 @@
+import { whenReady } from "./dom.js";
+
 (function(){
-  var el = document.getElementById('Highlight-script');
   function run() {
     var code = document.querySelector('.blob-content code');
     if (code && typeof hljs !== 'undefined') hljs.highlightElement(code);
@@ -124,10 +125,5 @@
       firstTarget.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
     }
   }
-  if (el) {
-    if (el.getAttribute('data-loaded')) run();
-    else el.addEventListener('load', function(){ el.setAttribute('data-loaded','1'); run(); });
-  } else {
-    run();
-  }
+  whenReady('Highlight-script', 'hljs', run);
 })();
diff --git a/pygitweb/static/board-cards.js b/pygitweb/static/board-cards.js
new file mode 100644
index 0000000..1082ccf
--- /dev/null
+++ b/pygitweb/static/board-cards.js
@@ -0,0 +1,268 @@
+import {
+  getBoardContext,
+  pushTaskFieldUpdate,
+  refreshBoardRefManifest,
+  setCardTaskRefDisplay,
+  syncCardTitleLineTooltip,
+  updateColumnBadge,
+  findBoardCardByTaskRef,
+} from "./board-context.js";
+import { setMarkdownElement, syncCompactDescription } from "./board-markdown.js";
+import { commentCountLabel, updateCardCommentCount } from "./board-comments.js";
+
+var ICONS_BASE = '/static/icons/';
+var ICON_LIST = ['cloud.svg', 'cloud-filled.svg', 'cloud-storm.svg', 'cloud-down.svg', 'cloud-up.svg', 'cloud-exclamation.svg'];
+
+export function preloadIcons() {
+  ICON_LIST.forEach(function(name) {
+    var img = new Image();
+    img.src = ICONS_BASE + name;
+  });
+}
+
+function setCardIcon(card, iconName) {
+  var icon = card && card.querySelector && card.querySelector('.board-card-icon');
+  if (icon) icon.setAttribute('data-icon', iconName);
+}
+
+function assignCloudIconToCard(card) {
+  setCardIcon(card, 'cloud');
+}
+
+export function assignCloudIconToAllCards() {
+  document.querySelectorAll('.board-card').forEach(assignCloudIconToCard);
+}
+
+function bindCompactAssigneeEditable(card) {
+  var span = card && card.querySelector && card.querySelector('.board-card-assignee-compact');
+  if (!span || span.dataset.editableBound === '1') return;
+  span.dataset.editableBound = '1';
+  span.classList.add('board-card-assignee-compact-editable');
+  span.addEventListener('click', function (e) {
+    e.stopPropagation();
+    e.preventDefault();
+    if (card.querySelector('.board-card-assignee-compact-edit')) return;
+    var current = (span.textContent || '').trim();
+    if (current === 'None') current = '';
+    var wasDraggable = card.draggable;
+    card.draggable = false;
+    var input = document.createElement('input');
+    input.type = 'text';
+    input.className = 'form-control form-control-sm board-card-assignee-compact-edit';
+    input.placeholder = 'Unassigned';
+    input.value = current;
+    span.classList.add('d-none');
+    span.parentNode.insertBefore(input, span);
+    input.focus();
+    input.select();
+    input.addEventListener('click', function (ev) { ev.stopPropagation(); });
+    input.addEventListener('mousedown', function (ev) { ev.stopPropagation(); });
+    function restoreDraggable() {
+      card.draggable = wasDraggable;
+      if (wasDraggable) card.setAttribute('draggable', 'true');
+    }
+    function commit() {
+      var val = (input.value || '').trim();
+      span.textContent = val || 'None';
+      span.classList.remove('d-none');
+      input.remove();
+      restoreDraggable();
+      var detailAssignee = card.querySelector('.board-card-assignee');
+      if (detailAssignee) detailAssignee.textContent = val || 'None';
+      pushTaskFieldUpdate(card, { assignee: val || null });
+    }
+    input.addEventListener('blur', commit);
+    input.addEventListener('keydown', function (ev) {
+      ev.stopPropagation();
+      if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
+      if (ev.key === 'Escape') { ev.preventDefault(); input.value = current; input.blur(); }
+    });
+  });
+}
+
+function createCardFromTask(task) {
+  var template = document.getElementById('board-card-template');
+  if (!template || !template.content) return null;
+  var clone = template.content.cloneNode(true);
+  var card = clone.querySelector && clone.querySelector('.board-card');
+  if (!card) return null;
+  var status = task.status || 'TODO';
+  var points = (task.points !== null && task.points !== undefined) ? task.points : null;
+  card.dataset.taskRef = task.ref || '';
+  card.dataset.taskStatus = status;
+  card.dataset.taskPoints = points !== null ? String(points) : '';
+  setCardTaskRefDisplay(card, task.ref || '', task.id);
+  var titleEl = card.querySelector('.board-card-title');
+  if (titleEl) titleEl.textContent = task.title || 'Untitled';
+  syncCardTitleLineTooltip(card);
+  var descFull = card.querySelector('.board-card-description-full');
+  if (descFull) setMarkdownElement(descFull, task.description || '');
+  syncCompactDescription(card, task.description || '');
+  setCardIcon(card, 'cloud-filled');
+  var meta = card.querySelector('.board-card-meta');
+  if (meta) {
+    var badge = meta.querySelector('.badge');
+    var assigneeSpan = meta.querySelector('.board-card-assignee-compact');
+    var commentsSpan = meta.querySelector('.board-card-comments-count-meta') || meta.querySelector('.ms-auto');
+    if (badge) {
+      badge.textContent = task.priority || 'LOW';
+      badge.className = 'badge badge-outline text-' + (task.priority === 'CRITICAL' ? 'danger' : task.priority === 'HIGH' ? 'warning' : 'secondary');
+    }
+    var pointsBadge = meta.querySelector('.board-card-points-badge');
+    if (pointsBadge) {
+      if (points !== null) {
+        pointsBadge.textContent = points + ' pt' + (points !== 1 ? 's' : '');
+        pointsBadge.classList.remove('d-none');
+      } else {
+        pointsBadge.textContent = '';
+        pointsBadge.classList.add('d-none');
+      }
+    }
+    if (assigneeSpan) assigneeSpan.textContent = task.assignee || 'None';
+    if (commentsSpan) commentsSpan.textContent = commentCountLabel(task.comments_count || 0);
+  }
+  var details = card.querySelector('.board-card-details');
+  if (details) {
+    var dueDate = details.querySelector('.board-card-due-date');
+    var updatedAt = details.querySelector('.board-card-updated-at');
+    var statusEl = details.querySelector('.board-card-status');
+    var priorityEl = details.querySelector('.board-card-priority');
+    var pointsEl = details.querySelector('.board-card-points');
+    var assigneeEl = details.querySelector('.board-card-assignee');
+    var tagsEl = details.querySelector('.board-card-tags');
+    if (dueDate) dueDate.textContent = task.due_date || 'None';
+    if (updatedAt) updatedAt.textContent = task.updated_at || 'None';
+    if (statusEl) statusEl.textContent = status;
+    if (priorityEl) priorityEl.textContent = task.priority || 'LOW';
+    if (pointsEl) pointsEl.textContent = points !== null ? String(points) : '–';
+    if (assigneeEl) assigneeEl.textContent = task.assignee || 'None';
+    if (tagsEl) tagsEl.textContent = task.tags || '';
+    updateCardCommentCount(card, task.comments_count || 0);
+  }
+  bindCompactAssigneeEditable(card);
+  return clone;
+}
+
+function groupTasksByStatus(tasks) {
+  var byStatus = {};
+  if (!Array.isArray(tasks)) return byStatus;
+  tasks.forEach(function(task) {
+    var s = task.status || 'TODO';
+    if (!byStatus[s]) byStatus[s] = [];
+    byStatus[s].push(task);
+  });
+  return byStatus;
+}
+
+export function loadTasksForBoard() {
+  var ctx = getBoardContext();
+  if (!ctx.project) return Promise.resolve();
+  var listUrl = '/tasks/list?' + new URLSearchParams({ project: ctx.project, board: ctx.board }).toString();
+  return fetch(listUrl).then(function(res) {
+    if (!res.ok) return;
+    return res.json();
+  }).then(function(tasks) {
+    if (!Array.isArray(tasks)) return;
+    var byStatus = groupTasksByStatus(tasks);
+    var columns = document.querySelectorAll('.board-column.dropzone');
+    columns.forEach(function(column) {
+      var status = column.dataset.status || 'TODO';
+      var cardsContainer = column.querySelector('.board-column-cards');
+      if (!cardsContainer) return;
+      cardsContainer.innerHTML = '';
+      (byStatus[status] || []).forEach(function(task) {
+        var frag = createCardFromTask(task);
+        if (frag) cardsContainer.appendChild(frag);
+      });
+      updateColumnBadge(column);
+    });
+    refreshBoardRefManifest();
+  }).catch(function() {});
+}
+
+export function fetchBoardTasksJson() {
+  var ctx = getBoardContext();
+  if (!ctx.project) return Promise.resolve(null);
+  var listUrl = '/tasks/list?' + new URLSearchParams({ project: ctx.project, board: ctx.board }).toString();
+  return fetch(listUrl).then(function(res) {
+    return res.ok ? res.json() : null;
+  });
+}
+
+export function reorderBoardDomToTaskList(tasks) {
+  if (!Array.isArray(tasks)) return;
+  var byStatus = groupTasksByStatus(tasks);
+  document.querySelectorAll('.board-column.dropzone').forEach(function(column) {
+    var status = column.dataset.status || 'TODO';
+    var cardsContainer = column.querySelector('.board-column-cards');
+    if (!cardsContainer) return;
+    var ordered = byStatus[status] || [];
+    ordered.forEach(function(task) {
+      if (!task.ref) return;
+      var card = findBoardCardByTaskRef(task.ref);
+      if (card) cardsContainer.appendChild(card);
+    });
+    updateColumnBadge(column);
+  });
+}
+
+function addDefaultCard(ev) {
+  if (ev.target.closest && ev.target.closest('.board-column-task-count')) return;
+  var template = document.getElementById('board-card-template');
+  if (!template || !template.content) return;
+  var column = ev.currentTarget.closest('.board-column');
+  var cardsContainer = column && column.querySelector('.board-column-cards');
+  var boardColumns = document.querySelector('.board-columns');
+  if (!cardsContainer || !boardColumns) return;
+  var ctx = getBoardContext();
+  var project = ctx.project;
+  var board = ctx.board;
+  if (!project) return;
+  var clone = template.content.cloneNode(true);
+  var card = clone.querySelector && clone.querySelector('.board-card');
+  if (!card) return;
+  var titleEl = card.querySelector('.board-card-title');
+  if (titleEl) titleEl.textContent = 'New task';
+  syncCardTitleLineTooltip(card);
+  assignCloudIconToCard(card);
+  bindCompactAssigneeEditable(card);
+  cardsContainer.appendChild(clone);
+  var countBadge = column.querySelector('.board-column-task-count');
+  if (countBadge) {
+    countBadge.textContent = cardsContainer.querySelectorAll('.board-card').length;
+  }
+  var status = column.dataset.status || 'TODO';
+  setCardIcon(card, 'cloud-up');
+  var createUrl = '/tasks/create?' + new URLSearchParams({
+    project: project,
+    board: board,
+    title: 'New task',
+    description: '',
+    status: status,
+    priority: 'LOW',
+    assignee: '',
+    due_date: ''
+  }).toString();
+  fetch(createUrl, { method: 'POST' }).then(function(res) {
+    if (res.ok) {
+      return res.json().then(function(body) {
+        setCardIcon(card, 'cloud-filled');
+        if (body.ref) {
+          card.dataset.taskRef = body.ref;
+          setCardTaskRefDisplay(card, body.ref);
+        }
+      });
+    }
+    if (res.status >= 400 && res.status < 600) {
+      setCardIcon(card, 'cloud-exclamation');
+    }
+  }).catch(function() {
+    setCardIcon(card, 'cloud-exclamation');
+  });
+}
+
+export function bindAddCardButtons() {
+  document.querySelectorAll('.board-column-add-card').forEach(function(btn) {
+    btn.addEventListener('click', addDefaultCard);
+  });
+}
diff --git a/pygitweb/static/board-comments.js b/pygitweb/static/board-comments.js
new file mode 100644
index 0000000..713e588
--- /dev/null
+++ b/pygitweb/static/board-comments.js
@@ -0,0 +1,164 @@
+import { getBoardContext, showBoardError } from "./board-context.js";
+import { attachBoardRefAutocomplete, setMarkdownElement } from "./board-markdown.js";
+
+var boardAuth = { auth_enabled: false, username: null };
+
+export function loadBoardAuthStatus() {
+  return fetch('/auth/status', { credentials: 'same-origin' })
+    .then(function(res) { return res.ok ? res.json() : null; })
+    .then(function(s) {
+      if (!s) return;
+      boardAuth.auth_enabled = !!s.auth_enabled;
+      boardAuth.username = s.username || null;
+    })
+    .catch(function() {});
+}
+
+function canAddComment() {
+  return !boardAuth.auth_enabled || !!boardAuth.username;
+}
+
+export function commentCountLabel(n) {
+  return n + ' comment' + (n !== 1 ? 's' : '');
+}
+
+export function updateCardCommentCount(card, count) {
+  var n = count || 0;
+  var meta = card.querySelector('.board-card-meta');
+  if (meta) {
+    var commentsSpan = meta.querySelector('.board-card-comments-count-meta') || meta.querySelector('.ms-auto');
+    if (commentsSpan) commentsSpan.textContent = commentCountLabel(n);
+  }
+}
+
+function commentAuthorInitials(author) {
+  var parts = (author || '').trim().split(/\s+/).filter(Boolean);
+  if (!parts.length) return '?';
+  if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
+  return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
+}
+
+function commentAvatarHtml(comment) {
+  var wrap = document.createElement('span');
+  wrap.className = 'board-card-comment-avatar';
+  if (comment.gravatar_url) {
+    var img = document.createElement('img');
+    img.src = comment.gravatar_url;
+    img.alt = '';
+    img.width = 32;
+    img.height = 32;
+    img.onerror = function() {
+      wrap.replaceChildren();
+      var fb = document.createElement('span');
+      fb.className = 'board-card-comment-avatar-fallback';
+      fb.textContent = commentAuthorInitials(comment.author);
+      wrap.appendChild(fb);
+    };
+    wrap.appendChild(img);
+  } else {
+    var fb = document.createElement('span');
+    fb.className = 'board-card-comment-avatar-fallback';
+    fb.textContent = commentAuthorInitials(comment.author);
+    wrap.appendChild(fb);
+  }
+  return wrap;
+}
+
+function renderCommentsList(card, comments) {
+  var list = card.querySelector('.board-card-comments-list');
+  if (!list) return;
+  list.innerHTML = '';
+  if (!Array.isArray(comments) || !comments.length) return;
+  comments.forEach(function(comment) {
+    var row = document.createElement('div');
+    row.className = 'board-card-comment-item';
+    row.appendChild(commentAvatarHtml(comment));
+    var body = document.createElement('div');
+    body.className = 'board-card-comment-body';
+    var authorEl = document.createElement('div');
+    authorEl.className = 'board-card-comment-author';
+    authorEl.textContent = comment.author || 'Unknown';
+    var contentEl = document.createElement('div');
+    contentEl.className = 'board-card-comment-content';
+    setMarkdownElement(contentEl, comment.content || '');
+    body.appendChild(authorEl);
+    body.appendChild(contentEl);
+    row.appendChild(body);
+    list.appendChild(row);
+  });
+}
+
+function syncCommentFormVisibility(card) {
+  var form = card.querySelector('.board-card-comment-form');
+  var hint = card.querySelector('.board-card-comment-signin-hint');
+  if (!form || !hint) return;
+  if (canAddComment()) {
+    form.classList.remove('d-none');
+    hint.classList.add('d-none');
+  } else {
+    form.classList.add('d-none');
+    hint.classList.remove('d-none');
+  }
+}
+
+export function loadCommentsForCard(card) {
+  var taskRef = card.dataset.taskRef;
+  if (!taskRef) return Promise.resolve();
+  var ctx = getBoardContext();
+  if (!ctx.project) return Promise.resolve();
+  var listUrl = '/comments/list?' + new URLSearchParams({
+    project: ctx.project,
+    board: ctx.board,
+    task: taskRef
+  }).toString();
+  return fetch(listUrl, { credentials: 'same-origin' })
+    .then(function(res) { return res.ok ? res.json() : []; })
+    .then(function(comments) {
+      if (!Array.isArray(comments)) comments = [];
+      renderCommentsList(card, comments);
+      updateCardCommentCount(card, comments.length);
+      return comments;
+    })
+    .catch(function() { return []; });
+}
+
+export function setupCommentForm(popoutCard) {
+  var form = popoutCard.querySelector('.board-card-comment-form');
+  if (!form || form.dataset.bound === '1') return;
+  form.dataset.bound = '1';
+  syncCommentFormVisibility(popoutCard);
+  var commentInput = form.querySelector('.board-card-comment-input');
+  attachBoardRefAutocomplete(commentInput);
+  form.addEventListener('submit', function(e) {
+    e.preventDefault();
+    if (!canAddComment()) return;
+    var taskRef = popoutCard.dataset.taskRef;
+    if (!taskRef) return;
+    var input = form.querySelector('.board-card-comment-input');
+    var content = (input && input.value) ? input.value.trim() : '';
+    if (!content) return;
+    var ctx = getBoardContext();
+    if (!ctx.project) return;
+    var createUrl = '/comments/create?' + new URLSearchParams({
+      project: ctx.project,
+      board: ctx.board,
+      task: taskRef,
+      content: content
+    }).toString();
+    fetch(createUrl, { method: 'POST', credentials: 'same-origin' })
+      .then(function(res) {
+        if (!res.ok) {
+          var msg = (res.status === 401 || res.status === 403)
+            ? 'Not authorized to add comments (sign in again if needed).'
+            : 'Could not post comment.';
+          showBoardError(msg);
+          return null;
+        }
+        if (input) input.value = '';
+        return loadCommentsForCard(popoutCard);
+      })
+      .catch(function() {
+        showBoardError('Could not post comment.');
+      });
+  });
+}
diff --git a/pygitweb/static/board-context.js b/pygitweb/static/board-context.js
new file mode 100644
index 0000000..79aa27a
--- /dev/null
+++ b/pygitweb/static/board-context.js
@@ -0,0 +1,121 @@
+import { loadManifest } from "./board-refs.js";
+
+var boardRefManifest = null;
+
+export function getBoardRefManifest() {
+  return boardRefManifest;
+}
+
+export function refreshBoardRefManifest() {
+  var ctx = getBoardContext();
+  if (!ctx.project) return Promise.resolve(null);
+  return loadManifest(ctx.project, ctx.board)
+    .then(function (manifest) {
+      boardRefManifest = manifest;
+      return manifest;
+    })
+    .catch(function () {
+      boardRefManifest = { commits: [], refs: [] };
+      return boardRefManifest;
+    });
+}
+
+export function taskDisplayId(ref) {
+  if (!ref) return '';
+  var slug = ref.replace(/^refs\/tags\/tasks\//, '');
+  if (slug.indexOf('task_') === 0) return slug.slice(5);
+  return slug;
+}
+
+export function taskDisplayIdShort(fullId) {
+  if (!fullId) return '';
+  return fullId.length <= 4 ? fullId : fullId.slice(-4);
+}
+
+export function syncCardTitleLineTooltip(card) {
+  var titleLine = card.querySelector('.board-card-title-line');
+  if (!titleLine) return;
+  var titleEl = card.querySelector('.board-card-title');
+  var titleText = ((titleEl && titleEl.textContent) || '').trim() || 'Untitled';
+  var fullId = card.dataset.taskRef ? taskDisplayId(card.dataset.taskRef) : '';
+  titleLine.title = fullId ? (fullId + ': ' + titleText) : titleText;
+}
+
+export function setCardTaskRefDisplay(card, ref, id) {
+  var fullId = id || taskDisplayId(ref);
+  var idEl = card.querySelector('.board-card-task-id');
+  if (idEl) idEl.textContent = taskDisplayIdShort(fullId);
+  var refEl = card.querySelector('.board-card-task-ref');
+  if (refEl) refEl.textContent = ref || '';
+  syncCardTitleLineTooltip(card);
+}
+
+export function getBoardContext() {
+  var boardColumns = document.querySelector('.board-columns');
+  var project = boardColumns && boardColumns.dataset.project;
+  var board = boardColumns && boardColumns.dataset.board;
+  if (!project && location.pathname) {
+    var m = location.pathname.match(/^\/project\/(.+?)\/board\/?$/);
+    if (m) project = m[1];
+  }
+  if (!board) board = (new URLSearchParams(location.search)).get('b') || 'Tasks';
+  return { project: project || '', board: board || 'Tasks' };
+}
+
+export function findBoardCardByTaskRef(taskRef) {
+  if (!taskRef) return null;
+  var cards = document.querySelectorAll('.board-card');
+  for (var i = 0; i < cards.length; i++) {
+    if (cards[i].dataset.taskRef === taskRef) return cards[i];
+  }
+  return null;
+}
+
+export function showBoardError(message) {
+  var boardColumns = document.querySelector('.board-columns');
+  var anchor = boardColumns && boardColumns.parentNode;
+  if (!anchor) return;
+  var existing = document.querySelector('.board-task-error-hint');
+  if (existing) existing.remove();
+  var el = document.createElement('div');
+  el.className = 'alert board-task-error-hint py-2 px-3 mb-2';
+  el.setAttribute('role', 'alert');
+  el.textContent = message;
+  anchor.insertBefore(el, boardColumns);
+  window.setTimeout(function() {
+    if (el.parentNode) el.remove();
+  }, 8000);
+}
+
+export function updateColumnBadge(column) {
+  var cardsContainer = column && column.querySelector('.board-column-cards');
+  var countBadge = column && column.querySelector('.board-column-task-count');
+  var pointsBadge = column && column.querySelector('.board-column-points-total');
+  if (!cardsContainer) return;
+  var cards = cardsContainer.querySelectorAll('.board-card');
+  if (countBadge) countBadge.textContent = cards.length;
+  if (pointsBadge) {
+    var total = 0;
+    cards.forEach(function(c) {
+      var v = c.dataset.taskPoints;
+      if (v) total += parseInt(v, 10) || 0;
+    });
+    pointsBadge.textContent = total + ' pts';
+  }
+}
+
+export function pushTaskFieldUpdate(card, fields) {
+  var taskRef = card && card.dataset.taskRef;
+  if (!taskRef) return Promise.resolve({ ok: false, status: 0 });
+  var ctx = getBoardContext();
+  if (!ctx.project) return Promise.resolve({ ok: false, status: 0 });
+  return fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(fields)
+  }).then(function(res) {
+    return { ok: res.ok, status: res.status };
+  }).catch(function() {
+    return { ok: false, status: 0 };
+  });
+}
diff --git a/pygitweb/static/board-dnd.js b/pygitweb/static/board-dnd.js
new file mode 100644
index 0000000..0580311
--- /dev/null
+++ b/pygitweb/static/board-dnd.js
@@ -0,0 +1,154 @@
+import {
+  findBoardCardByTaskRef,
+  getBoardContext,
+  showBoardError,
+  updateColumnBadge,
+} from "./board-context.js";
+import { fetchBoardTasksJson, reorderBoardDomToTaskList } from "./board-cards.js";
+import { showCardPopout } from "./board-popout.js";
+
+var draggedCard = null;
+var didDrag = false;
+
+function boardRoot() {
+  return document.querySelector('.board-columns');
+}
+
+function clearColumnDropHighlight() {
+  document.querySelectorAll('.board-column.dropzone.border-primary').forEach(function(el) {
+    el.classList.remove('border-primary');
+  });
+}
+
+function dragTransferLooksLikeTask(e) {
+  if (draggedCard) return true;
+  var types = e.dataTransfer && e.dataTransfer.types;
+  if (!types || !types.length) return false;
+  for (var i = 0; i < types.length; i++) {
+    if (types[i] === 'text/plain' || types[i] === 'Text') return true;
+  }
+  return false;
+}
+
+function handleDragStart(e) {
+  var card = e.target.closest && e.target.closest('.board-card');
+  if (!card) return;
+  didDrag = true;
+  draggedCard = card;
+  e.dataTransfer.effectAllowed = 'move';
+  try {
+    e.dataTransfer.setData('text/plain', card.dataset.taskRef || '');
+  } catch (err) {
+    /* ignore setData failures in restricted environments */
+  }
+  card.classList.add('opacity-50');
+}
+
+function handleDragEnd(e) {
+  var card = (draggedCard && draggedCard.classList.contains('opacity-50') ? draggedCard : null) ||
+    (e.target.closest && e.target.closest('.board-card'));
+  if (card) card.classList.remove('opacity-50');
+  clearColumnDropHighlight();
+  window.setTimeout(function() {
+    draggedCard = null;
+    didDrag = false;
+  }, 0);
+}
+
+function handleCardClick(e) {
+  if (didDrag) return;
+  var card = e.target.closest && e.target.closest('.board-card');
+  if (!card || card.classList.contains('board-card-popout')) return;
+  e.preventDefault();
+  showCardPopout(card);
+}
+
+function handleBoardDragOver(e) {
+  var root = boardRoot();
+  if (!root || !root.contains(e.target)) return;
+  var col = e.target.closest && e.target.closest('.board-column.dropzone');
+  if (!col || !dragTransferLooksLikeTask(e)) return;
+  e.preventDefault();
+  e.dataTransfer.dropEffect = 'move';
+  clearColumnDropHighlight();
+  col.classList.add('border-primary');
+}
+
+function pushTaskStatusUpdate(card, newStatus) {
+  var taskRef = card && card.dataset.taskRef;
+  if (!taskRef) return Promise.resolve({ ok: false, status: 0 });
+  var ctx = getBoardContext();
+  if (!ctx.project) return Promise.resolve({ ok: false, status: 0 });
+  return fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ status: newStatus || 'TODO' })
+  }).then(function(res) {
+    return { ok: res.ok, status: res.status };
+  }).catch(function() {
+    return { ok: false, status: 0 };
+  });
+}
+
+function handleBoardDrop(e) {
+  var root = boardRoot();
+  var col = e.target.closest && e.target.closest('.board-column.dropzone');
+  if (!col || !root || !root.contains(e.target)) return;
+  e.preventDefault();
+  e.stopPropagation();
+  clearColumnDropHighlight();
+  var taskRef = '';
+  try {
+    taskRef = e.dataTransfer.getData('text/plain') || '';
+  } catch (err2) {
+    taskRef = '';
+  }
+  var card = draggedCard || findBoardCardByTaskRef(taskRef);
+  if (!card) return;
+  var targetColumn = col;
+  var targetCards = targetColumn.querySelector('.board-column-cards');
+  if (!targetCards) return;
+  var sourceColumn = card.closest && card.closest('.board-column');
+  if (sourceColumn === targetColumn) return;
+  var newStatus = targetColumn.dataset.status || 'TODO';
+  var prevStatus = card.dataset.taskStatus || 'TODO';
+  var sourceCards = sourceColumn && sourceColumn.querySelector('.board-column-cards');
+  var nextSibling = card.nextSibling;
+  card.dataset.taskStatus = newStatus;
+  targetCards.appendChild(card);
+  updateColumnBadge(sourceColumn);
+  updateColumnBadge(targetColumn);
+  pushTaskStatusUpdate(card, newStatus).then(function(result) {
+    if (result.ok) {
+      fetchBoardTasksJson().then(function(tasks) {
+        if (tasks) reorderBoardDomToTaskList(tasks);
+      });
+      return;
+    }
+    card.dataset.taskStatus = prevStatus;
+    if (sourceCards) {
+      if (nextSibling && nextSibling.parentNode === sourceCards) {
+        sourceCards.insertBefore(card, nextSibling);
+      } else {
+        sourceCards.appendChild(card);
+      }
+    }
+    updateColumnBadge(sourceColumn);
+    updateColumnBadge(targetColumn);
+    var st = result.status;
+    var msg = (st === 401 || st === 403)
+      ? 'Not authorized to update tasks (sign in again if needed). The card was moved back.'
+      : 'Could not save the task move. The card was moved back.';
+    showBoardError(msg);
+  });
+}
+
+export function bindBoardInteractions() {
+  var root = boardRoot();
+  if (!root) return;
+  root.addEventListener('dragstart', handleDragStart);
+  root.addEventListener('dragend', handleDragEnd);
+  root.addEventListener('click', handleCardClick);
+  root.addEventListener('dragover', handleBoardDragOver, true);
+  root.addEventListener('drop', handleBoardDrop, true);
+}
diff --git a/pygitweb/static/board-markdown.js b/pygitweb/static/board-markdown.js
new file mode 100644
index 0000000..722f633
--- /dev/null
+++ b/pygitweb/static/board-markdown.js
@@ -0,0 +1,84 @@
+import { whenReady } from "./dom.js";
+import { attachAutocomplete, linkifyElement } from "./board-refs.js";
+import { getBoardContext, getBoardRefManifest } from "./board-context.js";
+
+var boardMarkdownConverter = null;
+
+export function linkifyMarkdownElement(el) {
+  if (!el || !getBoardRefManifest()) return;
+  var ctx = getBoardContext();
+  if (!ctx.project) return;
+  linkifyElement(el, getBoardRefManifest(), ctx.project);
+}
+
+export function attachBoardRefAutocomplete(textarea) {
+  if (!textarea || !getBoardRefManifest()) return;
+  attachAutocomplete(textarea, getBoardRefManifest());
+}
+
+function getBoardMarkdownConverter() {
+  if (!boardMarkdownConverter && typeof showdown !== 'undefined') {
+    boardMarkdownConverter = new showdown.Converter({ ghCompatibleHeaderId: true });
+  }
+  return boardMarkdownConverter;
+}
+
+function markdownToHtml(source) {
+  var conv = getBoardMarkdownConverter();
+  if (!conv) return null;
+  return conv.makeHtml(source || '');
+}
+
+function htmlToPlainPreview(html) {
+  var tmp = document.createElement('div');
+  tmp.innerHTML = html;
+  return (tmp.textContent || '').replace(/\s+/g, ' ').trim();
+}
+
+function markdownPlainPreview(source, maxLen) {
+  var md = source || '';
+  var html = markdownToHtml(md);
+  var text = html !== null ? htmlToPlainPreview(html) : md.replace(/\s+/g, ' ').trim();
+  if (maxLen && text.length > maxLen) return text.slice(0, maxLen) + '\u2026';
+  return text;
+}
+
+export function getMarkdownFromElement(el) {
+  if (!el) return '';
+  if (Object.prototype.hasOwnProperty.call(el.dataset, 'boardMarkdown')) {
+    return el.dataset.boardMarkdown;
+  }
+  return (el.textContent || '').trim();
+}
+
+export function setMarkdownElement(el, source) {
+  if (!el) return;
+  var md = source || '';
+  el.dataset.boardMarkdown = md;
+  el.classList.add('markdown-body', 'board-card-markdown');
+  function apply() {
+    var html = markdownToHtml(md);
+    if (html !== null) {
+      el.innerHTML = html;
+      linkifyMarkdownElement(el);
+    } else {
+      el.textContent = md;
+    }
+  }
+  if (typeof showdown !== 'undefined') {
+    apply();
+  } else {
+    el.textContent = md;
+    whenReady('Showdown-script', 'showdown', apply);
+  }
+}
+
+export function syncCompactDescription(card, source) {
+  var preview = card.querySelector('.board-card-description-preview');
+  var full = card.querySelector('.board-card-description-full');
+  var md = source !== undefined ? source : getMarkdownFromElement(full);
+  if (preview) {
+    preview.textContent = md ? markdownPlainPreview(md, 120) : '';
+    preview.dataset.boardMarkdown = md;
+  }
+}
diff --git a/pygitweb/static/board-popout.js b/pygitweb/static/board-popout.js
new file mode 100644
index 0000000..188369c
--- /dev/null
+++ b/pygitweb/static/board-popout.js
@@ -0,0 +1,431 @@
+import {
+  getBoardContext,
+  showBoardError,
+  syncCardTitleLineTooltip,
+  updateColumnBadge,
+} from "./board-context.js";
+import {
+  attachBoardRefAutocomplete,
+  getMarkdownFromElement,
+  linkifyMarkdownElement,
+  setMarkdownElement,
+  syncCompactDescription,
+} from "./board-markdown.js";
+import { loadCommentsForCard, setupCommentForm } from "./board-comments.js";
+import { loadTasksForBoard } from "./board-cards.js";
+
+function parseDateForInput(text) {
+  if (!text || text.trim() === 'None') return '';
+  var s = text.trim();
+  var m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
+  return m ? m[0] : '';
+}
+
+function setupPopoutEditables(popoutCard) {
+  var titleEl = popoutCard.querySelector('.board-card-title');
+  var descEl = popoutCard.querySelector('.board-card-description-full');
+  var dueEl = popoutCard.querySelector('.board-card-due-date');
+  var statusEl = popoutCard.querySelector('.board-card-status');
+  var priorityEl = popoutCard.querySelector('.board-card-priority');
+  var tagsEl = popoutCard.querySelector('.board-card-tags');
+  var pointsEl = popoutCard.querySelector('.board-card-points');
+  var assigneeEl = popoutCard.querySelector('.board-card-assignee');
+  var FIBONACCI_POINTS = ['–', '0', '1', '2', '3', '5', '8', '13', '21'];
+
+  function pushTaskUpdate() {
+    var taskRef = popoutCard.dataset.taskRef;
+    if (!taskRef) return;
+    var ctx = getBoardContext();
+    if (!ctx.project) return;
+    var tEl = popoutCard.querySelector('.board-card-title');
+    var dEl = popoutCard.querySelector('.board-card-description-full');
+    var dueEl_ = popoutCard.querySelector('.board-card-due-date');
+    var sEl = popoutCard.querySelector('.board-card-status');
+    var pEl = popoutCard.querySelector('.board-card-priority');
+    var ptEl = popoutCard.querySelector('.board-card-points');
+    var aEl = popoutCard.querySelector('.board-card-assignee');
+    var tagsEl_ = popoutCard.querySelector('.board-card-tags');
+    var dueVal = (dueEl_ && dueEl_.textContent) ? dueEl_.textContent.trim() : '';
+    var statusDisplay = (sEl && sEl.textContent) ? sEl.textContent.trim() : 'TODO';
+    var priorityDisplay = (pEl && pEl.textContent) ? pEl.textContent.trim() : 'LOW';
+    var tagsVal = (tagsEl_ && tagsEl_.textContent) ? tagsEl_.textContent.trim() : '';
+    var pointsDisplay = (ptEl && ptEl.textContent) ? ptEl.textContent.trim() : '–';
+    var body = {
+      title: (tEl && tEl.textContent) ? tEl.textContent.trim() || 'Untitled' : 'Untitled',
+      description: getMarkdownFromElement(dEl),
+      status: statusDisplay ? statusDisplay.replace(/\s+/g, '_').toUpperCase() : 'TODO',
+      priority: priorityDisplay ? priorityDisplay.replace(/\s+/g, '_').toUpperCase() : 'LOW',
+      points: (pointsDisplay && pointsDisplay !== '–') ? parseInt(pointsDisplay, 10) : null,
+      assignee: (aEl && aEl.textContent && aEl.textContent.trim() !== 'None') ? aEl.textContent.trim() : null,
+      due_date: (dueVal && dueVal !== 'None') ? dueVal : null,
+      tags: tagsVal || ''
+    };
+    fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body)
+    }).then(function() {
+      var card = popoutCard.closest ? popoutCard : null;
+      if (card) {
+        card.dataset.taskPoints = (body.points !== null && body.points !== undefined) ? String(body.points) : '';
+        var col = card.closest('.board-column');
+        if (col) updateColumnBadge(col);
+      }
+    }).catch(function() {});
+  }
+
+  if (titleEl) titleEl.classList.add('board-card-editable');
+  function commitTitle() {
+    var input = popoutCard.querySelector('.board-card-title-edit');
+    if (!input) return;
+    var val = (input.value || '').trim() || 'Untitled';
+    titleEl.textContent = val;
+    titleEl.classList.remove('d-none');
+    input.remove();
+    syncCardTitleLineTooltip(popoutCard);
+    pushTaskUpdate();
+  }
+  if (titleEl) {
+    titleEl.addEventListener('click', function () {
+      if (popoutCard.querySelector('.board-card-title-edit')) return;
+      var val = (titleEl.textContent || '').trim();
+      var input = document.createElement('input');
+      input.type = 'text';
+      input.className = 'form-control form-control-sm board-card-title-edit';
+      input.value = val;
+      titleEl.classList.add('d-none');
+      titleEl.parentNode.insertBefore(input, titleEl);
+      input.focus();
+      input.addEventListener('blur', commitTitle);
+      input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
+    });
+  }
+
+  function commitDescription() {
+    var textarea = popoutCard.querySelector('.board-card-description-edit');
+    if (!textarea) return;
+    var val = (textarea.value || '').trim();
+    setMarkdownElement(descEl, val);
+    syncCompactDescription(popoutCard, val);
+    descEl.classList.remove('d-none');
+    textarea.remove();
+    pushTaskUpdate();
+  }
+  if (descEl) {
+    descEl.addEventListener('click', function () {
+      if (popoutCard.querySelector('.board-card-description-edit')) return;
+      var val = getMarkdownFromElement(descEl);
+      var textarea = document.createElement('textarea');
+      textarea.className = 'form-control form-control-sm board-card-description-edit mt-1';
+      textarea.rows = 3;
+      textarea.value = val;
+      descEl.classList.add('d-none');
+      descEl.parentNode.insertBefore(textarea, descEl);
+      attachBoardRefAutocomplete(textarea);
+      textarea.focus();
+      textarea.addEventListener('blur', commitDescription);
+    });
+  }
+
+  function commitDueDate() {
+    var input = popoutCard.querySelector('.board-card-due-date-edit');
+    if (!input) return;
+    var val = (input.value || '').trim();
+    dueEl.textContent = val || 'None';
+    dueEl.classList.remove('d-none');
+    input.remove();
+    pushTaskUpdate();
+  }
+  if (dueEl) {
+    dueEl.addEventListener('click', function () {
+      if (popoutCard.querySelector('.board-card-due-date-edit')) return;
+      var val = parseDateForInput(dueEl.textContent);
+      var input = document.createElement('input');
+      input.type = 'date';
+      input.className = 'form-control form-control-sm board-card-due-date-edit mt-1';
+      input.value = val;
+      dueEl.classList.add('d-none');
+      dueEl.parentNode.insertBefore(input, dueEl);
+      input.focus();
+      input.addEventListener('blur', commitDueDate);
+    });
+  }
+
+  function commitStatus() {
+    var sel = popoutCard.querySelector('.board-card-status-edit');
+    if (!sel) return;
+    var display = (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].text) ? sel.options[sel.selectedIndex].text : (sel.value || 'TODO');
+    statusEl.textContent = display || 'TODO';
+    statusEl.classList.remove('d-none');
+    sel.remove();
+    var statusValue = display ? display.replace(/\s+/g, '_').toUpperCase() : 'TODO';
+    popoutCard.dataset.taskStatus = statusValue;
+    pushTaskUpdate();
+  }
+  if (statusEl) {
+    statusEl.addEventListener('click', function () {
+      if (popoutCard.querySelector('.board-card-status-edit')) return;
+      var current = (statusEl.textContent || '').trim() || 'TODO';
+      var sel = document.createElement('select');
+      sel.className = 'form-select form-select-sm board-card-status-edit mt-1';
+      sel.innerHTML = '';
+      var opt = document.createElement('option');
+      opt.value = current;
+      opt.textContent = current;
+      opt.selected = true;
+      sel.appendChild(opt);
+      statusEl.classList.add('d-none');
+      statusEl.parentNode.insertBefore(sel, statusEl);
+      sel.focus();
+      sel.addEventListener('blur', commitStatus);
+      sel.addEventListener('change', commitStatus);
+    });
+  }
+
+  function commitPriority() {
+    var sel = popoutCard.querySelector('.board-card-priority-edit');
+    if (!sel) return;
+    var display = (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].text) ? sel.options[sel.selectedIndex].text : (sel.value || 'LOW');
+    priorityEl.textContent = display || 'LOW';
+    priorityEl.classList.remove('d-none');
+    sel.remove();
+    pushTaskUpdate();
+  }
+  if (priorityEl) {
+    priorityEl.addEventListener('click', function () {
+      if (popoutCard.querySelector('.board-card-priority-edit')) return;
+      var current = (priorityEl.textContent || '').trim() || 'LOW';
+      var sel = document.createElement('select');
+      sel.className = 'form-select form-select-sm board-card-priority-edit mt-1';
+      sel.innerHTML = '';
+      var opt = document.createElement('option');
+      opt.value = current;
+      opt.textContent = current;
+      opt.selected = true;
+      sel.appendChild(opt);
+      priorityEl.classList.add('d-none');
+      priorityEl.parentNode.insertBefore(sel, priorityEl);
+      sel.focus();
+      sel.addEventListener('blur', commitPriority);
+      sel.addEventListener('change', commitPriority);
+    });
+  }
+
+  if (tagsEl) {
+    tagsEl.addEventListener('blur', function () {
+      pushTaskUpdate();
+    });
+  }
+
+  function commitPoints() {
+    var sel = popoutCard.querySelector('.board-card-points-edit');
+    if (!sel) return;
+    var display = (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].text) ? sel.options[sel.selectedIndex].text : '–';
+    pointsEl.textContent = display;
+    pointsEl.classList.remove('d-none');
+    sel.remove();
+    pushTaskUpdate();
+  }
+  if (pointsEl) {
+    pointsEl.addEventListener('click', function () {
+      if (popoutCard.querySelector('.board-card-points-edit')) return;
+      var current = (pointsEl.textContent || '').trim() || '–';
+      var sel = document.createElement('select');
+      sel.className = 'form-select form-select-sm board-card-points-edit mt-1';
+      FIBONACCI_POINTS.forEach(function (val) {
+        var opt = document.createElement('option');
+        opt.value = val;
+        opt.textContent = val;
+        if (val === current) opt.selected = true;
+        sel.appendChild(opt);
+      });
+      pointsEl.classList.add('d-none');
+      pointsEl.parentNode.insertBefore(sel, pointsEl);
+      sel.focus();
+      sel.addEventListener('blur', commitPoints);
+      sel.addEventListener('change', commitPoints);
+    });
+  }
+
+  function commitAssignee() {
+    var input = popoutCard.querySelector('.board-card-assignee-edit');
+    if (!input) return;
+    var val = (input.value || '').trim();
+    assigneeEl.textContent = val || 'None';
+    assigneeEl.classList.remove('d-none');
+    input.remove();
+    var compactSpan = popoutCard.querySelector('.board-card-assignee-compact');
+    if (compactSpan) compactSpan.textContent = val || 'None';
+    pushTaskUpdate();
+  }
+  if (assigneeEl) {
+    assigneeEl.addEventListener('click', function () {
+      if (popoutCard.querySelector('.board-card-assignee-edit')) return;
+      var current = (assigneeEl.textContent || '').trim();
+      if (current === 'None') current = '';
+      var input = document.createElement('input');
+      input.type = 'text';
+      input.className = 'form-control form-control-sm board-card-assignee-edit mt-1';
+      input.placeholder = 'Unassigned';
+      input.value = current;
+      assigneeEl.classList.add('d-none');
+      assigneeEl.parentNode.insertBefore(input, assigneeEl);
+      input.focus();
+      input.addEventListener('blur', commitAssignee);
+      input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
+    });
+  }
+}
+
+function setupPopoutActions(popoutCard, closePopout, cardsContainer) {
+  var deleteBtn = popoutCard.querySelector('.board-card-delete-btn');
+  var archiveBtn = popoutCard.querySelector('.board-card-archive-btn');
+  var taskRef = popoutCard.dataset.taskRef;
+  if (!taskRef) return;
+  var ctx = getBoardContext();
+  if (!ctx.project) return;
+
+  function removeCardFromBoard() {
+    if (closePopout) closePopout();
+    popoutCard.remove();
+    var column = cardsContainer && cardsContainer.closest('.board-column');
+    if (column) updateColumnBadge(column);
+  }
+
+  if (deleteBtn) {
+    deleteBtn.addEventListener('click', function (e) {
+      e.preventDefault();
+      e.stopPropagation();
+      if (!window.confirm('Delete this task permanently? This cannot be undone.')) return;
+      fetch('/tasks/delete?' + new URLSearchParams({
+        project: ctx.project,
+        board: ctx.board,
+        task: taskRef
+      }).toString(), { method: 'POST', credentials: 'same-origin' })
+        .then(function (res) {
+          if (res.ok) removeCardFromBoard();
+        })
+        .catch(function () {});
+    });
+  }
+
+  if (archiveBtn) {
+    archiveBtn.addEventListener('click', function (e) {
+      e.preventDefault();
+      e.stopPropagation();
+      if (!window.confirm('Archive this task? It will be moved to the Archive board.')) return;
+      fetch('/tasks/archive?' + new URLSearchParams({
+        project: ctx.project,
+        board: ctx.board,
+        task: taskRef
+      }).toString(), { method: 'POST', credentials: 'same-origin' })
+        .then(function (res) {
+          if (res.ok) removeCardFromBoard();
+        })
+        .catch(function () {});
+    });
+  }
+}
+
+function loadBoardOptionsForMove(select, excludeName) {
+  var ctx = getBoardContext();
+  if (!ctx.project) return;
+  fetch('/board/list?' + new URLSearchParams({ project: ctx.project }).toString())
+    .then(function(res) { return res.ok ? res.json() : []; })
+    .then(function(boards) {
+      select.innerHTML = '<option value="">Move to board…</option>';
+      (boards || []).forEach(function(b) {
+        if (b.name === excludeName) return;
+        var opt = document.createElement('option');
+        opt.value = b.name;
+        opt.textContent = b.name;
+        select.appendChild(opt);
+      });
+    })
+    .catch(function() {});
+}
+
+function setupMoveControl(popoutCard) {
+  var select = popoutCard.querySelector('.board-card-move-select');
+  if (!select) return;
+  var ctx = getBoardContext();
+  loadBoardOptionsForMove(select, ctx.board);
+  if (select.dataset.bound === '1') return;
+  select.dataset.bound = '1';
+  select.addEventListener('change', function() {
+    var target = select.value;
+    var taskRef = popoutCard.dataset.taskRef;
+    if (!target || !taskRef) return;
+    var ctx2 = getBoardContext();
+    if (!ctx2.project) return;
+    fetch('/tasks/move?' + new URLSearchParams({ project: ctx2.project, board: ctx2.board, task: taskRef, target: target }).toString(), {
+      method: 'POST',
+      credentials: 'same-origin'
+    }).then(function(res) {
+      if (!res.ok) {
+        var msg = (res.status === 401 || res.status === 403)
+          ? 'Not authorized to move tasks (sign in again if needed).'
+          : 'Could not move task.';
+        showBoardError(msg);
+        select.value = '';
+        return;
+      }
+      var overlay = popoutCard.closest('.board-card-popout-overlay');
+      if (overlay && overlay.parentNode) {
+        overlay.parentNode.removeChild(overlay);
+      }
+      loadTasksForBoard();
+    }).catch(function() {
+      showBoardError('Could not move task.');
+      select.value = '';
+    });
+  });
+}
+
+export function showCardPopout(card) {
+  var cardsContainer = card.parentNode;
+  var nextSibling = card.nextSibling;
+  card.classList.add('board-card-popout');
+  card.draggable = false;
+  card.removeAttribute('draggable');
+  var details = card.querySelector('.board-card-details');
+  if (details) details.classList.remove('d-none');
+  setupPopoutEditables(card);
+  setupCommentForm(card);
+  setupMoveControl(card);
+  var descFull = card.querySelector('.board-card-description-full');
+  if (descFull && !Object.prototype.hasOwnProperty.call(descFull.dataset, 'boardMarkdown')) {
+    setMarkdownElement(descFull, getMarkdownFromElement(descFull));
+    syncCompactDescription(card);
+  } else if (descFull) {
+    linkifyMarkdownElement(descFull);
+  }
+  document.querySelectorAll('.board-card-comment-content.board-card-markdown').forEach(function (el) {
+    if (card.contains(el)) linkifyMarkdownElement(el);
+  });
+  loadCommentsForCard(card);
+  var overlay = document.createElement('div');
+  overlay.className = 'board-card-popout-overlay';
+  overlay.appendChild(card);
+  function close() {
+    overlay.removeEventListener('click', closeOnBackdrop);
+    document.body.removeChild(overlay);
+    card.classList.remove('board-card-popout');
+    card.setAttribute('draggable', 'true');
+    card.draggable = true;
+    if (details) details.classList.add('d-none');
+    if (nextSibling) {
+      cardsContainer.insertBefore(card, nextSibling);
+    } else {
+      cardsContainer.appendChild(card);
+    }
+    updateColumnBadge(cardsContainer.closest('.board-column'));
+  }
+  function closeOnBackdrop(e) {
+    if (e.target === overlay) close();
+  }
+  setupPopoutActions(card, close, cardsContainer);
+  overlay.addEventListener('click', closeOnBackdrop);
+  document.body.appendChild(overlay);
+  updateColumnBadge(cardsContainer.closest('.board-column'));
+}
diff --git a/pygitweb/static/board-refs.js b/pygitweb/static/board-refs.js
index 6308e2f..b6a3b04 100644
--- a/pygitweb/static/board-refs.js
+++ b/pygitweb/static/board-refs.js
@@ -1,7 +1,6 @@
-(function () {
-  "use strict";
+import { escapeHtml } from "./dom.js";
 
-  var REF_TOKEN =
+var REF_TOKEN =
     /(?:^|[\s(])(#([0-9a-fA-F]{4,40})|@([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*))/g;
   var SUGGEST_LIMIT = 12;
 
@@ -17,14 +16,6 @@
     );
   }
 
-  function escapeHtml(value) {
-    return String(value)
-      .replace(/&/g, "&amp;")
-      .replace(/</g, "&lt;")
-      .replace(/>/g, "&gt;")
-      .replace(/"/g, "&quot;");
-  }
-
   function splitReferenceText(text) {
     var parts = [];
     var last = 0;
@@ -98,7 +89,7 @@
     return null;
   }
 
-  function linkifyElement(root, manifest, project) {
+  export function linkifyElement(root, manifest, project) {
     if (!root || !project) return;
     var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
     var textNodes = [];
@@ -162,7 +153,7 @@
       .slice(0, SUGGEST_LIMIT);
   }
 
-  function loadManifest(project, board) {
+  export function loadManifest(project, board) {
     var url =
       projectPath(project) +
       "/board-ref-manifest?" +
@@ -182,7 +173,7 @@
       });
   }
 
-  function attachAutocomplete(textarea, manifest) {
+  export function attachAutocomplete(textarea, manifest) {
     if (!textarea || textarea.dataset.boardRefAutocomplete === "1") return;
     textarea.dataset.boardRefAutocomplete = "1";
     var menu = document.createElement("div");
@@ -290,13 +281,3 @@
       hideMenu();
     });
   }
-
-  window.BoardRefs = {
-    loadManifest: loadManifest,
-    linkifyElement: linkifyElement,
-    attachAutocomplete: attachAutocomplete,
-    resolveRef: resolveRef,
-    resolveCommit: resolveCommit,
-    filterManifestItems: filterManifestItems,
-  };
-})();
diff --git a/pygitweb/static/board.js b/pygitweb/static/board.js
index c28948a..0f563bf 100644
--- a/pygitweb/static/board.js
+++ b/pygitweb/static/board.js
@@ -1,1288 +1,94 @@
-(function() {
-  var template = document.getElementById('board-card-template');
-  var addCardButtons = document.querySelectorAll('.board-column-add-card');
-
-  var ICONS_BASE = '/static/icons/';
-  var ICON_LIST = ['cloud.svg', 'cloud-filled.svg', 'cloud-storm.svg', 'cloud-down.svg', 'cloud-up.svg', 'cloud-exclamation.svg'];
-  var boardMarkdownConverter = null;
-  var boardRefManifest = null;
-
-  function refreshBoardRefManifest() {
-    var ctx = getBoardContext();
-    if (!ctx.project || !window.BoardRefs) return Promise.resolve(null);
-    return BoardRefs.loadManifest(ctx.project, ctx.board)
-      .then(function (manifest) {
-        boardRefManifest = manifest;
-        return manifest;
-      })
-      .catch(function () {
-        boardRefManifest = { commits: [], refs: [] };
-        return boardRefManifest;
-      });
-  }
-
-  function linkifyMarkdownElement(el) {
-    if (!el || !window.BoardRefs || !boardRefManifest) return;
-    var ctx = getBoardContext();
-    if (!ctx.project) return;
-    BoardRefs.linkifyElement(el, boardRefManifest, ctx.project);
-  }
-
-  function attachBoardRefAutocomplete(textarea) {
-    if (!textarea || !window.BoardRefs || !boardRefManifest) return;
-    BoardRefs.attachAutocomplete(textarea, boardRefManifest);
-  }
-
-  function whenShowdownReady(cb) {
-    if (typeof showdown !== 'undefined') {
-      cb();
-      return;
-    }
-    var script = document.getElementById('Showdown-script');
-    if (!script) return;
-    function run() {
-      if (typeof showdown !== 'undefined') cb();
-    }
-    script.addEventListener('load', run);
-    if (script.getAttribute('data-loaded')) run();
-  }
-
-  function getBoardMarkdownConverter() {
-    if (!boardMarkdownConverter && typeof showdown !== 'undefined') {
-      boardMarkdownConverter = new showdown.Converter({ ghCompatibleHeaderId: true });
-    }
-    return boardMarkdownConverter;
-  }
-
-  function markdownToHtml(source) {
-    var conv = getBoardMarkdownConverter();
-    if (!conv) return null;
-    return conv.makeHtml(source || '');
-  }
-
-  function htmlToPlainPreview(html) {
-    var tmp = document.createElement('div');
-    tmp.innerHTML = html;
-    return (tmp.textContent || '').replace(/\s+/g, ' ').trim();
-  }
-
-  function markdownPlainPreview(source, maxLen) {
-    var md = source || '';
-    var html = markdownToHtml(md);
-    var text = html !== null ? htmlToPlainPreview(html) : md.replace(/\s+/g, ' ').trim();
-    if (maxLen && text.length > maxLen) return text.slice(0, maxLen) + '\u2026';
-    return text;
-  }
-
-  function getMarkdownFromElement(el) {
-    if (!el) return '';
-    if (Object.prototype.hasOwnProperty.call(el.dataset, 'boardMarkdown')) {
-      return el.dataset.boardMarkdown;
-    }
-    return (el.textContent || '').trim();
-  }
-
-  function setMarkdownElement(el, source) {
-    if (!el) return;
-    var md = source || '';
-    el.dataset.boardMarkdown = md;
-    el.classList.add('markdown-body', 'board-card-markdown');
-    function apply() {
-      var html = markdownToHtml(md);
-      if (html !== null) {
-        el.innerHTML = html;
-        linkifyMarkdownElement(el);
-      } else {
-        el.textContent = md;
-      }
-    }
-    if (typeof showdown !== 'undefined') {
-      apply();
-    } else {
-      el.textContent = md;
-      whenShowdownReady(apply);
-    }
-  }
-
-  function syncCompactDescription(card, source) {
-    var preview = card.querySelector('.board-card-description-preview');
-    var full = card.querySelector('.board-card-description-full');
-    var md = source !== undefined ? source : getMarkdownFromElement(full);
-    if (preview) {
-      preview.textContent = md ? markdownPlainPreview(md, 120) : '';
-      preview.dataset.boardMarkdown = md;
-    }
-  }
-
-  function preloadIcons() {
-    ICON_LIST.forEach(function(name) {
-      var img = new Image();
-      img.src = ICONS_BASE + name;
-    });
-  }
-
-  function setCardIcon(card, iconName) {
-    var icon = card && card.querySelector && card.querySelector('.board-card-icon');
-    if (icon) icon.setAttribute('data-icon', iconName);
-  }
-
-  function assignCloudIconToCard(card) {
-    setCardIcon(card, 'cloud');
-  }
-
-  function assignCloudIconToAllCards() {
-    document.querySelectorAll('.board-card').forEach(assignCloudIconToCard);
-  }
-
-  preloadIcons();
-  function initBoard() {
-    initBoardSelector();
-    assignCloudIconToAllCards();
-    refreshBoardRefManifest().then(function () {
-      return loadTasksForBoard();
-    }).then(function () {
-      openTaskFromQueryString();
-    });
-  }
-  if (document.readyState === 'loading') {
-    document.addEventListener('DOMContentLoaded', initBoard);
-  } else {
-    initBoard();
-  }
-
-  function taskDisplayId(ref) {
-    if (!ref) return '';
-    var slug = ref.replace(/^refs\/tags\/tasks\//, '');
-    if (slug.indexOf('task_') === 0) return slug.slice(5);
-    return slug;
-  }
-
-  function taskDisplayIdShort(fullId) {
-    if (!fullId) return '';
-    return fullId.length <= 4 ? fullId : fullId.slice(-4);
-  }
-
-  function syncCardTitleLineTooltip(card) {
-    var titleLine = card.querySelector('.board-card-title-line');
-    if (!titleLine) return;
-    var titleEl = card.querySelector('.board-card-title');
-    var titleText = ((titleEl && titleEl.textContent) || '').trim() || 'Untitled';
-    var fullId = card.dataset.taskRef ? taskDisplayId(card.dataset.taskRef) : '';
-    titleLine.title = fullId ? (fullId + ': ' + titleText) : titleText;
-  }
-
-  function setCardTaskRefDisplay(card, ref, id) {
-    var fullId = id || taskDisplayId(ref);
-    var idEl = card.querySelector('.board-card-task-id');
-    if (idEl) idEl.textContent = taskDisplayIdShort(fullId);
-    var refEl = card.querySelector('.board-card-task-ref');
-    if (refEl) refEl.textContent = ref || '';
-    syncCardTitleLineTooltip(card);
-  }
-
-  function getBoardContext() {
-    var boardColumns = document.querySelector('.board-columns');
-    var project = boardColumns && boardColumns.dataset.project;
-    var board = boardColumns && boardColumns.dataset.board;
-    if (!project && location.pathname) {
-      var m = location.pathname.match(/^\/project\/(.+?)\/board\/?$/);
-      if (m) project = m[1];
-    }
-    if (!board) board = (new URLSearchParams(location.search)).get('b') || 'Tasks';
-    return { project: project || '', board: board || 'Tasks' };
-  }
-
-  var lastLoadedBoards = [];
-
-  function boardViewUrl(project, boardName) {
-    var path = '/project/' + project + '/board/';
-    var params = new URLSearchParams();
-    if (boardName && boardName !== 'Tasks') params.set('b', boardName);
-    var qs = params.toString();
-    return qs ? path + '?' + qs : path;
-  }
-
-  function initBoardSelector() {
-    var select = document.getElementById('board-board-select');
-    if (!select) return;
-    var ctx = getBoardContext();
-    if (!ctx.project) return;
-
-    fetch('/board/list?' + new URLSearchParams({ project: ctx.project }).toString())
-      .then(function (res) { return res.ok ? res.json() : null; })
-      .then(function (boards) {
-        if (!Array.isArray(boards) || !boards.length) return;
-        lastLoadedBoards = boards;
-        var names = boards.map(function (b) { return b.name; });
-        select.innerHTML = '';
-        boards.forEach(function (b) {
-          var opt = document.createElement('option');
-          opt.value = b.name;
-          var label = b.name;
-          if (typeof b.task_count === 'number') label += ' (' + b.task_count + ')';
-          opt.textContent = label;
-          if (b.name === ctx.board) opt.selected = true;
-          select.appendChild(opt);
-        });
-        if (names.indexOf(ctx.board) < 0) {
-          var current = document.createElement('option');
-          current.value = ctx.board;
-          current.textContent = ctx.board;
-          current.selected = true;
-          select.insertBefore(current, select.firstChild);
-        }
-      })
-      .catch(function () {});
-
-    select.addEventListener('change', function () {
-      var nextBoard = select.value;
-      if (!nextBoard || nextBoard === ctx.board) return;
-      location.assign(boardViewUrl(ctx.project, nextBoard));
-    });
-  }
-
-  function pushTaskFieldUpdate(card, fields) {
-    var taskRef = card && card.dataset.taskRef;
-    if (!taskRef) return Promise.resolve({ ok: false, status: 0 });
-    var ctx = getBoardContext();
-    if (!ctx.project) return Promise.resolve({ ok: false, status: 0 });
-    return fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify(fields)
-    }).then(function(res) {
-      return { ok: res.ok, status: res.status };
-    }).catch(function() {
-      return { ok: false, status: 0 };
-    });
-  }
-
-  /** Wire click-to-edit assignee directly on the collapsed card, without opening the popout. */
-  function bindCompactAssigneeEditable(card) {
-    var span = card && card.querySelector && card.querySelector('.board-card-assignee-compact');
-    if (!span || span.dataset.editableBound === '1') return;
-    span.dataset.editableBound = '1';
-    span.classList.add('board-card-assignee-compact-editable');
-    span.addEventListener('click', function (e) {
-      e.stopPropagation();
-      e.preventDefault();
-      if (card.querySelector('.board-card-assignee-compact-edit')) return;
-      var current = (span.textContent || '').trim();
-      if (current === 'None') current = '';
-      var wasDraggable = card.draggable;
-      card.draggable = false;
-      var input = document.createElement('input');
-      input.type = 'text';
-      input.className = 'form-control form-control-sm board-card-assignee-compact-edit';
-      input.placeholder = 'Unassigned';
-      input.value = current;
-      span.classList.add('d-none');
-      span.parentNode.insertBefore(input, span);
-      input.focus();
-      input.select();
-      input.addEventListener('click', function (ev) { ev.stopPropagation(); });
-      input.addEventListener('mousedown', function (ev) { ev.stopPropagation(); });
-      function restoreDraggable() {
-        card.draggable = wasDraggable;
-        if (wasDraggable) card.setAttribute('draggable', 'true');
-      }
-      function commit() {
-        var val = (input.value || '').trim();
-        span.textContent = val || 'None';
-        span.classList.remove('d-none');
-        input.remove();
-        restoreDraggable();
-        var detailAssignee = card.querySelector('.board-card-assignee');
-        if (detailAssignee) detailAssignee.textContent = val || 'None';
-        pushTaskFieldUpdate(card, { assignee: val || null });
-      }
-      input.addEventListener('blur', commit);
-      input.addEventListener('keydown', function (ev) {
-        ev.stopPropagation();
-        if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
-        if (ev.key === 'Escape') { ev.preventDefault(); input.value = current; input.blur(); }
-      });
-    });
-  }
-
-  function createCardFromTask(task) {
-    if (!template || !template.content) return null;
-    var clone = template.content.cloneNode(true);
-    var card = clone.querySelector && clone.querySelector('.board-card');
-    if (!card) return null;
-    var status = task.status || 'TODO';
-    var points = (task.points !== null && task.points !== undefined) ? task.points : null;
-    card.dataset.taskRef = task.ref || '';
-    card.dataset.taskStatus = status;
-    card.dataset.taskPoints = points !== null ? String(points) : '';
-    setCardTaskRefDisplay(card, task.ref || '', task.id);
-    var titleEl = card.querySelector('.board-card-title');
-    if (titleEl) titleEl.textContent = task.title || 'Untitled';
-    syncCardTitleLineTooltip(card);
-    var descFull = card.querySelector('.board-card-description-full');
-    if (descFull) setMarkdownElement(descFull, task.description || '');
-    syncCompactDescription(card, task.description || '');
-    setCardIcon(card, 'cloud-filled');
-    var meta = card.querySelector('.board-card-meta');
-    if (meta) {
-      var badge = meta.querySelector('.badge');
-      var assigneeSpan = meta.querySelector('.board-card-assignee-compact');
-      var commentsSpan = meta.querySelector('.board-card-comments-count-meta') || meta.querySelector('.ms-auto');
-      if (badge) {
-        badge.textContent = task.priority || 'LOW';
-        badge.className = 'badge badge-outline text-' + (task.priority === 'CRITICAL' ? 'danger' : task.priority === 'HIGH' ? 'warning' : 'secondary');
-      }
-      var pointsBadge = meta.querySelector('.board-card-points-badge');
-      if (pointsBadge) {
-        if (points !== null) {
-          pointsBadge.textContent = points + ' pt' + (points !== 1 ? 's' : '');
-          pointsBadge.classList.remove('d-none');
-        } else {
-          pointsBadge.textContent = '';
-          pointsBadge.classList.add('d-none');
-        }
-      }
-      if (assigneeSpan) assigneeSpan.textContent = task.assignee || 'None';
-      if (commentsSpan) commentsSpan.textContent = commentCountLabel(task.comments_count || 0);
-    }
-    var details = card.querySelector('.board-card-details');
-    if (details) {
-      var dueDate = details.querySelector('.board-card-due-date');
-      var updatedAt = details.querySelector('.board-card-updated-at');
-      var statusEl = details.querySelector('.board-card-status');
-      var priorityEl = details.querySelector('.board-card-priority');
-      var pointsEl = details.querySelector('.board-card-points');
-      var assigneeEl = details.querySelector('.board-card-assignee');
-      var tagsEl = details.querySelector('.board-card-tags');
-      if (dueDate) dueDate.textContent = task.due_date || 'None';
-      if (updatedAt) updatedAt.textContent = task.updated_at || 'None';
-      if (statusEl) statusEl.textContent = status;
-      if (priorityEl) priorityEl.textContent = task.priority || 'LOW';
-      if (pointsEl) pointsEl.textContent = points !== null ? String(points) : '–';
-      if (assigneeEl) assigneeEl.textContent = task.assignee || 'None';
-      if (tagsEl) tagsEl.textContent = task.tags || '';
-      updateCardCommentCount(card, task.comments_count || 0);
-    }
-    bindCompactAssigneeEditable(card);
-    return clone;
-  }
-
-  function groupTasksByStatus(tasks) {
-    var byStatus = {};
-    if (!Array.isArray(tasks)) return byStatus;
-    tasks.forEach(function(task) {
-      var s = task.status || 'TODO';
-      if (!byStatus[s]) byStatus[s] = [];
-      byStatus[s].push(task);
-    });
-    return byStatus;
-  }
-
-  function loadTasksForBoard() {
-    var ctx = getBoardContext();
-    if (!ctx.project) return Promise.resolve();
-    var listUrl = '/tasks/list?' + new URLSearchParams({ project: ctx.project, board: ctx.board }).toString();
-    return fetch(listUrl).then(function(res) {
-      if (!res.ok) return;
-      return res.json();
-    }).then(function(tasks) {
-      if (!Array.isArray(tasks)) return;
-      var byStatus = groupTasksByStatus(tasks);
-      var columns = document.querySelectorAll('.board-column.dropzone');
-      columns.forEach(function(column) {
-        var status = column.dataset.status || 'TODO';
-        var cardsContainer = column.querySelector('.board-column-cards');
-        if (!cardsContainer) return;
-        cardsContainer.innerHTML = '';
-        (byStatus[status] || []).forEach(function(task) {
-          var frag = createCardFromTask(task);
-          if (frag) cardsContainer.appendChild(frag);
-        });
-        updateColumnBadge(column);
-      });
-      refreshBoardRefManifest();
-    }).catch(function() {});
-  }
-
-  function openTaskFromQueryString() {
-    var taskRef = (new URLSearchParams(location.search)).get("task");
-    if (!taskRef) return;
-    window.setTimeout(function () {
-      var card = findBoardCardByTaskRef(taskRef);
-      if (card && !card.classList.contains("board-card-popout")) {
-        showCardPopout(card);
-      }
-    }, 0);
-  }
-
-  function fetchBoardTasksJson() {
-    var ctx = getBoardContext();
-    if (!ctx.project) return Promise.resolve(null);
-    var listUrl = '/tasks/list?' + new URLSearchParams({ project: ctx.project, board: ctx.board }).toString();
-    return fetch(listUrl).then(function(res) {
-      return res.ok ? res.json() : null;
-    });
-  }
-
-  function reorderBoardDomToTaskList(tasks) {
-    if (!Array.isArray(tasks)) return;
-    var byStatus = groupTasksByStatus(tasks);
-    document.querySelectorAll('.board-column.dropzone').forEach(function(column) {
-      var status = column.dataset.status || 'TODO';
-      var cardsContainer = column.querySelector('.board-column-cards');
-      if (!cardsContainer) return;
-      var ordered = byStatus[status] || [];
-      ordered.forEach(function(task) {
-        if (!task.ref) return;
-        var card = findBoardCardByTaskRef(task.ref);
-        if (card) cardsContainer.appendChild(card);
-      });
-      updateColumnBadge(column);
-    });
-  }
-
-  function addDefaultCard(ev) {
-    if (ev.target.closest && ev.target.closest('.board-column-task-count')) return;
-    if (!template || !template.content) return;
-    var column = ev.currentTarget.closest('.board-column');
-    var cardsContainer = column && column.querySelector('.board-column-cards');
-    var boardColumns = document.querySelector('.board-columns');
-    if (!cardsContainer || !boardColumns) return;
-    var ctx = getBoardContext();
-    var project = ctx.project;
-    var board = ctx.board;
-    if (!project) return;
-    var clone = template.content.cloneNode(true);
-    var card = clone.querySelector && clone.querySelector('.board-card');
-    if (!card) return;
-    var titleEl = card.querySelector('.board-card-title');
-    if (titleEl) titleEl.textContent = 'New task';
-    syncCardTitleLineTooltip(card);
-    assignCloudIconToCard(card);
-    bindCompactAssigneeEditable(card);
-    cardsContainer.appendChild(clone);
-    var countBadge = column.querySelector('.board-column-task-count');
-    if (countBadge) {
-      countBadge.textContent = cardsContainer.querySelectorAll('.board-card').length;
-    }
-    var status = column.dataset.status || 'TODO';
-    setCardIcon(card, 'cloud-up');
-    var createUrl = '/tasks/create?' + new URLSearchParams({
-      project: project,
-      board: board,
-      title: 'New task',
-      description: '',
-      status: status,
-      priority: 'LOW',
-      assignee: '',
-      due_date: ''
-    }).toString();
-    fetch(createUrl, { method: 'POST' }).then(function(res) {
-      if (res.ok) {
-        return res.json().then(function(body) {
-          setCardIcon(card, 'cloud-filled');
-          if (body.ref) {
-            card.dataset.taskRef = body.ref;
-            setCardTaskRefDisplay(card, body.ref);
-          }
-        });
-      }
-      if (res.status >= 400 && res.status < 600) {
-        setCardIcon(card, 'cloud-exclamation');
-      }
-    }).catch(function() {
-      setCardIcon(card, 'cloud-exclamation');
-    });
-  }
-
-  addCardButtons.forEach(function(btn) {
-    btn.addEventListener('click', addDefaultCard);
-  });
-
-  var boardColumns = document.querySelector('.board-columns');
-  var draggedCard = null;
-  var didDrag = false;
-  var boardAuth = { auth_enabled: false, username: null };
-
-  function loadBoardAuthStatus() {
-    return fetch('/auth/status', { credentials: 'same-origin' })
-      .then(function(res) { return res.ok ? res.json() : null; })
-      .then(function(s) {
-        if (!s) return;
-        boardAuth.auth_enabled = !!s.auth_enabled;
-        boardAuth.username = s.username || null;
-      })
-      .catch(function() {});
-  }
-  loadBoardAuthStatus();
-
-  function canAddComment() {
-    return !boardAuth.auth_enabled || !!boardAuth.username;
-  }
-
-  function commentCountLabel(n) {
-    return n + ' comment' + (n !== 1 ? 's' : '');
-  }
-
-  function updateCardCommentCount(card, count) {
-    var n = count || 0;
-    var meta = card.querySelector('.board-card-meta');
-    if (meta) {
-      var commentsSpan = meta.querySelector('.board-card-comments-count-meta') || meta.querySelector('.ms-auto');
-      if (commentsSpan) commentsSpan.textContent = commentCountLabel(n);
-    }
-  }
-
-  function commentAuthorInitials(author) {
-    var parts = (author || '').trim().split(/\s+/).filter(Boolean);
-    if (!parts.length) return '?';
-    if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
-    return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
-  }
-
-  function commentAvatarHtml(comment) {
-    var wrap = document.createElement('span');
-    wrap.className = 'board-card-comment-avatar';
-    if (comment.gravatar_url) {
-      var img = document.createElement('img');
-      img.src = comment.gravatar_url;
-      img.alt = '';
-      img.width = 32;
-      img.height = 32;
-      img.onerror = function() {
-        wrap.replaceChildren();
-        var fb = document.createElement('span');
-        fb.className = 'board-card-comment-avatar-fallback';
-        fb.textContent = commentAuthorInitials(comment.author);
-        wrap.appendChild(fb);
-      };
-      wrap.appendChild(img);
-    } else {
-      var fb = document.createElement('span');
-      fb.className = 'board-card-comment-avatar-fallback';
-      fb.textContent = commentAuthorInitials(comment.author);
-      wrap.appendChild(fb);
-    }
-    return wrap;
-  }
-
-  function renderCommentsList(card, comments) {
-    var list = card.querySelector('.board-card-comments-list');
-    if (!list) return;
-    list.innerHTML = '';
-    if (!Array.isArray(comments) || !comments.length) return;
-    comments.forEach(function(comment) {
-      var row = document.createElement('div');
-      row.className = 'board-card-comment-item';
-      row.appendChild(commentAvatarHtml(comment));
-      var body = document.createElement('div');
-      body.className = 'board-card-comment-body';
-      var authorEl = document.createElement('div');
-      authorEl.className = 'board-card-comment-author';
-      authorEl.textContent = comment.author || 'Unknown';
-      var contentEl = document.createElement('div');
-      contentEl.className = 'board-card-comment-content';
-      setMarkdownElement(contentEl, comment.content || '');
-      body.appendChild(authorEl);
-      body.appendChild(contentEl);
-      row.appendChild(body);
-      list.appendChild(row);
-    });
-  }
-
-  function syncCommentFormVisibility(card) {
-    var form = card.querySelector('.board-card-comment-form');
-    var hint = card.querySelector('.board-card-comment-signin-hint');
-    if (!form || !hint) return;
-    if (canAddComment()) {
-      form.classList.remove('d-none');
-      hint.classList.add('d-none');
-    } else {
-      form.classList.add('d-none');
-      hint.classList.remove('d-none');
-    }
-  }
-
-  function loadCommentsForCard(card) {
-    var taskRef = card.dataset.taskRef;
-    if (!taskRef) return Promise.resolve();
-    var ctx = getBoardContext();
-    if (!ctx.project) return Promise.resolve();
-    var listUrl = '/comments/list?' + new URLSearchParams({
-      project: ctx.project,
-      board: ctx.board,
-      task: taskRef
-    }).toString();
-    return fetch(listUrl, { credentials: 'same-origin' })
-      .then(function(res) { return res.ok ? res.json() : []; })
-      .then(function(comments) {
-        if (!Array.isArray(comments)) comments = [];
-        renderCommentsList(card, comments);
-        updateCardCommentCount(card, comments.length);
-        return comments;
-      })
-      .catch(function() { return []; });
-  }
-
-  function setupCommentForm(popoutCard) {
-    var form = popoutCard.querySelector('.board-card-comment-form');
-    if (!form || form.dataset.bound === '1') return;
-    form.dataset.bound = '1';
-    syncCommentFormVisibility(popoutCard);
-    var commentInput = form.querySelector('.board-card-comment-input');
-    attachBoardRefAutocomplete(commentInput);
-    form.addEventListener('submit', function(e) {
-      e.preventDefault();
-      if (!canAddComment()) return;
-      var taskRef = popoutCard.dataset.taskRef;
-      if (!taskRef) return;
-      var input = form.querySelector('.board-card-comment-input');
-      var content = (input && input.value) ? input.value.trim() : '';
-      if (!content) return;
-      var ctx = getBoardContext();
-      if (!ctx.project) return;
-      var createUrl = '/comments/create?' + new URLSearchParams({
-        project: ctx.project,
-        board: ctx.board,
-        task: taskRef,
-        content: content
-      }).toString();
-      fetch(createUrl, { method: 'POST', credentials: 'same-origin' })
-        .then(function(res) {
-          if (!res.ok) {
-            var msg = (res.status === 401 || res.status === 403)
-              ? 'Not authorized to add comments (sign in again if needed).'
-              : 'Could not post comment.';
-            showBoardError(msg);
-            return null;
-          }
-          if (input) input.value = '';
-          return loadCommentsForCard(popoutCard);
-        })
-        .catch(function() {
-          showBoardError('Could not post comment.');
-        });
-    });
-  }
-
-  function findBoardCardByTaskRef(taskRef) {
-    if (!taskRef) return null;
-    var cards = document.querySelectorAll('.board-card');
-    for (var i = 0; i < cards.length; i++) {
-      if (cards[i].dataset.taskRef === taskRef) return cards[i];
-    }
-    return null;
-  }
-
-  function clearColumnDropHighlight() {
-    document.querySelectorAll('.board-column.dropzone.border-primary').forEach(function(el) {
-      el.classList.remove('border-primary');
-    });
-  }
-
-  function dragTransferLooksLikeTask(e) {
-    if (draggedCard) return true;
-    var types = e.dataTransfer && e.dataTransfer.types;
-    if (!types || !types.length) return false;
-    for (var i = 0; i < types.length; i++) {
-      if (types[i] === 'text/plain' || types[i] === 'Text') return true;
-    }
-    return false;
-  }
-
-  function showBoardError(message) {
-    var anchor = boardColumns && boardColumns.parentNode;
-    if (!anchor) return;
-    var existing = document.querySelector('.board-task-error-hint');
-    if (existing) existing.remove();
-    var el = document.createElement('div');
-    el.className = 'alert board-task-error-hint py-2 px-3 mb-2';
-    el.setAttribute('role', 'alert');
-    el.textContent = message;
-    anchor.insertBefore(el, boardColumns);
-    window.setTimeout(function() {
-      if (el.parentNode) el.remove();
-    }, 8000);
-  }
-
-  function updateColumnBadge(column) {
-    var cardsContainer = column && column.querySelector('.board-column-cards');
-    var countBadge = column && column.querySelector('.board-column-task-count');
-    var pointsBadge = column && column.querySelector('.board-column-points-total');
-    if (!cardsContainer) return;
-    var cards = cardsContainer.querySelectorAll('.board-card');
-    if (countBadge) countBadge.textContent = cards.length;
-    if (pointsBadge) {
-      var total = 0;
-      cards.forEach(function(c) {
-        var v = c.dataset.taskPoints;
-        if (v) total += parseInt(v, 10) || 0;
-      });
-      pointsBadge.textContent = total + ' pts';
-    }
-  }
-
-  function handleDragStart(e) {
-    var card = e.target.closest && e.target.closest('.board-card');
-    if (!card) return;
-    didDrag = true;
-    draggedCard = card;
-    e.dataTransfer.effectAllowed = 'move';
-    try {
-      e.dataTransfer.setData('text/plain', card.dataset.taskRef || '');
-    } catch (err) {
-      /* ignore setData failures in restricted environments */
-    }
-    card.classList.add('opacity-50');
-  }
-  function handleDragEnd(e) {
-    var card = (draggedCard && draggedCard.classList.contains('opacity-50') ? draggedCard : null) ||
-      (e.target.closest && e.target.closest('.board-card'));
-    if (card) card.classList.remove('opacity-50');
-    clearColumnDropHighlight();
-    window.setTimeout(function() {
-      draggedCard = null;
-      didDrag = false;
-    }, 0);
-  }
-
-  /** Parse display date text to YYYY-MM-DD for input[type=date]. Returns '' if none. */
-  function parseDateForInput(text) {
-    if (!text || text.trim() === 'None') return '';
-    var s = text.trim();
-    var m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
-    return m ? m[0] : '';
-  }
-
-  function setupPopoutEditables(popoutCard) {
-    var titleEl = popoutCard.querySelector('.board-card-title');
-    var descEl = popoutCard.querySelector('.board-card-description-full');
-    var dueEl = popoutCard.querySelector('.board-card-due-date');
-    var statusEl = popoutCard.querySelector('.board-card-status');
-    var priorityEl = popoutCard.querySelector('.board-card-priority');
-    var tagsEl = popoutCard.querySelector('.board-card-tags');
-    var pointsEl = popoutCard.querySelector('.board-card-points');
-    var assigneeEl = popoutCard.querySelector('.board-card-assignee');
-    var FIBONACCI_POINTS = ['–', '0', '1', '2', '3', '5', '8', '13', '21'];
-
-    function pushTaskUpdate() {
-      var taskRef = popoutCard.dataset.taskRef;
-      if (!taskRef) return;
-      var ctx = getBoardContext();
-      if (!ctx.project) return;
-      var tEl = popoutCard.querySelector('.board-card-title');
-      var dEl = popoutCard.querySelector('.board-card-description-full');
-      var dueEl_ = popoutCard.querySelector('.board-card-due-date');
-      var sEl = popoutCard.querySelector('.board-card-status');
-      var pEl = popoutCard.querySelector('.board-card-priority');
-      var ptEl = popoutCard.querySelector('.board-card-points');
-      var aEl = popoutCard.querySelector('.board-card-assignee');
-      var tagsEl_ = popoutCard.querySelector('.board-card-tags');
-      var dueVal = (dueEl_ && dueEl_.textContent) ? dueEl_.textContent.trim() : '';
-      var statusDisplay = (sEl && sEl.textContent) ? sEl.textContent.trim() : 'TODO';
-      var priorityDisplay = (pEl && pEl.textContent) ? pEl.textContent.trim() : 'LOW';
-      var tagsVal = (tagsEl_ && tagsEl_.textContent) ? tagsEl_.textContent.trim() : '';
-      var pointsDisplay = (ptEl && ptEl.textContent) ? ptEl.textContent.trim() : '–';
-      var body = {
-        title: (tEl && tEl.textContent) ? tEl.textContent.trim() || 'Untitled' : 'Untitled',
-        description: getMarkdownFromElement(dEl),
-        status: statusDisplay ? statusDisplay.replace(/\s+/g, '_').toUpperCase() : 'TODO',
-        priority: priorityDisplay ? priorityDisplay.replace(/\s+/g, '_').toUpperCase() : 'LOW',
-        points: (pointsDisplay && pointsDisplay !== '–') ? parseInt(pointsDisplay, 10) : null,
-        assignee: (aEl && aEl.textContent && aEl.textContent.trim() !== 'None') ? aEl.textContent.trim() : null,
-        due_date: (dueVal && dueVal !== 'None') ? dueVal : null,
-        tags: tagsVal || ''
-      };
-      fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify(body)
-      }).then(function() {
-        var card = popoutCard.closest ? popoutCard : null;
-        if (card) {
-          card.dataset.taskPoints = (body.points !== null && body.points !== undefined) ? String(body.points) : '';
-          var col = card.closest('.board-column');
-          if (col) updateColumnBadge(col);
-        }
-      }).catch(function() {});
-    }
-
-    if (titleEl) titleEl.classList.add('board-card-editable');
-    function commitTitle() {
-      var input = popoutCard.querySelector('.board-card-title-edit');
-      if (!input) return;
-      var val = (input.value || '').trim() || 'Untitled';
-      titleEl.textContent = val;
-      titleEl.classList.remove('d-none');
-      input.remove();
-      syncCardTitleLineTooltip(popoutCard);
-      pushTaskUpdate();
-    }
-    if (titleEl) {
-      titleEl.addEventListener('click', function () {
-        if (popoutCard.querySelector('.board-card-title-edit')) return;
-        var val = (titleEl.textContent || '').trim();
-        var input = document.createElement('input');
-        input.type = 'text';
-        input.className = 'form-control form-control-sm board-card-title-edit';
-        input.value = val;
-        titleEl.classList.add('d-none');
-        titleEl.parentNode.insertBefore(input, titleEl);
-        input.focus();
-        input.addEventListener('blur', commitTitle);
-        input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
-      });
-    }
-
-    function commitDescription() {
-      var textarea = popoutCard.querySelector('.board-card-description-edit');
-      if (!textarea) return;
-      var val = (textarea.value || '').trim();
-      setMarkdownElement(descEl, val);
-      syncCompactDescription(popoutCard, val);
-      descEl.classList.remove('d-none');
-      textarea.remove();
-      pushTaskUpdate();
-    }
-    if (descEl) {
-      descEl.addEventListener('click', function () {
-        if (popoutCard.querySelector('.board-card-description-edit')) return;
-        var val = getMarkdownFromElement(descEl);
-        var textarea = document.createElement('textarea');
-        textarea.className = 'form-control form-control-sm board-card-description-edit mt-1';
-        textarea.rows = 3;
-        textarea.value = val;
-        descEl.classList.add('d-none');
-        descEl.parentNode.insertBefore(textarea, descEl);
-        attachBoardRefAutocomplete(textarea);
-        textarea.focus();
-        textarea.addEventListener('blur', commitDescription);
-      });
-    }
-
-    function commitDueDate() {
-      var input = popoutCard.querySelector('.board-card-due-date-edit');
-      if (!input) return;
-      var val = (input.value || '').trim();
-      dueEl.textContent = val || 'None';
-      dueEl.classList.remove('d-none');
-      input.remove();
-      pushTaskUpdate();
-    }
-    if (dueEl) {
-      dueEl.addEventListener('click', function () {
-        if (popoutCard.querySelector('.board-card-due-date-edit')) return;
-        var val = parseDateForInput(dueEl.textContent);
-        var input = document.createElement('input');
-        input.type = 'date';
-        input.className = 'form-control form-control-sm board-card-due-date-edit mt-1';
-        input.value = val;
-        dueEl.classList.add('d-none');
-        dueEl.parentNode.insertBefore(input, dueEl);
-        input.focus();
-        input.addEventListener('blur', commitDueDate);
-      });
-    }
-
-    function commitStatus() {
-      var sel = popoutCard.querySelector('.board-card-status-edit');
-      if (!sel) return;
-      var display = (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].text) ? sel.options[sel.selectedIndex].text : (sel.value || 'TODO');
-      statusEl.textContent = display || 'TODO';
-      statusEl.classList.remove('d-none');
-      sel.remove();
-      var statusValue = display ? display.replace(/\s+/g, '_').toUpperCase() : 'TODO';
-      popoutCard.dataset.taskStatus = statusValue;
-      pushTaskUpdate();
-    }
-    if (statusEl) {
-      statusEl.addEventListener('click', function () {
-        if (popoutCard.querySelector('.board-card-status-edit')) return;
-        var current = (statusEl.textContent || '').trim() || 'TODO';
-        var sel = document.createElement('select');
-        sel.className = 'form-select form-select-sm board-card-status-edit mt-1';
-        sel.innerHTML = '';
-        var opt = document.createElement('option');
-        opt.value = current;
-        opt.textContent = current;
-        opt.selected = true;
-        sel.appendChild(opt);
-        statusEl.classList.add('d-none');
-        statusEl.parentNode.insertBefore(sel, statusEl);
-        sel.focus();
-        sel.addEventListener('blur', commitStatus);
-        sel.addEventListener('change', commitStatus);
-      });
-    }
-
-    function commitPriority() {
-      var sel = popoutCard.querySelector('.board-card-priority-edit');
-      if (!sel) return;
-      var display = (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].text) ? sel.options[sel.selectedIndex].text : (sel.value || 'LOW');
-      priorityEl.textContent = display || 'LOW';
-      priorityEl.classList.remove('d-none');
-      sel.remove();
-      pushTaskUpdate();
-    }
-    if (priorityEl) {
-      priorityEl.addEventListener('click', function () {
-        if (popoutCard.querySelector('.board-card-priority-edit')) return;
-        var current = (priorityEl.textContent || '').trim() || 'LOW';
-        var sel = document.createElement('select');
-        sel.className = 'form-select form-select-sm board-card-priority-edit mt-1';
-        sel.innerHTML = '';
+import { findBoardCardByTaskRef, getBoardContext, refreshBoardRefManifest } from "./board-context.js";
+import { loadBoardAuthStatus } from "./board-comments.js";
+import { assignCloudIconToAllCards, bindAddCardButtons, loadTasksForBoard, preloadIcons } from "./board-cards.js";
+import { showCardPopout } from "./board-popout.js";
+import { bindBoardInteractions } from "./board-dnd.js";
+
+var lastLoadedBoards = [];
+
+function boardViewUrl(project, boardName) {
+  var path = '/project/' + project + '/board/';
+  var params = new URLSearchParams();
+  if (boardName && boardName !== 'Tasks') params.set('b', boardName);
+  var qs = params.toString();
+  return qs ? path + '?' + qs : path;
+}
+
+function initBoardSelector() {
+  var select = document.getElementById('board-board-select');
+  if (!select) return;
+  var ctx = getBoardContext();
+  if (!ctx.project) return;
+
+  fetch('/board/list?' + new URLSearchParams({ project: ctx.project }).toString())
+    .then(function (res) { return res.ok ? res.json() : null; })
+    .then(function (boards) {
+      if (!Array.isArray(boards) || !boards.length) return;
+      lastLoadedBoards = boards;
+      var names = boards.map(function (b) { return b.name; });
+      select.innerHTML = '';
+      boards.forEach(function (b) {
         var opt = document.createElement('option');
-        opt.value = current;
-        opt.textContent = current;
-        opt.selected = true;
-        sel.appendChild(opt);
-        priorityEl.classList.add('d-none');
-        priorityEl.parentNode.insertBefore(sel, priorityEl);
-        sel.focus();
-        sel.addEventListener('blur', commitPriority);
-        sel.addEventListener('change', commitPriority);
-      });
-    }
-
-    if (tagsEl) {
-      tagsEl.addEventListener('blur', function () {
-        pushTaskUpdate();
-      });
-    }
-
-    function commitPoints() {
-      var sel = popoutCard.querySelector('.board-card-points-edit');
-      if (!sel) return;
-      var display = (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].text) ? sel.options[sel.selectedIndex].text : '–';
-      pointsEl.textContent = display;
-      pointsEl.classList.remove('d-none');
-      sel.remove();
-      pushTaskUpdate();
-    }
-    if (pointsEl) {
-      pointsEl.addEventListener('click', function () {
-        if (popoutCard.querySelector('.board-card-points-edit')) return;
-        var current = (pointsEl.textContent || '').trim() || '–';
-        var sel = document.createElement('select');
-        sel.className = 'form-select form-select-sm board-card-points-edit mt-1';
-        FIBONACCI_POINTS.forEach(function (val) {
-          var opt = document.createElement('option');
-          opt.value = val;
-          opt.textContent = val;
-          if (val === current) opt.selected = true;
-          sel.appendChild(opt);
-        });
-        pointsEl.classList.add('d-none');
-        pointsEl.parentNode.insertBefore(sel, pointsEl);
-        sel.focus();
-        sel.addEventListener('blur', commitPoints);
-        sel.addEventListener('change', commitPoints);
-      });
-    }
-
-    function commitAssignee() {
-      var input = popoutCard.querySelector('.board-card-assignee-edit');
-      if (!input) return;
-      var val = (input.value || '').trim();
-      assigneeEl.textContent = val || 'None';
-      assigneeEl.classList.remove('d-none');
-      input.remove();
-      var compactSpan = popoutCard.querySelector('.board-card-assignee-compact');
-      if (compactSpan) compactSpan.textContent = val || 'None';
-      pushTaskUpdate();
-    }
-    if (assigneeEl) {
-      assigneeEl.addEventListener('click', function () {
-        if (popoutCard.querySelector('.board-card-assignee-edit')) return;
-        var current = (assigneeEl.textContent || '').trim();
-        if (current === 'None') current = '';
-        var input = document.createElement('input');
-        input.type = 'text';
-        input.className = 'form-control form-control-sm board-card-assignee-edit mt-1';
-        input.placeholder = 'Unassigned';
-        input.value = current;
-        assigneeEl.classList.add('d-none');
-        assigneeEl.parentNode.insertBefore(input, assigneeEl);
-        input.focus();
-        input.addEventListener('blur', commitAssignee);
-        input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
-      });
-    }
-  }
-
-  function setupPopoutActions(popoutCard, closePopout, cardsContainer) {
-    var deleteBtn = popoutCard.querySelector('.board-card-delete-btn');
-    var archiveBtn = popoutCard.querySelector('.board-card-archive-btn');
-    var taskRef = popoutCard.dataset.taskRef;
-    if (!taskRef) return;
-    var ctx = getBoardContext();
-    if (!ctx.project) return;
-
-    function removeCardFromBoard() {
-      if (closePopout) closePopout();
-      popoutCard.remove();
-      var column = cardsContainer && cardsContainer.closest('.board-column');
-      if (column) updateColumnBadge(column);
-    }
-
-    if (deleteBtn) {
-      deleteBtn.addEventListener('click', function (e) {
-        e.preventDefault();
-        e.stopPropagation();
-        if (!window.confirm('Delete this task permanently? This cannot be undone.')) return;
-        fetch('/tasks/delete?' + new URLSearchParams({
-          project: ctx.project,
-          board: ctx.board,
-          task: taskRef
-        }).toString(), { method: 'POST', credentials: 'same-origin' })
-          .then(function (res) {
-            if (res.ok) removeCardFromBoard();
-          })
-          .catch(function () {});
-      });
-    }
-
-    if (archiveBtn) {
-      archiveBtn.addEventListener('click', function (e) {
-        e.preventDefault();
-        e.stopPropagation();
-        if (!window.confirm('Archive this task? It will be moved to the Archive board.')) return;
-        fetch('/tasks/archive?' + new URLSearchParams({
-          project: ctx.project,
-          board: ctx.board,
-          task: taskRef
-        }).toString(), { method: 'POST', credentials: 'same-origin' })
-          .then(function (res) {
-            if (res.ok) removeCardFromBoard();
-          })
-          .catch(function () {});
-      });
-    }
-  }
-
-  function loadBoardOptionsForMove(select, excludeName) {
-    var ctx = getBoardContext();
-    if (!ctx.project) return;
-    fetch('/board/list?' + new URLSearchParams({ project: ctx.project }).toString())
-      .then(function(res) { return res.ok ? res.json() : []; })
-      .then(function(boards) {
-        select.innerHTML = '<option value="">Move to board…</option>';
-        (boards || []).forEach(function(b) {
-          if (b.name === excludeName) return;
-          var opt = document.createElement('option');
-          opt.value = b.name;
-          opt.textContent = b.name;
-          select.appendChild(opt);
-        });
-      })
-      .catch(function() {});
-  }
-
-  function setupMoveControl(popoutCard) {
-    var select = popoutCard.querySelector('.board-card-move-select');
-    if (!select) return;
-    var ctx = getBoardContext();
-    loadBoardOptionsForMove(select, ctx.board);
-    if (select.dataset.bound === '1') return;
-    select.dataset.bound = '1';
-    select.addEventListener('change', function() {
-      var target = select.value;
-      var taskRef = popoutCard.dataset.taskRef;
-      if (!target || !taskRef) return;
-      var ctx2 = getBoardContext();
-      if (!ctx2.project) return;
-      fetch('/tasks/move?' + new URLSearchParams({ project: ctx2.project, board: ctx2.board, task: taskRef, target: target }).toString(), {
-        method: 'POST',
-        credentials: 'same-origin'
-      }).then(function(res) {
-        if (!res.ok) {
-          var msg = (res.status === 401 || res.status === 403)
-            ? 'Not authorized to move tasks (sign in again if needed).'
-            : 'Could not move task.';
-          showBoardError(msg);
-          select.value = '';
-          return;
-        }
-        var overlay = popoutCard.closest('.board-card-popout-overlay');
-        if (overlay && overlay.parentNode) {
-          overlay.parentNode.removeChild(overlay);
-        }
-        loadTasksForBoard();
-      }).catch(function() {
-        showBoardError('Could not move task.');
-        select.value = '';
-      });
-    });
-  }
-
-  function showCardPopout(card) {
-    var cardsContainer = card.parentNode;
-    var nextSibling = card.nextSibling;
-    card.classList.add('board-card-popout');
-    card.draggable = false;
-    card.removeAttribute('draggable');
-    var details = card.querySelector('.board-card-details');
-    if (details) details.classList.remove('d-none');
-    setupPopoutEditables(card);
-    setupCommentForm(card);
-    setupMoveControl(card);
-    var descFull = card.querySelector('.board-card-description-full');
-    if (descFull && !Object.prototype.hasOwnProperty.call(descFull.dataset, 'boardMarkdown')) {
-      setMarkdownElement(descFull, getMarkdownFromElement(descFull));
-      syncCompactDescription(card);
-    } else if (descFull) {
-      linkifyMarkdownElement(descFull);
-    }
-    document.querySelectorAll('.board-card-comment-content.board-card-markdown').forEach(function (el) {
-      if (card.contains(el)) linkifyMarkdownElement(el);
-    });
-    loadCommentsForCard(card);
-    var overlay = document.createElement('div');
-    overlay.className = 'board-card-popout-overlay';
-    overlay.appendChild(card);
-    function close() {
-      overlay.removeEventListener('click', closeOnBackdrop);
-      document.body.removeChild(overlay);
-      card.classList.remove('board-card-popout');
-      card.setAttribute('draggable', 'true');
-      card.draggable = true;
-      if (details) details.classList.add('d-none');
-      if (nextSibling) {
-        cardsContainer.insertBefore(card, nextSibling);
-      } else {
-        cardsContainer.appendChild(card);
+        opt.value = b.name;
+        var label = b.name;
+        if (typeof b.task_count === 'number') label += ' (' + b.task_count + ')';
+        opt.textContent = label;
+        if (b.name === ctx.board) opt.selected = true;
+        select.appendChild(opt);
+      });
+      if (names.indexOf(ctx.board) < 0) {
+        var current = document.createElement('option');
+        current.value = ctx.board;
+        current.textContent = ctx.board;
+        current.selected = true;
+        select.insertBefore(current, select.firstChild);
       }
-      updateColumnBadge(cardsContainer.closest('.board-column'));
-    }
-    function closeOnBackdrop(e) {
-      if (e.target === overlay) close();
-    }
-    setupPopoutActions(card, close, cardsContainer);
-    overlay.addEventListener('click', closeOnBackdrop);
-    document.body.appendChild(overlay);
-    updateColumnBadge(cardsContainer.closest('.board-column'));
-  }
-
-  function handleCardClick(e) {
-    if (didDrag) return;
-    var card = e.target.closest && e.target.closest('.board-card');
-    if (!card || card.classList.contains('board-card-popout')) return;
-    e.preventDefault();
-    showCardPopout(card);
-  }
-
-  function handleBoardDragOver(e) {
-    if (!boardColumns || !boardColumns.contains(e.target)) return;
-    var col = e.target.closest && e.target.closest('.board-column.dropzone');
-    if (!col || !dragTransferLooksLikeTask(e)) return;
-    e.preventDefault();
-    e.dataTransfer.dropEffect = 'move';
-    clearColumnDropHighlight();
-    col.classList.add('border-primary');
-  }
-
-  function pushTaskStatusUpdate(card, newStatus) {
-    var taskRef = card && card.dataset.taskRef;
-    if (!taskRef) return Promise.resolve({ ok: false, status: 0 });
-    var ctx = getBoardContext();
-    if (!ctx.project) return Promise.resolve({ ok: false, status: 0 });
-    return fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ status: newStatus || 'TODO' })
-    }).then(function(res) {
-      return { ok: res.ok, status: res.status };
-    }).catch(function() {
-      return { ok: false, status: 0 };
-    });
-  }
+    })
+    .catch(function () {});
 
-  function handleBoardDrop(e) {
-    var col = e.target.closest && e.target.closest('.board-column.dropzone');
-    if (!col || !boardColumns || !boardColumns.contains(e.target)) return;
-    e.preventDefault();
-    e.stopPropagation();
-    clearColumnDropHighlight();
-    var taskRef = '';
-    try {
-      taskRef = e.dataTransfer.getData('text/plain') || '';
-    } catch (err2) {
-      taskRef = '';
+  select.addEventListener('change', function () {
+    var nextBoard = select.value;
+    if (!nextBoard || nextBoard === ctx.board) return;
+    location.assign(boardViewUrl(ctx.project, nextBoard));
+  });
+}
+
+function openTaskFromQueryString() {
+  var taskRef = (new URLSearchParams(location.search)).get("task");
+  if (!taskRef) return;
+  window.setTimeout(function () {
+    var card = findBoardCardByTaskRef(taskRef);
+    if (card && !card.classList.contains("board-card-popout")) {
+      showCardPopout(card);
+    }
+  }, 0);
+}
+
+function suggestSprintName() {
+  var max = 0;
+  lastLoadedBoards.forEach(function(b) {
+    var m = /^Sprint-(\d+)$/.exec(b.name || '');
+    if (m) {
+      var n = parseInt(m[1], 10);
+      if (n > max) max = n;
     }
-    var card = draggedCard || findBoardCardByTaskRef(taskRef);
-    if (!card) return;
-    var targetColumn = col;
-    var targetCards = targetColumn.querySelector('.board-column-cards');
-    if (!targetCards) return;
-    var sourceColumn = card.closest && card.closest('.board-column');
-    if (sourceColumn === targetColumn) return;
-    var newStatus = targetColumn.dataset.status || 'TODO';
-    var prevStatus = card.dataset.taskStatus || 'TODO';
-    var sourceCards = sourceColumn && sourceColumn.querySelector('.board-column-cards');
-    var nextSibling = card.nextSibling;
-    card.dataset.taskStatus = newStatus;
-    targetCards.appendChild(card);
-    updateColumnBadge(sourceColumn);
-    updateColumnBadge(targetColumn);
-    pushTaskStatusUpdate(card, newStatus).then(function(result) {
-      if (result.ok) {
-        fetchBoardTasksJson().then(function(tasks) {
-          if (tasks) reorderBoardDomToTaskList(tasks);
-        });
-        return;
-      }
-      card.dataset.taskStatus = prevStatus;
-      if (sourceCards) {
-        if (nextSibling && nextSibling.parentNode === sourceCards) {
-          sourceCards.insertBefore(card, nextSibling);
-        } else {
-          sourceCards.appendChild(card);
-        }
-      }
-      updateColumnBadge(sourceColumn);
-      updateColumnBadge(targetColumn);
-      var st = result.status;
-      var msg = (st === 401 || st === 403)
-        ? 'Not authorized to update tasks (sign in again if needed). The card was moved back.'
-        : 'Could not save the task move. The card was moved back.';
-      showBoardError(msg);
-    });
-  }
-
-  if (boardColumns) {
-    boardColumns.addEventListener('dragstart', handleDragStart);
-    boardColumns.addEventListener('dragend', handleDragEnd);
-    boardColumns.addEventListener('click', handleCardClick);
-    boardColumns.addEventListener('dragover', handleBoardDragOver, true);
-    boardColumns.addEventListener('drop', handleBoardDrop, true);
-  }
-
-  // ---- Board switcher / create (including "+ Sprint" quick-create) ----
-
-  function suggestSprintName() {
-    var max = 0;
-    lastLoadedBoards.forEach(function(b) {
-      var m = /^Sprint-(\d+)$/.exec(b.name || '');
-      if (m) {
-        var n = parseInt(m[1], 10);
-        if (n > max) max = n;
-      }
-    });
-    return 'Sprint-' + (max + 1);
-  }
-
-  function showBoardNewForm(prefill) {
-    var form = document.getElementById('board-new-form');
-    var input = document.getElementById('board-new-name');
-    var err = document.getElementById('board-new-error');
-    if (!form || !input) return;
-    form.classList.remove('d-none');
-    if (err) err.classList.add('d-none');
-    input.value = prefill || '';
-    input.focus();
-    input.select();
-  }
-
+  });
+  return 'Sprint-' + (max + 1);
+}
+
+function showBoardNewForm(prefill) {
+  var form = document.getElementById('board-new-form');
+  var input = document.getElementById('board-new-name');
+  var err = document.getElementById('board-new-error');
+  if (!form || !input) return;
+  form.classList.remove('d-none');
+  if (err) err.classList.add('d-none');
+  input.value = prefill || '';
+  input.focus();
+  input.select();
+}
+
+function bindNewBoardForm() {
   var newBoardBtn = document.getElementById('board-new-board-btn');
   if (newBoardBtn) newBoardBtn.addEventListener('click', function() { showBoardNewForm(''); });
 
@@ -1327,4 +133,25 @@
       });
     });
   }
-})();
+}
+
+function initBoard() {
+  preloadIcons();
+  bindAddCardButtons();
+  bindBoardInteractions();
+  loadBoardAuthStatus();
+  bindNewBoardForm();
+  initBoardSelector();
+  assignCloudIconToAllCards();
+  refreshBoardRefManifest().then(function () {
+    return loadTasksForBoard();
+  }).then(function () {
+    openTaskFromQueryString();
+  });
+}
+
+if (document.readyState === 'loading') {
+  document.addEventListener('DOMContentLoaded', initBoard);
+} else {
+  initBoard();
+}
diff --git a/pygitweb/static/diff-view.js b/pygitweb/static/diff-view.js
new file mode 100644
index 0000000..ea3b9b9
--- /dev/null
+++ b/pygitweb/static/diff-view.js
@@ -0,0 +1,47 @@
+import { escapeHtml, whenReady } from "./dom.js";
+
+const container = document.getElementById("d2h-container");
+const output = document.getElementById("d2h-output");
+const emptyEl = document.getElementById("d2h-empty");
+
+function render() {
+	if (!container || !output || !emptyEl) return;
+	const diffB64 = container.getAttribute("data-diff-b64");
+	const hasDiff = container.getAttribute("data-has-diff") === "true";
+	const emptyText = container.getAttribute("data-empty-text") || "";
+
+	if (!hasDiff || !diffB64) {
+		emptyEl.textContent = emptyText;
+		emptyEl.style.display = "block";
+		return;
+	}
+	try {
+		const raw = atob(diffB64);
+		if (!raw) {
+			emptyEl.textContent = emptyText;
+			emptyEl.style.display = "block";
+			return;
+		}
+		const diffStr = (typeof TextDecoder !== "undefined")
+			? new TextDecoder("utf-8").decode(Uint8Array.from(raw, (c) => c.charCodeAt(0)))
+			: raw;
+		if (typeof Diff2HtmlUI === "undefined") {
+			output.innerHTML = '<p class="text-warning">Diff2HtmlUI not loaded.</p>';
+			return;
+		}
+		const config = {
+			drawFileList: true,
+			matching: "lines",
+			outputFormat: "side-by-side",
+			synchronisedScroll: true,
+			highlight: true,
+		};
+		const diff2htmlUi = new Diff2HtmlUI(output, diffStr, config);
+		diff2htmlUi.draw();
+		diff2htmlUi.highlightCode();
+	} catch (e) {
+		output.innerHTML = '<p class="text-danger">Failed to render diff: ' + escapeHtml(e.message || e) + "</p>";
+	}
+}
+
+whenReady("diff2html-script", "Diff2HtmlUI", render);
diff --git a/pygitweb/static/dom.js b/pygitweb/static/dom.js
new file mode 100644
index 0000000..79490b5
--- /dev/null
+++ b/pygitweb/static/dom.js
@@ -0,0 +1,26 @@
+export function escapeHtml(value) {
+	return String(value).replace(/[&<>"']/g, function (c) {
+		return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
+	});
+}
+
+export function whenReady(scriptId, globalName, callback) {
+	const ready = () => typeof globalThis[globalName] !== "undefined";
+	if (ready()) {
+		callback();
+		return;
+	}
+	const script = document.getElementById(scriptId);
+	if (!script) {
+		callback();
+		return;
+	}
+	const run = () => {
+		if (ready()) callback();
+	};
+	script.addEventListener("load", () => {
+		script.setAttribute("data-loaded", "1");
+		run();
+	});
+	if (script.getAttribute("data-loaded")) run();
+}
diff --git a/pygitweb/static/readme-render.js b/pygitweb/static/readme-render.js
index 86a114d..ea96696 100644
--- a/pygitweb/static/readme-render.js
+++ b/pygitweb/static/readme-render.js
@@ -1,5 +1,5 @@
-(function() {
-  var el = document.getElementById("Showdown-script");
+import { whenReady } from "./dom.js";
+
   function isRelativeLink(href) {
     if (!href || href.charAt(0) === "#") return false;
     if (href.charAt(0) === "/") return false;
@@ -167,8 +167,8 @@
       if (toc) mountTocAndScrollSpy(toc, container);
     } catch (e) {}
   }
-  if (el) {
-    if (el.getAttribute("data-loaded")) render();
-    else el.addEventListener("load", function() { el.setAttribute("data-loaded", "1"); render(); });
-  }
-})();
+export function renderReadme() {
+  whenReady("Showdown-script", "showdown", render);
+}
+
+renderReadme();
diff --git a/pygitweb/static/search-page.js b/pygitweb/static/search-page.js
index ec952c5..3461b01 100644
--- a/pygitweb/static/search-page.js
+++ b/pygitweb/static/search-page.js
@@ -1,3 +1,5 @@
+import { escapeHtml } from "./dom.js";
+
 (function () {
 	"use strict";
 
@@ -13,12 +15,6 @@
 	const elResults = document.getElementById("search-results");
 	const elAdvanced = document.getElementById("search-advanced");
 
-	function escapeHtml(s) {
-		return String(s).replace(/[&<>"']/g, function (c) {
-			return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
-		});
-	}
-
 	function readUrl() {
 		const p = new URLSearchParams(window.location.search);
 		if (p.has("patterns")) elPattern.value = p.get("patterns");
diff --git a/pygitweb/static/summary-ref-switcher.js b/pygitweb/static/summary-ref-switcher.js
index f894711..e6f16f1 100644
--- a/pygitweb/static/summary-ref-switcher.js
+++ b/pygitweb/static/summary-ref-switcher.js
@@ -1,3 +1,5 @@
+import { renderReadme } from "./readme-render.js";
+
 (() => {
 	const selectEl = document.getElementById("summary-ref-select");
 	const commitLinkEl = document.getElementById("summary-ref-commit-link");
@@ -21,18 +23,6 @@
 
 	const SUBSCRIBE_BACKOFF_MS = 5000;
 
-	const rerunScripts = () => {
-		for (const oldScript of readmeContainerEl.querySelectorAll("script")) {
-			const scriptEl = document.createElement("script");
-			if (oldScript.src) {
-				scriptEl.src = oldScript.src;
-			} else if (oldScript.textContent) {
-				scriptEl.textContent = oldScript.textContent;
-			}
-			oldScript.replaceWith(scriptEl);
-		}
-	};
-
 	const loadState = async (ref) => {
 		const response = await fetch(
 			`${projectApiBase()}/summary-ref-state?ref=${encodeURIComponent(ref)}`,
@@ -58,7 +48,7 @@
 		}
 		readmeContainerEl.innerHTML = state.readme_html || "";
 		if (state.readme_html) {
-			rerunScripts();
+			renderReadme();
 		}
 	};
 
diff --git a/pygitweb/templates/addproject.html b/pygitweb/templates/addproject.html
index c3d5183..25b2acb 100644
--- a/pygitweb/templates/addproject.html
+++ b/pygitweb/templates/addproject.html
@@ -54,121 +54,4 @@
   </div>
 </div>
 
-<script>
-(function() {
-  var projectNameInput = document.getElementById('project_name');
-  var nameValidTimeout = null;
-
-  function setProjectNameBorder(valid) {
-    projectNameInput.classList.remove('border', 'border-success', 'border-danger');
-    projectNameInput.classList.add('border');
-    if (valid === true) {
-      projectNameInput.classList.add('border-success');
-    } else if (valid === false) {
-      projectNameInput.classList.add('border-danger');
-    } else {
-      projectNameInput.classList.remove('border');
-    }
-  }
-
-  function checkProjectName() {
-    var name = (projectNameInput.value || '').trim();
-    if (!name) {
-      setProjectNameBorder(null);
-      return;
-    }
-    var url = '/projectnamevalid?name=' + encodeURIComponent(name);
-    fetch(url, { credentials: 'same-origin' })
-      .then(function(res) {
-        // 401/403 when not logged in → red outline; same as other validation failures.
-        setProjectNameBorder(res.ok);
-      })
-      .catch(function() {
-        setProjectNameBorder(null);
-      });
-  }
-
-  projectNameInput.addEventListener('blur', function() {
-    checkProjectName();
-  });
-  projectNameInput.addEventListener('input', function() {
-    clearTimeout(nameValidTimeout);
-    var name = (projectNameInput.value || '').trim();
-    if (!name) {
-      setProjectNameBorder(null);
-      return;
-    }
-    nameValidTimeout = setTimeout(checkProjectName, 400);
-  });
-})();
-
-(function() {
-  var sourceRadios = Array.prototype.slice.call(document.querySelectorAll('input[name="project_source_type"]'));
-  var emptyTab = document.getElementById('tab-empty-repo');
-  var zipTab = document.getElementById('tab-upload-zip');
-  var remoteInput = document.getElementById('pull_from_remote');
-  var zipInput = document.getElementById('repo_zip');
-
-  function getSelectedSource() {
-    for (var i = 0; i < sourceRadios.length; i++) {
-      if (sourceRadios[i].checked) {
-        return sourceRadios[i].value;
-      }
-    }
-    return 'empty';
-  }
-
-  function updateSourceTabs() {
-    var selected = getSelectedSource();
-    var isZip = selected === 'zip';
-    emptyTab.classList.toggle('d-none', isZip);
-    zipTab.classList.toggle('d-none', !isZip);
-    if (isZip) {
-      remoteInput.value = '';
-    } else {
-      zipInput.value = '';
-      zipInput.dispatchEvent(new Event('change'));
-    }
-  }
-
-  sourceRadios.forEach(function(radio) {
-    radio.addEventListener('change', updateSourceTabs);
-  });
-  updateSourceTabs();
-})();
-
-(function() {
-  var dropzone = document.getElementById('dropzone');
-  var input = document.getElementById('repo_zip');
-  var placeholder = dropzone.querySelector('[data-dropzone-placeholder]');
-  var filenameEl = dropzone.querySelector('[data-dropzone-filename]');
-
-  function showFile(name) {
-    if (name) {
-      placeholder.classList.add('d-none');
-      filenameEl.classList.remove('d-none');
-      filenameEl.textContent = 'Selected: ' + name;
-    } else {
-      placeholder.classList.remove('d-none');
-      filenameEl.classList.add('d-none');
-      filenameEl.textContent = '';
-    }
-  }
-
-  dropzone.addEventListener('click', function() { input.click(); });
-  dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.classList.add('border-primary'); });
-  dropzone.addEventListener('dragleave', function() { dropzone.classList.remove('border-primary'); });
-  dropzone.addEventListener('drop', function(e) {
-    e.preventDefault();
-    dropzone.classList.remove('border-primary');
-    var files = e.dataTransfer.files;
-    if (files.length && files[0].name.toLowerCase().endsWith('.zip')) {
-      input.files = files;
-      showFile(files[0].name);
-    }
-  });
-  input.addEventListener('change', function() {
-    showFile(input.files.length ? input.files[0].name : null);
-  });
-})();
-</script>
+<script type="module" src="/static/addproject.js"></script>
diff --git a/pygitweb/templates/blobdiff.html b/pygitweb/templates/blobdiff.html
index f1d5c65..eec2e73 100644
--- a/pygitweb/templates/blobdiff.html
+++ b/pygitweb/templates/blobdiff.html
@@ -1,57 +1,7 @@
 <h1>Blob diff {{ path_new }}</h1>
 <p>Project: <a href="/project/{{ project }}">{{ project }}</a> · <a href="{{ blob_link }}">blob</a></p>
-<div id="d2h-container" data-diff-b64="{{ diff_b64 }}" data-has-diff="{{ 'true' if diff_b64 else 'false' }}">
+<div id="d2h-container" data-diff-b64="{{ diff_b64 }}" data-has-diff="{{ 'true' if diff_b64 else 'false' }}" data-empty-text="No changes between these blobs.">
   <div id="d2h-output"></div>
-  <p id="d2h-empty" class="text-muted" style="display: none;">No changes between these blobs.</p>
+  <p id="d2h-empty" class="text-muted" style="display: none;"></p>
 </div>
-<script>
-(function () {
-  var container = document.getElementById('d2h-container');
-  var output = document.getElementById('d2h-output');
-  var emptyEl = document.getElementById('d2h-empty');
-  var diffB64 = container.getAttribute('data-diff-b64');
-  var hasDiff = container.getAttribute('data-has-diff') === 'true';
-
-  function render() {
-    if (!hasDiff || !diffB64) {
-      emptyEl.style.display = 'block';
-      return;
-    }
-    try {
-      var raw = atob(diffB64);
-      if (!raw) {
-        emptyEl.style.display = 'block';
-        return;
-      }
-      var diffStr = (typeof TextDecoder !== 'undefined')
-        ? new TextDecoder('utf-8').decode(Uint8Array.from(raw, function (c) { return c.charCodeAt(0); }))
-        : raw;
-      if (typeof Diff2HtmlUI === 'undefined') {
-        output.innerHTML = '<p class="text-warning">Diff2HtmlUI not loaded.</p>';
-        return;
-      }
-      var config = {
-        drawFileList: true,
-        matching: 'lines',
-        outputFormat: 'side-by-side',
-        synchronisedScroll: true,
-        highlight: true
-      };
-      var diff2htmlUi = new Diff2HtmlUI(output, diffStr, config);
-      diff2htmlUi.draw();
-      diff2htmlUi.highlightCode();
-    } catch (e) {
-      output.innerHTML = '<p class="text-danger">Failed to render diff: ' + (e.message || e) + '</p>';
-    }
-  }
-
-  var script = document.getElementById('diff2html-script');
-  if (script && (typeof Diff2HtmlUI !== 'undefined')) {
-    render();
-  } else if (script) {
-    script.addEventListener('load', render);
-  } else {
-    render();
-  }
-})();
-</script>
+<script type="module" src="/static/diff-view.js"></script>
diff --git a/pygitweb/templates/board.html b/pygitweb/templates/board.html
index dd19341..6a5d39c 100644
--- a/pygitweb/templates/board.html
+++ b/pygitweb/templates/board.html
@@ -51,5 +51,4 @@
   {% endfor %}
 </div>
 
-<script src="/static/board-refs.js"></script>
-<script src="/static/board.js"></script>
+<script type="module" src="/static/board.js"></script>
diff --git a/pygitweb/templates/commitdiff.html b/pygitweb/templates/commitdiff.html
index 0f86c60..343dc5a 100644
--- a/pygitweb/templates/commitdiff.html
+++ b/pygitweb/templates/commitdiff.html
@@ -1,57 +1,7 @@
 <h1>Commit diff {{ oid_short }}</h1>
 <p>Project: <a href="/project/{{ project }}">{{ project }}</a> · <a href="{{ commit_link }}">commit</a></p>
-<div id="d2h-container" data-diff-b64="{{ diff_b64 }}" data-has-diff="{{ 'true' if diff_b64 else 'false' }}">
+<div id="d2h-container" data-diff-b64="{{ diff_b64 }}" data-has-diff="{{ 'true' if diff_b64 else 'false' }}" data-empty-text="No changes in this commit.">
   <div id="d2h-output"></div>
-  <p id="d2h-empty" class="text-muted" style="display: none;">No changes in this commit.</p>
+  <p id="d2h-empty" class="text-muted" style="display: none;"></p>
 </div>
-<script>
-(function () {
-  var container = document.getElementById('d2h-container');
-  var output = document.getElementById('d2h-output');
-  var emptyEl = document.getElementById('d2h-empty');
-  var diffB64 = container.getAttribute('data-diff-b64');
-  var hasDiff = container.getAttribute('data-has-diff') === 'true';
-
-  function render() {
-    if (!hasDiff || !diffB64) {
-      emptyEl.style.display = 'block';
-      return;
-    }
-    try {
-      var raw = atob(diffB64);
-      if (!raw) {
-        emptyEl.style.display = 'block';
-        return;
-      }
-      var diffStr = (typeof TextDecoder !== 'undefined')
-        ? new TextDecoder('utf-8').decode(Uint8Array.from(raw, function (c) { return c.charCodeAt(0); }))
-        : raw;
-      if (typeof Diff2HtmlUI === 'undefined') {
-        output.innerHTML = '<p class="text-warning">Diff2HtmlUI not loaded.</p>';
-        return;
-      }
-      var config = {
-        drawFileList: true,
-        matching: 'lines',
-        outputFormat: 'side-by-side',
-        synchronisedScroll: true,
-        highlight: true
-      };
-      var diff2htmlUi = new Diff2HtmlUI(output, diffStr, config);
-      diff2htmlUi.draw();
-      diff2htmlUi.highlightCode();
-    } catch (e) {
-      output.innerHTML = '<p class="text-danger">Failed to render diff: ' + (e.message || e) + '</p>';
-    }
-  }
-
-  var script = document.getElementById('diff2html-script');
-  if (script && (typeof Diff2HtmlUI !== 'undefined')) {
-    render();
-  } else if (script) {
-    script.addEventListener('load', render);
-  } else {
-    render();
-  }
-})();
-</script>
+<script type="module" src="/static/diff-view.js"></script>
diff --git a/pygitweb/templates/search.html b/pygitweb/templates/search.html
index 687d18a..40d1a7c 100644
--- a/pygitweb/templates/search.html
+++ b/pygitweb/templates/search.html
@@ -74,4 +74,4 @@
   window.SEARCH_PROJECT = {{ project | tojson }};
   window.SEARCH_API_URL = {{ search_api_url | tojson }};
 </script>
-<script src="/static/search-page.js"></script>
+<script type="module" src="/static/search-page.js"></script>
