diff --git a/distgit/tasks.py b/distgit/tasks.py
new file mode 100644
index 0000000..5c9dd55
--- /dev/null
+++ b/distgit/tasks.py
@@ -0,0 +1,326 @@
+import json
+from random import randint
+from pygit2 import Tag, Oid, reference_is_valid_name, Repository
+from datetime import datetime
+from json import JSONEncoder
+from typing import Any
+from enum import Enum
+
+# GIT_OBJECT_TAG = 4, GIT_OBJECT_TREE = 2
+GIT_OBJECT_TAG = 4
+GIT_OBJECT_TREE = 2
+EMPTY_TREE_OID_HEX = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
+
+
+def _ensure_empty_tree(repo: Repository) -> None:
+    """Ensure the well-known empty tree object exists in the repo ODB (so tags can point to it)."""
+    try:
+        repo[Oid(hex=EMPTY_TREE_OID_HEX)]
+    except KeyError:
+        repo.odb.write(GIT_OBJECT_TREE, b"tree 0\0")
+
+
+class _DateTimeEncoder(JSONEncoder):
+    def default(self, o: Any) -> Any:
+        if hasattr(o, "isoformat"):
+            return o.isoformat()
+        if isinstance(o, Enum):
+            return o.value
+        return super().default(o)
+
+
+def _tagger_str(tagger: str) -> str:
+    """Produce a tagger line for git tag object (name <email> timestamp +tz)."""
+    if tagger and " <" in tagger and ">" in tagger:
+        return tagger + " 0 +0000"
+    return (tagger or "distgit <distgit@local>") + " 0 +0000"
+
+
+def _build_tag_raw(
+    target: Oid,
+    name: str,
+    tagger: str,
+    message: str,
+    object_type: str = "commit",
+) -> bytes:
+    """Build full raw git tag object bytes (header + content) for odb.write(4, data). object_type must match the target (e.g. 'commit' or 'tree')."""
+    content = (
+        f"object {str(target)}\n"
+        f"type {object_type}\n"
+        f"tag {name}\n"
+        f"tagger {_tagger_str(tagger)}\n"
+        f"\n{message}"
+    )
+    body = content.encode("utf-8")
+    header = f"tag {len(body)}\0".encode("utf-8")
+    return header + body
+
+
+def _read_tag_raw(repo: Repository, oid: Oid) -> tuple[Oid, str, str, str]:
+    """Read a tag from ODB; return (target, name, tagger_str, message)."""
+    obj = repo[oid]
+    if not isinstance(obj, Tag):
+        raise ValueError(f"Not a tag: {oid}")
+    tagger = obj.tagger
+    tagger_str = f"{tagger.name} <{tagger.email}> {tagger.time} {tagger.offset}" if tagger else ""
+    return (obj.target, obj.name, tagger_str, obj.message)
+
+
+"""
+A Task is an annotated tag (a full tag object, although it will get a ref as well).
+The usual fields of a tag (target, name, tagger) are present, but the "message" is a JSON string.
+All the fields seen below are supported, but arbitrary JSON keys may be added. Text fields are usually Markdown.
+The "name" must be a valid Git ref name (usually "tasks/<task-id>").
+Because the ODB is immutable, the tag will always point to the latest version of the task.
+"""
+
+
+class Task:
+    class Status(Enum):
+        TODO = "TODO"
+        IN_PROGRESS = "IN_PROGRESS"
+        IN_REVIEW = "IN_REVIEW"
+        DONE = "DONE"
+        CANCELLED = "CANCELLED"
+
+    class Priority(Enum):
+        LOW = "LOW"
+        MEDIUM = "MEDIUM"
+        HIGH = "HIGH"
+        CRITICAL = "CRITICAL"
+
+    def __init__(
+        self,
+        target : Oid,
+        name : str,
+        tagger : str,
+        title : str,
+        description : str = "",
+        status : Status | None = None,
+        priority : Priority | None = None,
+        assignee : str | None = None,
+        due_date : datetime | None = None
+    ):
+        if name is None:
+            name = f"tasks/{title.lower().replace(' ', '_')}"
+
+        if not reference_is_valid_name(name):
+            raise ValueError(f"Invalid task backend name: '{name}'")
+
+        self.target = target
+        self.tagger = tagger or ""
+        self.name = name
+        self.description = description
+        self.status = status
+        self.priority = priority
+        self.assignee = assignee
+        self.due_date = due_date
+        self.created_at = datetime.now()
+        self.comments = [] # Comment OIDs
+        self.update_message()
+
+    def update_message(self) -> None:
+        self.updated_at = datetime.now()
+        self.message = _DateTimeEncoder().encode(
+            {k: v for k, v in self.__dict__.items() if k not in ["target", "tagger", "name", "message"]}
+        )
+
+    """
+    Write the task to the repository.
+    @param repo: The repository to write to.
+    @return: The OID of the written task.
+    """
+    def write(self, repo: Repository) -> Oid:
+        try:
+            obj = repo[self.target]
+            object_type = (getattr(obj, "type_str", None) or "commit").lower()
+        except KeyError:
+            if str(self.target) == EMPTY_TREE_OID_HEX:
+                _ensure_empty_tree(repo)
+                object_type = "tree"
+            else:
+                object_type = "commit"
+        raw = _build_tag_raw(self.target, self.name, self.tagger, self.message, object_type=object_type)
+        oid = repo.odb.write(GIT_OBJECT_TAG, raw)
+        repo.references.create(self.name, oid, force=True)
+        return oid
+
+def _parse_dt(s):
+    if not s:
+        return None
+    if hasattr(s, "isoformat"):
+        return s
+    return datetime.fromisoformat(str(s).replace("Z", "+00:00")) if s else None
+
+
+def get_task(repo: Repository, ref: str) -> Task | None:
+    tag = repo.revparse_single(ref)
+    if not isinstance(tag, Tag):
+        raise ValueError(f"Requested task is not a tag: {ref}")
+    j = json.loads(tag.message)
+    status = Task.Status(j["status"]) if j.get("status") else None
+    priority = Task.Priority(j["priority"]) if j.get("priority") else None
+    t = Task(
+        tag.target,
+        tag.name,
+        tag.tagger,
+        j["title"],
+        description=j.get("description", ""),
+        status=status,
+        priority=priority,
+        assignee=j.get("assignee"),
+        due_date=_parse_dt(j.get("due_date")),
+    )
+    t.comments = j.get("comments", [])
+    t.created_at = _parse_dt(j.get("created_at")) or t.created_at
+    t.updated_at = _parse_dt(j.get("updated_at")) or t.updated_at
+    return t
+
+
+def get_task_by_oid(repo: Repository, oid: Oid) -> Task | None:
+    """Load a task by its tag OID (e.g. from board.tasks)."""
+    obj = repo[oid]
+    if not isinstance(obj, Tag):
+        raise ValueError(f"Object is not a task tag: {oid}")
+    j = json.loads(obj.message)
+    status = Task.Status(j["status"]) if j.get("status") else None
+    priority = Task.Priority(j["priority"]) if j.get("priority") else None
+    t = Task(
+        obj.target,
+        obj.name,
+        obj.tagger,
+        j["title"],
+        description=j.get("description", ""),
+        status=status,
+        priority=priority,
+        assignee=j.get("assignee"),
+        due_date=_parse_dt(j.get("due_date")),
+    )
+    t.comments = j.get("comments", [])
+    t.created_at = _parse_dt(j.get("created_at")) or t.created_at
+    t.updated_at = _parse_dt(j.get("updated_at")) or t.updated_at
+    return t
+
+
+"""
+A Comment is an annotated tag (a full tag object). Unlike tasks it does not get a ref by default.
+The tagger is the author of the comment and the target is the task or parent comment.
+Todo: this will not play nice with gc...
+"""
+
+
+class Comment:
+    def __init__(
+        self,
+        target: Oid,  # Task or parent comment OID
+        tagger: str,  # Author of the comment
+        content: str,
+        name: str | None = None,  # "comments/<comment-id>" - Note this will NOT get a ref by default
+    ):
+        if name is None:
+            name = f"comments/{randint(1, 2147483647)}"
+
+        self.target = target
+        self.tagger = tagger or ""
+        self.name = name
+        self.content = content
+        self.created_at = datetime.now()
+        self.edited_at = None
+        self.update_message()
+
+    def update_message(self) -> None:
+        self.updated_at = datetime.now()
+        self.message = _DateTimeEncoder().encode(
+            {k: v for k, v in self.__dict__.items() if k not in ["target", "tagger", "name", "message"]}
+        )
+
+    """
+    Write the comment to the repository.
+    @param repo: The repository to write to.
+    @return: The OID of the written comment.
+    """
+    def write(self, repo: Repository) -> Oid:
+        try:
+            obj = repo[self.target]
+            object_type = (getattr(obj, "type_str", None) or "commit").lower()
+        except KeyError:
+            if str(self.target) == EMPTY_TREE_OID_HEX:
+                _ensure_empty_tree(repo)
+                object_type = "tree"
+            else:
+                object_type = "commit"
+        raw = _build_tag_raw(self.target, self.name, self.tagger, self.message, object_type=object_type)
+        return repo.odb.write(GIT_OBJECT_TAG, raw)
+
+def get_comment(repo: Repository, oid: Oid) -> Comment | None:
+    obj = repo[oid]
+    if not isinstance(obj, Tag):
+        raise ValueError(f"Requested comment is not a tag: {oid}")
+    j = json.loads(obj.message)
+    c = Comment(obj.target, obj.tagger, j["content"], obj.name)
+    c.created_at = _parse_dt(j.get("created_at")) or c.created_at
+    c.edited_at = _parse_dt(j.get("edited_at"))
+    return c
+
+
+"""
+A Board is an annotated tag (a full tag object, although it will get a ref as well).
+The only used field is "message" which is a JSON string.
+The "tasks" field is an array of task OIDs.
+"""
+
+
+class Board:
+    def __init__(
+        self,
+        target: Oid,
+        name: str,
+        tagger: str,
+        description: str = "",
+    ):
+        self.target = target
+        self.name = name
+        self.tagger = tagger or ""
+        self.description = description
+        self.created_at = datetime.now()
+        self.updated_at = datetime.now()
+        self.tasks = []  # Task OIDs
+        self.update_message()
+
+    def update_message(self) -> None:
+        self.updated_at = datetime.now()
+        self.message = _DateTimeEncoder().encode(
+            {k: v for k, v in self.__dict__.items() if k not in ["target", "name", "tagger", "message"]}
+        )
+
+    def write(self, repo: Repository) -> Oid:
+        if str(self.target) == EMPTY_TREE_OID_HEX:
+            _ensure_empty_tree(repo)
+        try:
+            obj = repo[self.target]
+            object_type = (getattr(obj, "type_str", None) or "commit").lower()
+        except KeyError:
+            object_type = "tree" if str(self.target) == EMPTY_TREE_OID_HEX else "commit"
+        raw = _build_tag_raw(self.target, self.name, self.tagger, self.message, object_type=object_type)
+        oid = repo.odb.write(GIT_OBJECT_TAG, raw)
+        repo.references.create(self.name, oid, force=True)
+        return oid
+
+
+def get_board(repo: Repository, ref: str) -> Board | None:
+    try:
+        tag = repo.revparse_single(ref)
+    except Exception:
+        return None
+    if not isinstance(tag, Tag):
+        return None
+    try:
+        j = json.loads(tag.message)
+    except (ValueError, TypeError):
+        return None
+    b = Board(tag.target, tag.name, tag.tagger, j.get("description", ""))
+    b.tasks = j.get("tasks", [])
+    b.created_at = _parse_dt(j.get("created_at")) or b.created_at
+    b.updated_at = _parse_dt(j.get("updated_at")) or b.updated_at
+    return b
+
diff --git a/pygitweb/main.py b/pygitweb/main.py
index fa17303..731353f 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -31,7 +31,9 @@ from pygitweb.config import (
     evaluate_gitweb_config,
 )
 from pygitweb.formatting import esc_html
-from pygitweb.git_helpers import git_get_project_config, git_get_type
+from urllib.parse import quote
+
+from pygitweb.git_helpers import git_get_project_config, git_get_type, git_get_references
 from pygitweb.projects import git_get_projects_list, git_get_project_owner
 from pygitweb.templates_env import PREAMBLE, POSTAMBLE, env
 from pygitweb.validation import is_valid_action, is_valid_pathname, is_valid_project, is_valid_ref_format
@@ -57,6 +59,7 @@ from pygitweb.actions import (
     parse_pagination,
 )
 from pygitweb.settings import router as settings_router
+from pygitweb.tasks import board_router, comment_router, create_board_for_project, get_board_tasks_grouped, task_router
 
 app = FastAPI(
     debug=(DISTGIT_AUTH == "None"),
@@ -127,6 +130,9 @@ def _request_can_add_project(request: Request) -> bool:
 
 
 app.include_router(settings_router)
+app.include_router(board_router, prefix="/project/{project:path}")
+app.include_router(task_router, prefix="/project/{project:path}")
+app.include_router(comment_router, prefix="/project/{project:path}")
 
 
 @app.on_event("startup")
@@ -189,13 +195,33 @@ def git_project_list(
     if not list_:
         raise HTTPException(status_code=404, detail="No projects found")
 
+    auth_disabled = DISTGIT_AUTH == "None"
+
+    def board_cell(pr: dict) -> str:
+        path = pr.get("path", "")
+        path_enc = quote(path, safe="/")
+        try:
+            board_refs = git_get_references(path, "refs/boards")
+            has_boards = len(board_refs) > 0
+        except Exception:
+            has_boards = False
+        if has_boards:
+            return f'<a href="/project/{path_enc}/board/">project board</a>'
+        grey_style = ' style="color: #999; cursor: not-allowed;"' if not auth_disabled else ""
+        proj_q = quote(path, safe="")
+        return f'<a href="/board/create?project={proj_q}"{grey_style}>create board</a>'
+
     # We need better escaping logic here but can wait until we harden the templates
     table = env.get_template("table.html").render(
-        cols=["Project", "Description"],
+        cols=["Project", "Description", "Board"],
         rows=[
-            [f"<a href='/project/{pr.get('path', '')}'>{pr.get('path', '')}</a>",
-            pr.get('descr') or pr.get('path', "")]
-             for pr in list_[:50]]
+            [
+                f"<a href='/project/{quote(pr.get('path', ''), safe='/')}'>{esc_html(pr.get('path', ''))}</a>",
+                esc_html(pr.get("descr") or pr.get("path", "")),
+                board_cell(pr),
+            ]
+            for pr in list_[:50]
+        ],
     )
     pre = PREAMBLE.render(title=f'{esc_html(config.SITE_NAME)} - Projects', site_name=config.SITE_NAME)
     return HTMLResponse(f"{pre}<h1>Project List</h1>{table}{POSTAMBLE}")
@@ -237,6 +263,29 @@ def git_opml():
     return PlainTextResponse("\n".join(lines), media_type="text/xml; charset=utf-8")
 
 
+@app.get("/board/create", response_class=RedirectResponse)
+def board_create_page(
+    request: Request,
+    project: Annotated[str | None, Query(alias="p")] = None,
+) -> RedirectResponse:
+    """Create a board named 'Tasks' (refs/boards/Tasks) and redirect to the project summary."""
+    if DISTGIT_AUTH != "None":
+        raise HTTPException(status_code=401, detail="Authentication required")
+    p = project or request.query_params.get("project")
+    if not p:
+        raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
+    _validate_project(p)
+    try:
+        create_board_for_project(p, name="Tasks", description="")
+    except HTTPException as e:
+        if e.status_code == 409:
+            pass
+        else:
+            raise
+    p_url = quote(p, safe="/")
+    return RedirectResponse(url=f"/project/{p_url}", status_code=303)
+
+
 # ---------- Add project ----------
 
 
@@ -361,6 +410,34 @@ async def addproject_submit(
 # ---------- Routes (project required) ----------
 
 
+@app.get("/project/{project:path}/board/", response_class=HTMLResponse)
+def project_board(
+    project: str,
+    board: Annotated[str, Query(alias="b")] = "Tasks",
+) -> HTMLResponse:
+    """Board view: columns (dropzones) and task cards."""
+    _validate_project(project)
+    board_name = board
+    project_url = f"/project/{quote(project, safe='/')}"
+    board_url = f"{project_url}/board/"
+    columns = get_board_tasks_grouped(project, board_name)
+    for col in columns:
+        for t in col["tasks"]:
+            t["task_url"] = f"{board_url}?task={quote(t['ref'], safe='')}"
+    pre = PREAMBLE.render(
+        title=f"{esc_html(project)} - Board",
+        site_name=config.SITE_NAME,
+    )
+    body = env.get_template("board.html").render(
+        project=project,
+        project_url=project_url,
+        board_name=board_name,
+        board_url=board_url,
+        columns=columns,
+    )
+    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
 @app.get("/project/{project:path}", response_class=HTMLResponse)
 def dispatch(
     request: Request,
diff --git a/pygitweb/static/main.css b/pygitweb/static/main.css
index d61e84a..961e7bd 100644
--- a/pygitweb/static/main.css
+++ b/pygitweb/static/main.css
@@ -600,3 +600,26 @@ tbody tr:hover {
 .readme-toc-level-4 { padding-left: 1.5rem; }
 .readme-toc-level-5 { padding-left: 2rem; }
 .readme-toc-level-6 { padding-left: 2.5rem; }
+
+/* Board view: lift container max-width so columns can use full width; keep centered */
+.container-xl:has(.board-columns) {
+	max-width: none;
+	margin-left: auto;
+	margin-right: auto;
+}
+
+/* Board view: center the column group when there's space; still scroll when many columns */
+.board-columns {
+	justify-content: center;
+}
+
+/* Board view columns: use theme variables so they respect light/dark */
+.board-column {
+	background-color: var(--pgw-bg-muted);
+	color: var(--pgw-text);
+	border-color: var(--pgw-border) !important;
+}
+.board-column .h6,
+.board-column .text-muted {
+	color: var(--pgw-text-muted) !important;
+}
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
new file mode 100644
index 0000000..b7e226d
--- /dev/null
+++ b/pygitweb/tasks.py
@@ -0,0 +1,492 @@
+"""
+Task-related routes: Task, comment, board creation, listing, and management.
+Data is stored in the repo ODB/RefDB via distgit.tasks (Board, Task, Comment).
+"""
+from __future__ import annotations
+
+import time
+from typing import Any
+from urllib.parse import quote
+
+import pygit2
+from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi.responses import JSONResponse
+
+from distgit.tasks import Board, Comment, Task, get_board, get_comment, get_task, get_task_by_oid
+from pygitweb.config import DISTGIT_AUTH, EXPORT_OK, PROJECTROOT, STRICT_EXPORT
+from pygitweb.git_helpers import open_repo, git_get_references
+from pygitweb.projects import git_get_projects_list
+from pygitweb.validation import is_valid_project
+
+BOARD_REF_PREFIX = "refs/boards/"
+TASK_REF_PREFIX = "refs/tasks/"
+# Well-known empty tree OID for boards/tasks when repo has no HEAD
+EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
+
+
+def _require_auth_disabled() -> None:
+    """Raise 401 if authentication is enabled (we will add real auth later)."""
+    if DISTGIT_AUTH != "None":
+        raise HTTPException(status_code=401, detail="Authentication required")
+
+
+def _project_in_list(project: str) -> bool:
+    lst = git_get_projects_list(
+        filter_path="",
+        paranoid=STRICT_EXPORT,
+        export_ok=EXPORT_OK,
+    )
+    return any(p.get("path") == project for p in lst)
+
+
+def _validate_project(project: str | None) -> str:
+    if not project:
+        raise HTTPException(status_code=400, detail="Project needed")
+    if not is_valid_project(
+        project,
+        PROJECTROOT,
+        EXPORT_OK,
+        STRICT_EXPORT,
+        _project_in_list,
+    ):
+        raise HTTPException(status_code=404, detail="No such project")
+    return project
+
+
+def _board_ref(name: str) -> str:
+    return f"{BOARD_REF_PREFIX}{name}"
+
+
+def _task_ref(task_id: str) -> str:
+    return f"{TASK_REF_PREFIX}{task_id}"
+
+
+def _repo_head_or_empty(repo: pygit2.Repository) -> pygit2.Oid:
+    if repo.head:
+        return repo.head.target
+    return EMPTY_TREE_OID
+
+
+def create_board_for_project(
+    project: str,
+    name: str = "Tasks",
+    description: str = "",
+) -> str:
+    """Create a board in the project repo. Returns the board ref. Raises HTTPException on error."""
+    repo = open_repo(project)
+    ref = _board_ref(name)
+    if ref in repo.references:
+        raise HTTPException(status_code=409, detail="Board already exists")
+    target = _repo_head_or_empty(repo)
+    board = Board(target, ref, tagger="", description=description)
+    board.write(repo)
+    return ref
+
+
+# ---------- Board Routes ----------
+
+board_router = APIRouter(prefix="/boards", tags=["boards"])
+
+
+@board_router.get("/list", response_class=JSONResponse)
+def boards_list(request: Request, project: str) -> JSONResponse:
+    """List all boards for the project."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    refs = git_get_references(project, "refs/boards")
+    boards = []
+    for ref_name, _ in refs:
+        try:
+            b = get_board(repo, ref_name)
+            if b:
+                boards.append({
+                    "name": ref_name.replace(BOARD_REF_PREFIX, ""),
+                    "description": getattr(b, "description", "") or "",
+                    "task_count": len(getattr(b, "tasks", [])),
+                })
+        except (ValueError, KeyError):
+            continue
+    return JSONResponse(content=boards)
+
+
+@board_router.post("/create", response_class=JSONResponse)
+def boards_create(
+    request: Request,
+    project: str,
+    name: str = Query(..., description="Board name"),
+    description: str = Query("", description="Board description"),
+) -> JSONResponse:
+    """Create a new board."""
+    _require_auth_disabled()
+    _validate_project(project)
+    if not name or "/" in name or ".." in name:
+        raise HTTPException(status_code=400, detail="Invalid board name")
+    repo = open_repo(project)
+    ref = _board_ref(name)
+    if ref in repo.references:
+        raise HTTPException(status_code=409, detail="Board already exists")
+    target = _repo_head_or_empty(repo)
+    board = Board(target, ref, tagger="", description=description or "")
+    oid = board.write(repo)
+    return JSONResponse(content={"name": name, "ref": ref, "oid": str(oid)})
+
+
+@board_router.post("/delete", response_class=JSONResponse)
+def boards_delete(
+    request: Request,
+    project: str,
+    name: str = Query(..., description="Board name"),
+) -> JSONResponse:
+    """Delete a board (removes ref; tag object remains in ODB)."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    ref = _board_ref(name)
+    if ref not in repo.references:
+        raise HTTPException(status_code=404, detail="Board not found")
+    repo.references.delete(ref)
+    return JSONResponse(content={"message": "Board deleted"})
+
+
+# ---------- Task Routes ----------
+
+task_router = APIRouter(prefix="/tasks", tags=["tasks"])
+
+
+def _task_to_json(t: Task) -> dict[str, Any]:
+    return {
+        "ref": t.name,
+        "title": t.title,
+        "description": getattr(t, "description", "") or "",
+        "status": t.status.value if t.status else None,
+        "priority": t.priority.value if t.priority else None,
+        "assignee": getattr(t, "assignee"),
+        "due_date": t.due_date.isoformat() if getattr(t, "due_date") else None,
+        "created_at": getattr(t, "created_at").isoformat() if getattr(t, "created_at") else None,
+        "updated_at": getattr(t, "updated_at").isoformat() if getattr(t, "updated_at") else None,
+        "comments_count": len(getattr(t, "comments", [])),
+    }
+
+
+# Status order for board columns
+BOARD_STATUS_ORDER = ["TODO", "IN_PROGRESS", "IN_REVIEW", "DONE", "CANCELLED"]
+
+
+def get_board_tasks_grouped(
+    project: str,
+    board_name: str,
+) -> list[dict[str, Any]]:
+    """
+    Return tasks for a board grouped by status for board view.
+    Returns a list of { "status": str, "label": str, "tasks": [ _task_to_json, ... ] }
+    in BOARD_STATUS_ORDER. Skips validation/auth (caller must validate project).
+    """
+    repo = open_repo(project)
+    ref = _board_ref(board_name)
+    try:
+        b = get_board(repo, ref)
+    except (ValueError, KeyError):
+        return [
+            {"status": s, "label": s.replace("_", " ").title(), "tasks": []}
+            for s in BOARD_STATUS_ORDER
+        ]
+    if not b:
+        return [
+            {"status": s, "label": s.replace("_", " ").title(), "tasks": []}
+            for s in BOARD_STATUS_ORDER
+        ]
+    task_oids = getattr(b, "tasks", []) or []
+    by_status: dict[str, list[dict[str, Any]]] = {s: [] for s in BOARD_STATUS_ORDER}
+    for oid_hex in task_oids:
+        try:
+            oid = pygit2.Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+            t = get_task_by_oid(repo, oid)
+            if t:
+                d = _task_to_json(t)
+                status_key = (d.get("status") or "TODO") if d else "TODO"
+                if status_key not in by_status:
+                    by_status[status_key] = []
+                by_status[status_key].append(d)
+        except (ValueError, KeyError, TypeError):
+            continue
+    return [
+        {"status": s, "label": s.replace("_", " ").title(), "tasks": by_status.get(s, [])}
+        for s in BOARD_STATUS_ORDER
+    ]
+
+
+@task_router.get("/list", response_class=JSONResponse)
+def task_list(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+) -> JSONResponse:
+    """List all tasks on a board."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    ref = _board_ref(board)
+    b = get_board(repo, ref)
+    if not b:
+        raise HTTPException(status_code=404, detail="Board not found")
+    task_oids = getattr(b, "tasks", [])
+    tasks = []
+    for oid_hex in task_oids:
+        try:
+            oid = pygit2.Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+            t = get_task_by_oid(repo, oid)
+            if t:
+                tasks.append(_task_to_json(t))
+        except (ValueError, KeyError, TypeError):
+            continue
+    return JSONResponse(content=tasks)
+
+
+@task_router.post("/create", response_class=JSONResponse)
+def task_create(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+    title: str = Query(..., description="Task title"),
+    description: str = Query("", description="Task description"),
+    status: str = Query("TODO", description="Task status"),
+    priority: str = Query("LOW", description="Task priority"),
+    assignee: str = Query("", description="Assignee"),
+    due_date: str = Query("", description="Due date ISO"),
+) -> JSONResponse:
+    """Create a new task on a board."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    board_ref = _board_ref(board)
+    b = get_board(repo, board_ref)
+    if not b:
+        raise HTTPException(status_code=404, detail="Board not found")
+    task_id = f"task_{int(time.time() * 1000)}"
+    ref = _task_ref(task_id)
+    target = _repo_head_or_empty(repo)
+    status_enum = getattr(Task.Status, status, None) or Task.Status.TODO
+    priority_enum = getattr(Task.Priority, priority, None) or Task.Priority.LOW
+    due = None
+    if due_date:
+        try:
+            from datetime import datetime
+            due = datetime.fromisoformat(due_date.replace("Z", "+00:00"))
+        except ValueError:
+            pass
+    task = Task(
+        target, ref, tagger="",
+        title=title,
+        description=description or "",
+        status=status_enum,
+        priority=priority_enum,
+        assignee=assignee or None,
+        due_date=due,
+    )
+    oid = task.write(repo)
+    b.tasks = getattr(b, "tasks", []) or []
+    b.tasks.append(str(oid))
+    b.update_message()
+    b.write(repo)
+    return JSONResponse(content={"task_id": task_id, "ref": ref, "oid": str(oid)})
+
+
+@task_router.post("/update", response_class=JSONResponse)
+async def task_update(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+    task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tasks/task_123)"),
+) -> JSONResponse:
+    """Update a task (body: optional title, description, status, priority, assignee, due_date)."""
+    _require_auth_disabled()
+    _validate_project(project)
+    try:
+        body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception:
+        body = {}
+    repo = open_repo(project)
+    ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
+    t = get_task(repo, ref)
+    if not t:
+        raise HTTPException(status_code=404, detail="Task not found")
+    if "title" in body:
+        t.title = body["title"]
+    if "description" in body:
+        t.description = body.get("description", "")
+    if "status" in body and hasattr(Task.Status, body["status"]):
+        t.status = Task.Status(body["status"])
+    if "priority" in body and hasattr(Task.Priority, body["priority"]):
+        t.priority = Task.Priority(body["priority"])
+    if "assignee" in body:
+        t.assignee = body["assignee"] or None
+    if "due_date" in body:
+        try:
+            from datetime import datetime
+            t.due_date = datetime.fromisoformat(str(body["due_date"]).replace("Z", "+00:00")) if body["due_date"] else None
+        except ValueError:
+            pass
+    old_oid = str(repo.references[ref].resolve().target)
+    t.update_message()
+    new_oid = t.write(repo)
+    board_ref = _board_ref(board)
+    b = get_board(repo, board_ref)
+    if b and getattr(b, "tasks", None):
+        b.tasks = [str(new_oid) if o == old_oid else o for o in b.tasks]
+        b.update_message()
+        b.write(repo)
+    return JSONResponse(content=_task_to_json(t))
+
+
+@task_router.post("/delete", response_class=JSONResponse)
+def task_delete(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+    task_ref: str = Query(..., alias="task", description="Task ref"),
+) -> JSONResponse:
+    """Delete a task (remove ref and remove from board.tasks)."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
+    if ref not in repo.references:
+        raise HTTPException(status_code=404, detail="Task not found")
+    oid_hex = str(repo.references[ref].resolve().target)
+    repo.references.delete(ref)
+    board_ref = _board_ref(board)
+    b = get_board(repo, board_ref)
+    if b and getattr(b, "tasks", None):
+        try:
+            b.tasks = [x for x in b.tasks if x != oid_hex]
+            b.update_message()
+            b.write(repo)
+        except Exception:
+            pass
+    return JSONResponse(content={"message": "Task deleted"})
+
+
+# ---------- Comment Routes ----------
+
+comment_router = APIRouter(prefix="/comments", tags=["comments"])
+
+
+def _comment_to_json(c: Comment) -> dict[str, Any]:
+    return {
+        "content": c.content,
+        "tagger": c.tagger,
+        "created_at": getattr(c, "created_at").isoformat() if getattr(c, "created_at") else None,
+        "edited_at": getattr(c, "edited_at").isoformat() if getattr(c, "edited_at") else None,
+    }
+
+
+@comment_router.get("/list", response_class=JSONResponse)
+def comment_list(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+    task: str = Query(..., description="Task ref (e.g. refs/tasks/task_123)"),
+) -> JSONResponse:
+    """List all comments for a task."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    task_ref = task if task.startswith("refs/") else _task_ref(task)
+    t = get_task(repo, task_ref)
+    if not t:
+        raise HTTPException(status_code=404, detail="Task not found")
+    comment_oids = getattr(t, "comments", []) or []
+    comments = []
+    for oid_hex in comment_oids:
+        try:
+            oid = pygit2.Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
+            c = get_comment(repo, oid)
+            if c:
+                comments.append(_comment_to_json(c))
+        except (ValueError, KeyError, TypeError):
+            continue
+    return JSONResponse(content=comments)
+
+
+@comment_router.post("/create", response_class=JSONResponse)
+def comment_create(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+    task: str = Query(..., description="Task ref"),
+    content: str = Query(..., description="Comment content"),
+) -> JSONResponse:
+    """Create a new comment on a task."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    task_ref = task if task.startswith("refs/") else _task_ref(task)
+    t = get_task(repo, task_ref)
+    if not t:
+        raise HTTPException(status_code=404, detail="Task not found")
+    resolved = repo.revparse_single(task_ref)
+    target_oid = resolved.oid if hasattr(resolved, "oid") else pygit2.Oid(hex=str(resolved))
+    comment = Comment(target=target_oid, tagger="", content=content or "")
+    comment_oid = comment.write(repo)
+    t.comments = getattr(t, "comments", []) or []
+    t.comments.append(str(comment_oid))
+    t.update_message()
+    t.write(repo)
+    return JSONResponse(content={"oid": str(comment_oid), "message": "Comment created"})
+
+
+@comment_router.post("/update", response_class=JSONResponse)
+async def comment_update(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+    task: str = Query(..., description="Task ref (to update task.comments)"),
+    comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
+) -> JSONResponse:
+    """Update a comment (body: content). Writes new comment object and updates task.comments."""
+    _require_auth_disabled()
+    _validate_project(project)
+    try:
+        body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception:
+        body = {}
+    repo = open_repo(project)
+    oid = pygit2.Oid(hex=comment_oid)
+    c = get_comment(repo, oid)
+    if not c:
+        raise HTTPException(status_code=404, detail="Comment not found")
+    if "content" in body:
+        c.content = body["content"]
+    from datetime import datetime
+    c.edited_at = datetime.now()
+    c.update_message()
+    new_oid = c.write(repo)
+    task_ref = task if task.startswith("refs/") else _task_ref(task)
+    t = get_task(repo, task_ref)
+    if t and getattr(t, "comments", None):
+        t.comments = [str(new_oid) if str(co) == comment_oid else co for co in t.comments]
+        t.update_message()
+        t.write(repo)
+    return JSONResponse(content={**_comment_to_json(c), "oid": str(new_oid), "message": "Comment updated"})
+
+
+@comment_router.post("/delete", response_class=JSONResponse)
+def comment_delete(
+    request: Request,
+    project: str,
+    board: str = Query(..., description="Board name"),
+    comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
+) -> JSONResponse:
+    """Delete a comment (object remains in ODB; caller may remove from task.comments)."""
+    _require_auth_disabled()
+    _validate_project(project)
+    repo = open_repo(project)
+    oid = pygit2.Oid(hex=comment_oid)
+    try:
+        c = get_comment(repo, oid)
+    except (ValueError, KeyError):
+        raise HTTPException(status_code=404, detail="Comment not found")
+    if not c:
+        raise HTTPException(status_code=404, detail="Comment not found")
+    return JSONResponse(content={"message": "Comment deleted"})
\ No newline at end of file
diff --git a/pygitweb/templates/board.html b/pygitweb/templates/board.html
new file mode 100644
index 0000000..e48355e
--- /dev/null
+++ b/pygitweb/templates/board.html
@@ -0,0 +1,119 @@
+{# Board view: columns (dropzones) and task cards. Expects: project, project_url, board_name, board_url, columns [ { status, label, tasks } ] #}
+
+<template id="board-card-template">
+  {% include "board_card.html" without context %}
+</template>
+
+<h1 class="page-title d-flex align-items-center gap-2">
+  <a href="{{ project_url }}" class="text-reset">{{ project }}</a>
+  <span class="text-muted">/</span>
+  <span>{{ board_name }} board</span>
+</h1>
+
+<div class="board-columns d-flex gap-3 overflow-auto pb-2" style="min-height: 70vh;">
+  {% for col in columns %}
+  <div class="board-column flex-shrink-0 rounded border p-2 dropzone"
+       data-status="{{ col.status }}"
+       style="min-width: 280px; width: 280px;">
+    <div class="d-flex align-items-center justify-content-between mb-2">
+      <h3 class="h6 mb-0 text-muted">{{ col.label }}</h3>
+      <button type="button" class="badge bg-secondary border-0 board-column-add-card" aria-label="Add card">{{ col.tasks | length }}</button>
+    </div>
+    <div class="board-column-cards d-flex flex-column">
+      {% for task in col.tasks %}
+      {% include "board_card.html" %}
+      {% endfor %}
+    </div>
+  </div>
+  {% endfor %}
+</div>
+
+<script>
+(function() {
+  var template = document.getElementById('board-card-template');
+  var addCardButtons = document.querySelectorAll('.board-column-add-card');
+
+  function addDefaultCard(ev) {
+    if (!template || !template.content) return;
+    var column = ev.currentTarget.closest('.board-column');
+    var cardsContainer = column && column.querySelector('.board-column-cards');
+    if (!cardsContainer) return;
+    var clone = template.content.cloneNode(true);
+    var card = clone.querySelector && clone.querySelector('.board-card');
+    if (card) {
+      var titleEl = card.querySelector('.board-card-title');
+      if (titleEl) titleEl.textContent = 'New task';
+    }
+    cardsContainer.appendChild(clone);
+    var badge = column.querySelector('.board-column-add-card');
+    if (badge) {
+      var n = cardsContainer.querySelectorAll('.board-card').length;
+      badge.textContent = n;
+    }
+  }
+
+  addCardButtons.forEach(function(btn) {
+    btn.addEventListener('click', addDefaultCard);
+  });
+
+  var boardColumns = document.querySelector('.board-columns');
+  var dropzones = document.querySelectorAll('.board-column.dropzone');
+  var draggedCard = null;
+
+  function updateColumnBadge(column) {
+    var cardsContainer = column && column.querySelector('.board-column-cards');
+    var badge = column && column.querySelector('.board-column-add-card');
+    if (badge && cardsContainer) {
+      badge.textContent = cardsContainer.querySelectorAll('.board-card').length;
+    }
+  }
+
+  function handleDragStart(e) {
+    var card = e.target.closest && e.target.closest('.board-card');
+    if (!card) return;
+    draggedCard = card;
+    e.dataTransfer.effectAllowed = 'move';
+    e.dataTransfer.setData('text/plain', card.dataset.taskRef || '');
+    card.classList.add('opacity-50');
+  }
+  function handleDragEnd(e) {
+    var card = e.target.closest && e.target.closest('.board-card');
+    if (card) card.classList.remove('opacity-50');
+    draggedCard = null;
+  }
+
+  function handleDragOver(e) {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'move';
+    e.currentTarget.classList.add('border-primary');
+  }
+  function handleDragLeave(e) {
+    e.currentTarget.classList.remove('border-primary');
+  }
+  function handleDrop(e) {
+    e.preventDefault();
+    e.currentTarget.classList.remove('border-primary');
+    if (!draggedCard) return;
+    var targetColumn = e.currentTarget.closest && e.currentTarget.closest('.board-column');
+    var targetCards = targetColumn && targetColumn.querySelector('.board-column-cards');
+    if (!targetCards) return;
+    var sourceColumn = draggedCard.closest && draggedCard.closest('.board-column');
+    if (sourceColumn === targetColumn) return;
+    draggedCard.dataset.taskStatus = targetColumn.dataset.status || '';
+    targetCards.appendChild(draggedCard);
+    updateColumnBadge(sourceColumn);
+    updateColumnBadge(targetColumn);
+    // TODO: POST to update task status on server (e.g. /project/.../tasks/update?task=... with body { status })
+  }
+
+  if (boardColumns) {
+    boardColumns.addEventListener('dragstart', handleDragStart);
+    boardColumns.addEventListener('dragend', handleDragEnd);
+  }
+  dropzones.forEach(function(el) {
+    el.addEventListener('dragover', handleDragOver);
+    el.addEventListener('dragleave', handleDragLeave);
+    el.addEventListener('drop', handleDrop);
+  });
+})();
+</script>
diff --git a/pygitweb/templates/board_card.html b/pygitweb/templates/board_card.html
new file mode 100644
index 0000000..7154ce6
--- /dev/null
+++ b/pygitweb/templates/board_card.html
@@ -0,0 +1,30 @@
+{# Static card structure. When `task` is provided and truthy, title and meta are filled (server); otherwise empty for template clone / JS fill. #}
+<div class="card board-card mb-2 draggable" data-task-ref="{{ task.ref if task else '' }}" data-task-status="{{ (task.status or 'TODO') if task else 'TODO' }}" draggable="true">
+  <div class="card-body py-2 px-3">
+    <div class="board-card-title fw-medium text-truncate"{% if task %} title="{{ task.title }}"{% endif %}>{% if task %}{{ task.title or 'Untitled' }}{% endif %}</div>
+    <div class="board-card-description small text-muted mt-1 text-truncate">{% if task and task.description %}{{ task.description }}{% else %}{% endif %}</div>
+    <div class="board-card-meta d-flex align-items-center gap-1 mt-1 small text-muted">
+      {% if task %}
+        {% if task.priority and task.priority != 'LOW' %}
+        <span class="badge badge-outline text-{{ 'danger' if task.priority == 'CRITICAL' else 'warning' if task.priority == 'HIGH' else 'secondary' }}">{{ task.priority }}</span>
+        {% else %}
+        <span class="badge badge-outline text-secondary">LOW</span>
+        {% endif %}
+        {% if task.assignee %}
+        <span class="text-truncate">{{ task.assignee }}</span>
+        {% else %}
+        <span class="text-truncate">None</span>
+        {% endif %}
+        {% if task.comments_count %}
+        <span class="ms-auto">{{ task.comments_count }} comment{{ 's' if task.comments_count != 1 else '' }}</span>
+        {% else %}
+        <span class="ms-auto">0 comments</span>
+        {% endif %}
+      {% else %}
+        <span class="badge badge-outline text-secondary">LOW</span>
+        <span class="text-truncate">None</span>
+        <span class="ms-auto">0 comments</span>
+      {% endif %}
+    </div>
+  </div>
+</div>
