diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index 2bdb3d4..ea327b4 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -6,7 +6,6 @@ Ported from gitweb/gitweb.perl action handlers. Invoked by main.dispatch.
 from __future__ import annotations
 
 import base64
-import html
 import mimetypes
 import os
 from datetime import UTC, datetime, timedelta, timezone
@@ -45,21 +44,16 @@ from pygitweb.hooks_install import list_installable_hooks, list_installed_hooks
 from pygitweb.merge_requests import merge_request_tag_response, resolve_merge_request_tag_to_tip
 from pygitweb.projects import git_get_project_owner
 from pygitweb.templates_env import (
-	POSTAMBLE,
-	PREAMBLE,
 	Cell,
 	env,
 	html_format,
 	html_join,
 	html_link,
-	jinja_escape,
+	render_page,
 	trusted_html,
 )
 from pygitweb.validation import is_valid_pathname, is_valid_ref_format
 
-_VENDOR_HIGHLIGHT = '<script defer src="/static/vendor/highlight-11.9.0.min.js"></script>'
-_VENDOR_SHOWDOWN = '<script defer src="/static/vendor/showdown-2.1.0.min.js"></script>'
-
 
 class BlameHunkInfo(TypedDict):
 	commit_id: str
@@ -239,15 +233,15 @@ def _format_commit_table_row(project: str, commit: dict[str, Any], short: bool =
 	return [commit_cell, subject, author_display, date_str, age_str, diff_cell]
 
 
-def _render_pagination(
+def _pagination_context(
 	request: Request,
 	page: int,
 	pagecount: int,
 	has_prev: bool,
 	has_next: bool,
 	total_pages: int | None = None,
-) -> str:
-	"""Render Tabler pagination HTML."""
+) -> dict[str, Any]:
+	"""Context for pagination.html."""
 	prev_url = _pagination_url(request, page - 1, pagecount) if has_prev else ""
 	next_url = _pagination_url(request, page + 1, pagecount) if has_next else ""
 	page_links = None
@@ -261,35 +255,16 @@ def _render_pagination(
 			page_links = [(1, _pagination_url(request, 1, pagecount))] + [(-1, "")] + page_links
 		if end < total_pages:
 			page_links = page_links + [(-1, "")] + [(total_pages, _pagination_url(request, total_pages, pagecount))]
-	return env.get_template("pagination.html").render(
-		current_page=page,
-		pagecount=pagecount,
-		has_prev=has_prev,
-		has_next=has_next,
-		prev_url=prev_url,
-		next_url=next_url,
-		total_pages=total_pages,
-		page_links=page_links,
-	)
-
-
-def _render_collapsible_section(
-	title: str,
-	body: str,
-	*,
-	open_default: bool = True,
-	flush_table: bool = False,
-) -> str:
-	open_attr = " open" if open_default else ""
-	body_class = "card-body p-0" if flush_table else "card-body"
-	return (
-		f'<details class="summary-section card mt-3"{open_attr}>'
-		f'<summary class="card-header summary-section-toggle">'
-		f'<h2 class="card-title mb-0">{jinja_escape(title)}</h2>'
-		f"</summary>"
-		f'<div class="{body_class}">{body}</div>'
-		f"</details>"
-	)
+	return {
+		"current_page": page,
+		"pagecount": pagecount,
+		"has_prev": has_prev,
+		"has_next": has_next,
+		"prev_url": prev_url,
+		"next_url": next_url,
+		"total_pages": total_pages,
+		"page_links": page_links,
+	}
 
 
 def _render_tree_table(project: str, ref: str | None, f: str | None) -> str:
@@ -326,6 +301,31 @@ def _render_tree_table(project: str, ref: str | None, f: str | None) -> str:
 	return env.get_template("table.html").render(cols=["Name", "Type", ""], rows=rows)
 
 
+def _table_page(
+	*,
+	title: str,
+	heading: str | None = None,
+	cols: list[str] | None = None,
+	rows: list[list[Cell]] | None = None,
+	project: str | None = None,
+	pagination: dict[str, Any] | None = None,
+	empty_message: str | None = None,
+	empty_muted: bool = False,
+) -> HTMLResponse:
+	ctx: dict[str, object] = {
+		"title": title,
+		"heading": title if heading is None else heading,
+		"project": project,
+		"pagination": pagination,
+		"empty_message": empty_message,
+		"empty_muted": empty_muted,
+	}
+	if cols is not None:
+		ctx["cols"] = cols
+		ctx["rows"] = rows or []
+	return render_page("table_page.html", **ctx)
+
+
 def _render_readme_card(
 	project: str,
 	ref_oid: str,
@@ -338,24 +338,17 @@ def _render_readme_card(
 	blob_dir is the current tree path for relative links.
 	"""
 	is_markdown = readme_filename.lower().endswith(".md")
-	blob_base = f"/project/{project}?a=blob&h={quote(ref_oid, safe='')}&f="
-	blob_base_attr = blob_base.replace("&", "&amp;").replace('"', "&quot;")
-	blob_dir_attr = blob_dir.replace("&", "&amp;").replace('"', "&quot;") if blob_dir else ""
 	if is_markdown:
-		readme_b64 = base64.b64encode(readme_content.encode("utf-8")).decode("ascii")
-		body = (
-			'<div id="readme-container" class="markdown-body" data-readme-b64="'
-			+ readme_b64
-			+ '" data-blob-base="'
-			+ blob_base_attr
-			+ '"'
+		return env.get_template("readme_card.html").render(
+			markdown=True,
+			readme_b64=base64.b64encode(readme_content.encode("utf-8")).decode("ascii"),
+			blob_base=f"/project/{project}?a=blob&h={quote(ref_oid, safe='')}&f=",
+			blob_dir=blob_dir,
 		)
-		if blob_dir_attr:
-			body += ' data-blob-dir="' + blob_dir_attr + '"'
-		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)
+	return env.get_template("readme_card.html").render(
+		markdown=False,
+		plain=sanitize(readme_content) or "",
+	)
 
 
 def _commit_unified_diff_or_raise(project: str, h: str) -> tuple[str, str]:
@@ -472,25 +465,18 @@ def git_object(project: str, h: str | None) -> Response:
 			raise HTTPException(status_code=404, detail="Not a blob")
 		data = obj.data
 		text = to_utf8(data) or ""
-		body = jinja_escape(sanitize(text) or "") or ""
 		lines = text.split("\n")
 		num_lines = max(1, len(lines))
-		line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
 		oid_short = str(obj.id)[:7]
-		blob_html = (
-			f'<div class="blob-view">'
-			f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
-			f'<pre class="blob-content"><code class="hljs">{body}</code></pre>'
-			f"</div>"
-		)
-		pre = PREAMBLE.render(
+		return render_page(
+			"blob.html",
 			title=f"Blob {oid_short} - {project}",
-			site_name=settings.SITE_NAME,
-		)
-		return HTMLResponse(
-			f"{pre}<h1>Blob {jinja_escape(oid_short)}</h1>"
-			f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
-			f"{blob_html}{POSTAMBLE}"
+			heading=f"Blob {oid_short}",
+			project=project,
+			code=sanitize(text) or "",
+			line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
+			lang="",
+			blame=False,
 		)
 	raise HTTPException(status_code=404, detail="Unknown object type")
 
@@ -563,41 +549,35 @@ def git_search(
 def git_search_page(project: str) -> HTMLResponse:
 	"""Per-project search UI page. Calls /project/{project}?a=search via fetch."""
 	project_enc = quote(project, safe="/")
-	pre = PREAMBLE.render(
+	return render_page(
+		"search.html",
 		title=f"Search - {project}",
-		site_name=settings.SITE_NAME,
-	)
-	body = env.get_template("search.html").render(
 		project=project,
 		project_enc=project_enc,
 		project_url=f"/project/{project_enc}",
 		search_api_url=f"/project/{project_enc}",
 	)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
-def _hooks_summary_cell(project: str, project_enc: str) -> Cell:
-	"""Render the Hooks row: installed samples with versions and an install dropdown."""
-	tpl = env.get_template("hooks_summary_cell.html")
+def _hooks_context(project: str, project_enc: str) -> dict[str, Any]:
+	"""Data for hooks_summary_cell.html."""
 	try:
 		installed = list_installed_hooks(project)
 		available = list_installable_hooks(project)
 	except (KeyError, OSError):
-		return trusted_html(tpl.render(mode="unavailable"))
-	return trusted_html(
-		tpl.render(
-			mode="manage",
-			project_enc=project_enc,
-			installed=installed,
-			available=available,
-		)
-	)
+		return {"mode": "unavailable", "project_enc": project_enc, "installed": [], "available": []}
+	return {
+		"mode": "manage",
+		"project_enc": project_enc,
+		"installed": installed,
+		"available": available,
+	}
 
 
-def _merge_request_summary_cell(project: str) -> Cell:
+def _merge_request_context(project: str) -> dict[str, Any] | None:
 	heads = git_get_heads_list(project)
 	if not heads:
-		return html_format('<span class="text-muted">{}</span>', "No branches.")
+		return None
 	refs = [h[1] for h in heads]
 	branches = [{"short": h[0], "ref": h[1]} for h in heads]
 	if "refs/heads/main" in refs:
@@ -607,14 +587,12 @@ def _merge_request_summary_cell(project: str) -> Cell:
 	else:
 		ours_default_ref = refs[0]
 	theirs_default_ref = next((r for r in refs if r != ours_default_ref), refs[0])
-	return trusted_html(
-		env.get_template("merge_request_summary_cell.html").render(
-			project=project,
-			branches=branches,
-			theirs_default_ref=theirs_default_ref,
-			ours_default_ref=ours_default_ref,
-		)
-	)
+	return {
+		"project": project,
+		"branches": branches,
+		"theirs_default_ref": theirs_default_ref,
+		"ours_default_ref": ours_default_ref,
+	}
 
 
 def _project_has_boards(project: str) -> bool:
@@ -624,6 +602,27 @@ def _project_has_boards(project: str) -> bool:
 		return False
 
 
+class BoardLink(TypedDict):
+	href: str
+	text: str
+	style: str | None
+
+
+def board_link_spec(project: str) -> BoardLink:
+	if _project_has_boards(project):
+		return {
+			"href": f"/project/{quote(project, safe='/')}/board/",
+			"text": "project board",
+			"style": None,
+		}
+	style = "color: #999; cursor: not-allowed;" if settings.AUTH else None
+	return {
+		"href": "/board/create?project=" + quote(project, safe=""),
+		"text": "create board",
+		"style": style,
+	}
+
+
 def create_board_link(project: str) -> Cell:
 	href = "/board/create?project=" + quote(project, safe="")
 	if settings.AUTH:
@@ -632,9 +631,10 @@ def create_board_link(project: str) -> Cell:
 
 
 def project_board_link(project: str) -> Cell:
-	if _project_has_boards(project):
-		return html_link(f"/project/{quote(project, safe='/')}/board/", "project board")
-	return create_board_link(project)
+	spec = board_link_spec(project)
+	if spec["style"]:
+		return html_link(spec["href"], spec["text"], style=spec["style"])
+	return html_link(spec["href"], spec["text"])
 
 
 def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTMLResponse:
@@ -643,79 +643,33 @@ def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTM
 	owner = git_get_project_owner(project) or ""
 	initial_ref_state = summary_ref_state(project, "HEAD")
 	project_enc = quote(project, safe="/")
-
-	rows: list[list[Cell]] = [
-		["Owner", owner],
-		[
-			trusted_html(
-				'<label for="summary-ref-select">Ref</label> '
-				'<select id="summary-ref-select">'
-				'<option value="HEAD" selected>HEAD</option>'
-				"</select>"
-			),
-			html_join([
-				html_link(
-					initial_ref_state["commit_href"],
-					initial_ref_state["display"],
-					id="summary-ref-commit-link",
-				),
-				": ",
-				html_link(initial_ref_state["browse_href"], "browse", id="summary-ref-browse-link"),
-				" - ",
-				html_link(initial_ref_state["log_href"], "log", id="summary-ref-log-link"),
-				" - ",
-				html_link(initial_ref_state["shortlog_href"], "shortlog", id="summary-ref-shortlog-link"),
-			]),
-		],
-		["Branches", html_link(f"/project/{project}?a=heads", "view branches")],
-		["Merge request", _merge_request_summary_cell(project)],
-		["Tags", html_link(f"/project/{project}?a=tags", "view tags")],
-		["Remotes", html_link(f"/project/{project}?a=remotes", "view remotes")],
-		["Board", project_board_link(project)],
-		["Search", html_link(f"/project/{project_enc}/search", "search files")],
-		["Hooks", _hooks_summary_cell(project, project_enc)],
-	]
-	if extra_rows:
-		rows.extend(extra_rows)
-	table = env.get_template("table.html").render(
-		cols=["Field", "Value"],
-		rows=rows,
-	)
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - {project}", site_name=settings.SITE_NAME)
-	body_parts = [f"{pre}<h1>{jinja_escape(project)}</h1>"]
-	if descr:
-		body_parts.append(f"<p>{jinja_escape(descr)}</p>")
-	body_parts.append(_render_collapsible_section("Project", table, flush_table=True))
-	files_body = (
-		f'<div id="summary-tree-container" class="summary-tree-table">'
-		f"{initial_ref_state['tree_html']}</div>"
-		f'<p class="px-3 pb-3 mb-0"><a id="summary-tree-browse-link" href="{initial_ref_state["browse_href"]}">'
-		f"Browse full tree</a></p>"
+	ref_view: dict[str, object] = dict(initial_ref_state)
+	ref_view["tree_html"] = trusted_html(initial_ref_state["tree_html"])
+	ref_view["readme_html"] = trusted_html(initial_ref_state["readme_html"])
+	return render_page(
+		"summary.html",
+		title=f"{settings.SITE_NAME} - {project}",
+		project=project,
+		descr=descr,
+		owner=owner,
+		ref=ref_view,
+		project_url=f"/project/{project}",
+		project_url_enc=f"/project/{project_enc}",
+		board=board_link_spec(project),
+		hooks=_hooks_context(project, project_enc),
+		merge_request=_merge_request_context(project),
+		extra_rows=extra_rows or [],
 	)
-	body_parts.append(_render_collapsible_section("Files", files_body))
-	body_parts.append(_VENDOR_SHOWDOWN)
-	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 type="module" src="/static/summary-ref-switcher.js"></script>')
-	body_parts.append('<script src="/static/hook-install.js"></script>')
-
-	body_parts.append(POSTAMBLE)
-	return HTMLResponse("".join(body_parts))
 
 
 def git_remotes(project: str) -> HTMLResponse:
 	"""Remotes page: list configured remotes (name, url, push_url)."""
 	remotes = git_get_remotes_info(project)
+	title = f"Remotes - {project}"
 	if not remotes:
-		pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
-		return HTMLResponse(f"{pre}<h1>Remotes</h1><p>No remotes configured.</p>{POSTAMBLE}")
+		return _table_page(title=title, heading="Remotes", empty_message="No remotes configured.")
 	rows = [[r["name"], r["url"] or "—", r["push_url"] or "—"] for r in remotes]
-	table = env.get_template("table.html").render(
-		cols=["Name", "URL", "Push URL"],
-		rows=rows,
-	)
-	pre = PREAMBLE.render(title=f"Remotes - {project}", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<h1>Remotes</h1>{table}{POSTAMBLE}")
+	return _table_page(title=title, heading="Remotes", cols=["Name", "URL", "Push URL"], rows=rows)
 
 
 def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -> Response:
@@ -739,22 +693,17 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 				media_type = "application/octet-stream"
 		return Response(content=data, media_type=media_type)
 	text = to_utf8(data) or ""
-	body = jinja_escape(sanitize(text) or "") or ""
 	lines = text.split("\n")
 	num_lines = max(1, len(lines))
-	line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
 	ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
-	lang = BLOB_LANG.get(ext, "")
-	lang_attr = f" language-{lang}" if lang else ""
-	blob_html = (
-		f'<div class="blob-view">'
-		f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
-		f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
-		f"</div>"
+	return render_page(
+		"blob.html",
+		title=f"{f} - {project}",
+		code=sanitize(text) or "",
+		line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
+		lang=BLOB_LANG.get(ext, ""),
+		blame=False,
 	)
-	blob_script = f'{_VENDOR_HIGHLIGHT}<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}")
 
 
 def _blame_hunk_end_line(hunk: pygit2.BlameHunk) -> int:
@@ -821,20 +770,21 @@ def _blame_for_file(project: str, h: str | None, f: str) -> list[BlameHunkInfo]:
 	return _merge_blame_hunks(repo, blame)
 
 
-def _render_blame_commits_column(project: str, hunks: list[BlameHunkInfo], num_lines: int) -> str:
+def _blame_line_links(project: str, hunks: list[BlameHunkInfo], num_lines: int) -> list[dict[str, str] | None]:
 	hunk_by_start = {h["line_start"]: h for h in hunks}
-	lines: list[str] = []
+	lines: list[dict[str, str] | None] = []
 	for line_no in range(1, num_lines + 1):
 		hunk = hunk_by_start.get(line_no)
 		if hunk is None:
-			lines.append("")
+			lines.append(None)
 			continue
 		cid = hunk["commit_id"]
-		oid_short = jinja_escape(cid[:7]) or ""
-		commit_link = f"/project/{project}?a=commit&h={quote(cid, safe='')}"
-		tooltip = html.escape(f"{hunk['author_name']} — {hunk['time_display']}", quote=True)
-		lines.append(f'<a href="{commit_link}" class="blame-commit-link" title="{tooltip}">{oid_short}</a>')
-	return "\n".join(lines)
+		lines.append({
+			"href": f"/project/{project}?a=commit&h={quote(cid, safe='')}",
+			"title": f"{hunk['author_name']} — {hunk['time_display']}",
+			"oid_short": cid[:7],
+		})
+	return lines
 
 
 def git_blame(project: str, h: str | None, f: str | None) -> HTMLResponse:
@@ -847,24 +797,18 @@ def git_blame(project: str, h: str | None, f: str | None) -> HTMLResponse:
 	blob, _ref_oid = result
 	hunks = _blame_for_file(project, h, f)
 	text = to_utf8(blob.data) or ""
-	body = jinja_escape(sanitize(text) or "") or ""
 	lines = text.split("\n")
 	num_lines = max(1, len(lines))
-	line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
-	blame_commits = _render_blame_commits_column(project, hunks, num_lines)
 	ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
-	lang = BLOB_LANG.get(ext, "")
-	lang_attr = f" language-{lang}" if lang else ""
-	blame_html = (
-		f'<div class="blob-view blame-view">'
-		f'<div class="blame-commits" aria-label="Blame commits">{blame_commits}</div>'
-		f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
-		f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
-		f"</div>"
+	return render_page(
+		"blob.html",
+		title=f"Blame {f} - {project}",
+		code=sanitize(text) or "",
+		line_nums="\n".join(str(i) for i in range(1, num_lines + 1)),
+		lang=BLOB_LANG.get(ext, ""),
+		blame=True,
+		blame_lines=_blame_line_links(project, hunks, num_lines),
 	)
-	blame_script = f'{_VENDOR_HIGHLIGHT}<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}")
 
 
 def git_blame_raw(project: str, h: str | None, f: str | None) -> JSONResponse:
@@ -895,29 +839,31 @@ def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
 	if not result:
 		raise HTTPException(status_code=404, detail="Tree or path not found")
 	_tree, ref_oid = result
-	base = f"/project/{project}"
-	breadcrumbs = [f'<a href="{base}">{jinja_escape(project)}</a>']
+	crumbs: list[dict[str, str]] = []
 	if f:
 		parts = f.strip("/").split("/")
 		for i, seg in enumerate(parts):
 			prefix = "/".join(parts[: i + 1])
-			breadcrumbs.append(f' / <a href="{_tree_url(project, ref_oid, prefix)}">{jinja_escape(seg)}</a>')
-	breadcrumb_html = "".join(breadcrumbs)
+			crumbs.append({"href": _tree_url(project, ref_oid, prefix), "label": seg})
 	title_path = f" / {f}" if f else ""
-	pre = PREAMBLE.render(
-		title=f"{jinja_escape(project)}{jinja_escape(title_path)} - Tree",
-		site_name=settings.SITE_NAME,
-	)
-	table = _render_tree_table(project, h, f)
-	body_parts = [f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{jinja_escape(title_path)}</h1>{table}"]
+	readme_html = ""
+	needs_showdown = False
 	readme = get_readme_at_ref_path(project, ref_oid, f or "")
 	if readme:
 		readme_filename, readme_content = readme
-		if readme_filename.lower().endswith(".md"):
-			body_parts.append(_VENDOR_SHOWDOWN)
-		body_parts.append(_render_readme_card(project, ref_oid, readme_filename, readme_content, f or ""))
-	body_parts.append(POSTAMBLE)
-	return HTMLResponse("".join(body_parts))
+		needs_showdown = readme_filename.lower().endswith(".md")
+		readme_html = _render_readme_card(project, ref_oid, readme_filename, readme_content, f or "")
+	return render_page(
+		"tree.html",
+		title=f"{project}{title_path} - Tree",
+		project=project,
+		project_href=f"/project/{project}",
+		breadcrumbs=crumbs,
+		path=f or "",
+		tree_html=trusted_html(_render_tree_table(project, h, f)),
+		readme_html=trusted_html(readme_html),
+		needs_showdown=needs_showdown,
+	)
 
 
 def git_log(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
@@ -933,13 +879,13 @@ def git_log(project: str, h: str | None, request: Request, page: int, pagecount:
 	for commit in commits:
 		rows.append(_format_commit_table_row(project, commit, short=False))
 	ref_display = h[:7] if h else "HEAD"
-	title = f"Log - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	table = env.get_template("table.html").render(
-		cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"], rows=rows
+	title = f"Log - {project} @ {ref_display}"
+	return _table_page(
+		title=title,
+		cols=["Commit", "Subject", "Author", "Date", "Age", "Diff"],
+		rows=rows,
+		pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
 	)
-	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
-	return HTMLResponse(f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}")
 
 
 def git_shortlog(project: str, h: str | None, request: Request, page: int, pagecount: int) -> HTMLResponse:
@@ -955,11 +901,13 @@ def git_shortlog(project: str, h: str | None, request: Request, page: int, pagec
 	for commit in commits:
 		rows.append(_format_commit_table_row(project, commit, short=True))
 	ref_display = h[:7] if h else "HEAD"
-	title = f"Shortlog - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	table = env.get_template("table.html").render(cols=["Commit", "Subject", "Author", "Age", "Diff"], rows=rows)
-	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
-	return HTMLResponse(f"{pre}<h1>{title}</h1>{table}{pagination_html}{POSTAMBLE}")
+	title = f"Shortlog - {project} @ {ref_display}"
+	return _table_page(
+		title=title,
+		cols=["Commit", "Subject", "Author", "Age", "Diff"],
+		rows=rows,
+		pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
+	)
 
 
 def git_history(
@@ -986,16 +934,13 @@ def git_history(
 	for commit in commits:
 		rows.append(_format_commit_table_row(project, commit, short=False))
 	ref_display = h[:7] if h else "HEAD"
-	title = f"History - {jinja_escape(f)} - {jinja_escape(project)} @ {jinja_escape(ref_display)}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	table = env.get_template("table.html").render(
-		cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"], rows=rows
-	)
-	pagination_html = _render_pagination(request, page, pagecount, page > 1, has_next)
-	return HTMLResponse(
-		f"{pre}<h1>{title}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
-		f"{table}{pagination_html}{POSTAMBLE}"
+	title = f"History - {f} - {project} @ {ref_display}"
+	return _table_page(
+		title=title,
+		project=project,
+		cols=["Commit", "Subject", "Author", "Date", "Age", "Patch"],
+		rows=rows,
+		pagination=_pagination_context(request, page, pagecount, page > 1, has_next),
 	)
 
 
@@ -1018,17 +963,13 @@ def git_heads(project: str, token: str | None) -> HTMLResponse:
 			html_link(commit_href, oid_short),
 			html_link(tree_href, "tree"),
 		])
-	title = f"Heads - {jinja_escape(project)}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	table = env.get_template("table.html").render(
+	title = f"Heads - {project}"
+	return _table_page(
+		title=title,
+		project=project,
 		cols=["Head", "Commit", ""],
 		rows=rows,
 	)
-	return HTMLResponse(
-		f"{pre}<h1>{title}</h1>"
-		+ f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>{table}"
-		+ POSTAMBLE
-	)
 
 
 def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLResponse:
@@ -1070,19 +1011,13 @@ def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLR
 			html_link(tag_link, name),
 			obj_cell,
 		])
-	title = f"Tags - {jinja_escape(project)}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	table = env.get_template("table.html").render(
+	title = f"Tags - {project}"
+	return _table_page(
+		title=title,
+		project=project,
 		cols=["Tag", "Object"],
 		rows=rows,
-	)
-	pagination_html = _render_pagination(
-		request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages
-	)
-	return HTMLResponse(
-		f"{pre}<h1>{title}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
-		f"{table}{pagination_html}{POSTAMBLE}"
+		pagination=_pagination_context(request, page, pagecount, page > 1, page < total_pages, total_pages=total_pages),
 	)
 
 
@@ -1153,13 +1088,12 @@ def git_tag(project: str, h: str | None, notice: str | None = None) -> HTMLRespo
 	]
 	if message:
 		table_rows.append(["Message", html_format("<pre class='tag-message'>{}</pre>", message)])
-	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
-	title = f"Tag {jinja_escape(tag_name)} - {jinja_escape(project)}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	return HTMLResponse(
-		f"{pre}<h1>Tag {jinja_escape(tag_name)}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
-		f"{table}{POSTAMBLE}"
+	return _table_page(
+		title=f"Tag {tag_name} - {project}",
+		heading=f"Tag {tag_name}",
+		project=project,
+		cols=["Field", "Value"],
+		rows=table_rows,
 	)
 
 
@@ -1204,13 +1138,12 @@ def git_commit(project: str, h: str | None) -> HTMLResponse:
 	if message:
 		table_rows.append(["", html_format("<pre class='commit-message'>{}</pre>", message)])
 	table_rows.append(["", html_join([html_link(diff_link, "diff"), " · ", html_link(patch_link, "patch")])])
-	table = env.get_template("table.html").render(cols=["Field", "Value"], rows=table_rows)
-	title = f"Commit {jinja_escape(oid_short)} - {jinja_escape(project)}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	return HTMLResponse(
-		f"{pre}<h1>Commit {jinja_escape(oid_short)}</h1>"
-		f"<p>Project: <a href='/project/{project}'>{jinja_escape(project)}</a></p>"
-		f"{table}{POSTAMBLE}"
+	return _table_page(
+		title=f"Commit {oid_short} - {project}",
+		heading=f"Commit {oid_short}",
+		project=project,
+		cols=["Field", "Value"],
+		rows=table_rows,
 	)
 
 
@@ -1218,15 +1151,14 @@ def git_commitdiff(project: str, h: str | None) -> HTMLResponse:
 	"""Commit diff page: unified diff rendered with diff2html."""
 	diff_text, oid_short = _commit_unified_diff_or_raise(project, h or "")
 	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
-	title = f"Commit diff {oid_short} - {project}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	body = env.get_template("commitdiff.html").render(
+	return render_page(
+		"commitdiff.html",
+		title=f"Commit diff {oid_short} - {project}",
 		project=project,
 		oid_short=oid_short,
 		diff_b64=diff_b64,
 		commit_link=f"/project/{project}?a=commit&h={quote(h or '', safe='')}",
 	)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
 def git_patch(project: str, h: str | None) -> PlainTextResponse:
@@ -1292,18 +1224,14 @@ def git_blobdiff(
 	if diff_text is None:
 		raise HTTPException(status_code=404, detail="Blob not found")
 	diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
-	blob_link = f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
-	pre = PREAMBLE.render(
-		title=f"Blob diff - {jinja_escape(path_new)} - {jinja_escape(project)}",
-		site_name=settings.SITE_NAME,
-	)
-	body = env.get_template("blobdiff.html").render(
+	return render_page(
+		"blobdiff.html",
+		title=f"Blob diff - {path_new} - {project}",
 		project=project,
 		path_new=path_new,
 		diff_b64=diff_b64,
-		blob_link=blob_link,
+		blob_link=f"/project/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}",
 	)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
 def git_blobpatch(
diff --git a/pygitweb/auth.py b/pygitweb/auth.py
index a52747d..1ad01ce 100644
--- a/pygitweb/auth.py
+++ b/pygitweb/auth.py
@@ -35,7 +35,7 @@ from pygitweb.password_hash import verify_password
 from pygitweb.permissions import Permission, PermissionPrincipal, has_branch_read_permission
 from pygitweb.projects import git_get_project_owner
 from pygitweb.sessions import create_session, get_session, revoke_session
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
+from pygitweb.templates_env import render_page
 
 ACCESS_TOKEN_COOKIE_NAME = "pygitweb_access_token"
 LOGIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@@ -319,16 +319,15 @@ async def login_page(
 	if not settings.AUTH:
 		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
 	next_safe = safe_next_url(next)
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Sign in", site_name=settings.SITE_NAME)
-	body = env.get_template("login.html").render(
-		site_name=settings.SITE_NAME,
+	return render_page(
+		"login.html",
+		title=f"{settings.SITE_NAME} - Sign in",
 		next=next_safe,
 		error=None,
 		local_available=is_local_auth_available(auth_config),
 		oauth_available=is_oauth_auth_available(auth_config),
 		oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
 	)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
 @auth_router.post(
@@ -351,16 +350,15 @@ async def login_form_submit(
 	next_safe = safe_next_url(next)
 	user = authenticate_local_user(username.strip(), password)
 	if user is None:
-		pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Sign in", site_name=settings.SITE_NAME)
-		body = env.get_template("login.html").render(
-			site_name=settings.SITE_NAME,
+		return render_page(
+			"login.html",
+			title=f"{settings.SITE_NAME} - Sign in",
 			next=next_safe,
 			error="Incorrect username or password.",
 			local_available=True,
 			oauth_available=is_oauth_auth_available(auth_config),
 			oauth_provider=str(auth_config.oauth_provider).strip() or "OAuth",
 		)
-		return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 	sid = create_session(user.username, email=user.email, auth_method="local")
 	resp = RedirectResponse(url=next_safe, status_code=303)
 	resp.set_cookie(
@@ -445,9 +443,12 @@ async def user_account_page(
 	token: Annotated[str | None, Depends(access_token_from_request)],
 ) -> HTMLResponse | RedirectResponse:
 	if not settings.AUTH:
-		pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Account", site_name=settings.SITE_NAME)
-		body = '<p class="text-muted">Authentication is not enabled on this server.</p>'
-		return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+		return render_page(
+			"message.html",
+			title=f"{settings.SITE_NAME} - Account",
+			paragraphs=["Authentication is not enabled on this server."],
+			muted=True,
+		)
 	if not is_auth_configured(auth_config):
 		raise HTTPException(
 			status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -456,9 +457,11 @@ async def user_account_page(
 	u = decode_access_token(token or "") if token else None
 	if u is None:
 		return RedirectResponse(url="/login?next=/user", status_code=303)
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Account", site_name=settings.SITE_NAME)
-	body = env.get_template("user_profile.html").render(username=u.username, site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+	return render_page(
+		"user_profile.html",
+		title=f"{settings.SITE_NAME} - Account",
+		username=u.username,
+	)
 
 
 @auth_router.get(
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 6a75c33..eb356ea 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -26,6 +26,7 @@ from pygitweb.actions import (
 	SearchSortQuery,
 	SummaryRefsPayload,
 	SummaryRefState,
+	board_link_spec,
 	create_board_link,
 	git_blame,
 	git_blame_raw,
@@ -49,7 +50,6 @@ from pygitweb.actions import (
 	git_tags,
 	git_tree,
 	parse_pagination,
-	project_board_link,
 	summary_ref_options,
 	summary_ref_state,
 )
@@ -122,7 +122,7 @@ from pygitweb.tasks import (
 	task_router,
 )
 from pygitweb.team import team_router
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_join, html_link, jinja_escape, trusted_html
+from pygitweb.templates_env import Cell, html_join, html_link, jinja_escape, render_page, trusted_html
 from pygitweb.timeline_cache import clear_timeline_cache_async, get_all_timeline_events, warm_timeline_cache_async
 from pygitweb.validation import is_valid_pathname
 
@@ -266,32 +266,30 @@ def git_project_list(
 		export_ok=settings.EXPORT_OK,
 	)
 	list_ = filter_projects_by_read_access(all_projects, token)
-	visibility_notice = ""
+	notice = ""
 	if not list_:
 		if all_projects and settings.AUTH:
-			visibility_notice = (
-				'<p class="text-warning">No projects are visible with your current authentication '
-				"and permissions. Sign in or ask an administrator for repository access.</p>"
-			)
+			notice = "hidden"
 		elif not all_projects:
-			visibility_notice = "<p>No projects found.</p>"
-
-	table = env.get_template("table.html").render(
-		cols=["Project", "Description", "Board"],
-		rows=[
-			[
-				html_link(
-					f"/project/{quote(pr.get('path', ''), safe='/')}",
-					pr.get("path", "") or "",
-				),
-				pr.get("descr") or pr.get("path", "") or "",
-				project_board_link(pr.get("path", "") or ""),
-			]
-			for pr in list_[:50]
-		],
+			notice = "empty"
+	projects = []
+	for pr in list_[:50]:
+		path = pr.get("path", "") or ""
+		board = board_link_spec(path)
+		projects.append({
+			"href": f"/project/{quote(path, safe='/')}",
+			"path": path,
+			"descr": pr.get("descr") or path,
+			"board_href": board["href"],
+			"board_text": board["text"],
+			"board_style": board["style"],
+		})
+	return render_page(
+		"project_list.html",
+		title=f"{settings.SITE_NAME} - Projects",
+		notice=notice,
+		projects=projects,
 	)
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Projects", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<h1>Project List</h1>{visibility_notice}{table}{POSTAMBLE}")
 
 
 @app.get("/index", response_class=PlainTextResponse)
@@ -367,28 +365,29 @@ def activity_page(
 			event_label,
 			description,
 		])
-	table = env.get_template("table.html").render(
-		cols=["Time", "Project Name", "Event", "Description"],
-		rows=rows,
-	)
 	prev_url = ""
 	next_url = ""
 	if p > 1:
 		prev_url = f"/activity?{urlencode({'page': str(p - 1), 'pagecount': str(pc)})}"
 	if has_next:
 		next_url = f"/activity?{urlencode({'page': str(p + 1), 'pagecount': str(pc)})}"
-	pagination_html = env.get_template("pagination.html").render(
-		current_page=p,
-		pagecount=pc,
-		has_prev=p > 1,
-		has_next=has_next,
-		prev_url=prev_url,
-		next_url=next_url,
-		total_pages=None,
-		page_links=None,
+	return render_page(
+		"table_page.html",
+		title=f"{settings.SITE_NAME} - Activity",
+		heading="Activity",
+		cols=["Time", "Project Name", "Event", "Description"],
+		rows=rows,
+		pagination={
+			"current_page": p,
+			"pagecount": pc,
+			"has_prev": p > 1,
+			"has_next": has_next,
+			"prev_url": prev_url,
+			"next_url": next_url,
+			"total_pages": None,
+			"page_links": None,
+		},
 	)
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Activity", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<h1>Activity</h1>{table}{pagination_html}{POSTAMBLE}")
 
 
 @app.get("/boards", response_class=HTMLResponse)
@@ -421,10 +420,15 @@ def boards_overview_page(
 				html_link(board_url, b["name"]),
 				str(b.get("task_count", 0)),
 			])
-	table = env.get_template("table.html").render(cols=["Project", "Board", "Tasks"], rows=rows)
-	body = table if rows else '<p class="text-muted">No projects found.</p>'
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Boards", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<h1>Boards</h1>{body}{POSTAMBLE}")
+	return render_page(
+		"table_page.html",
+		title=f"{settings.SITE_NAME} - Boards",
+		heading="Boards",
+		cols=["Project", "Board", "Tasks"],
+		rows=rows,
+		empty_message="No projects found." if not rows else None,
+		empty_muted=True,
+	)
 
 
 @app.get("/opml", response_class=PlainTextResponse)
@@ -488,18 +492,13 @@ def addproject_page(
 	show_sign_in_notice = (
 		settings.AUTH and is_auth_configured(auth_config) and (not token or decode_access_token(token) is None)
 	)
-	pre = PREAMBLE.render(
+	return render_page(
+		"addproject.html",
 		title=f"{settings.SITE_NAME} - Add Project",
-		site_name=settings.SITE_NAME,
-	)
-	tpl = env.get_template("addproject.html")
-	body = tpl.render(
-		site_name=settings.SITE_NAME,
 		show_sign_in_notice=show_sign_in_notice,
 		empty_repo_form_content=Project.form_content(),
 		upload_zip_form_content=ProjectZip.form_content(),
 	)
-	return HTMLResponse(status_code=200, content=f"{pre}{body}{POSTAMBLE}")
 
 
 @app.post("/addproject", response_class=HTMLResponse)
@@ -573,18 +572,15 @@ def project_board(
 	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"{jinja_escape(project)} - Board",
-		site_name=settings.SITE_NAME,
-	)
-	body = env.get_template("board.html").render(
+	return render_page(
+		"board.html",
+		title=f"{project} - Board",
 		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}/board-ref-manifest")
@@ -628,10 +624,7 @@ def project_subpage(
 		if content is None:
 			raise HTTPException(status_code=404, detail="No content for subpage")
 		content_lc = content.lstrip().lower()
-		pre = PREAMBLE.render(
-			title=f"{jinja_escape(subpage.link_name(project) or subpage_name)} - {jinja_escape(project)}",
-			site_name=settings.SITE_NAME,
-		)
+		title = f"{subpage.link_name(project) or subpage_name} - {project}"
 		if content_lc.startswith("<!doctype html") or "<html" in content_lc:
 			if raw:
 				stylesheet_href = "/static/main.css"
@@ -641,13 +634,20 @@ def project_subpage(
 				return HTMLResponse(f"{link_tag}{content}")
 			project_enc = quote(project, safe="/")
 			subpage_enc = quote(subpage_name, safe="")
-			iframe_src = f"/project/{project_enc}/subpage/{subpage_enc}?raw=true"
-			return HTMLResponse(
-				f"{pre}<h1>{jinja_escape(project)}</h1>"
-				f'<iframe class="subpage-frame" src="{iframe_src}" title="{jinja_escape(subpage_name)}"></iframe>'
-				f"{POSTAMBLE}"
+			return render_page(
+				"subpage.html",
+				title=title,
+				project=project,
+				subpage_name=subpage_name,
+				iframe_src=f"/project/{project_enc}/subpage/{subpage_enc}?raw=true",
 			)
-		return HTMLResponse(f"{pre}<h1>{jinja_escape(project)}</h1>{content}{POSTAMBLE}")
+		return render_page(
+			"subpage.html",
+			title=title,
+			project=project,
+			subpage_name=subpage_name,
+			content=trusted_html(content),
+		)
 	raise HTTPException(status_code=404, detail="Unknown subpage")
 
 
@@ -852,8 +852,11 @@ async def dispatch(
 		result = plugin.action(project, request)
 		if result is not None:
 			return result
-		pre = PREAMBLE.render(title=f"{plugin.action_name()} - {project}", site_name=settings.SITE_NAME)
-		return HTMLResponse(f"{pre}<p>Action completed.</p>{POSTAMBLE}")
+		return render_page(
+			"message.html",
+			title=f"{plugin.action_name()} - {project}",
+			paragraphs=["Action completed."],
+		)
 	if action == "summary":
 		return git_summary(project, extra_rows=_project_subpage_rows(project))
 	if action == "tree":
@@ -908,8 +911,11 @@ async def dispatch(
 			search_max_count,
 		)
 	# Stub others with minimal response
-	pre = PREAMBLE.render(title=f"{action} - {project}", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<p>Action: {jinja_escape(action)}</p><p>Project: {jinja_escape(project)}</p>{POSTAMBLE}")
+	return render_page(
+		"message.html",
+		title=f"{action} - {project}",
+		paragraphs=[f"Action: {action}", f"Project: {project}"],
+	)
 
 
 if __name__ == "__main__":
diff --git a/pygitweb/merge_requests.py b/pygitweb/merge_requests.py
index b8861f3..aff5397 100644
--- a/pygitweb/merge_requests.py
+++ b/pygitweb/merge_requests.py
@@ -12,11 +12,10 @@ from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygittools.merge import MergeRequest, MergeRequestStatus, get_merge_request_by_oid
 from pygitweb.auth import access_token_from_request, principal_from_session, require_permission
-from pygitweb.config import settings
 from pygitweb.dependencies import ValidatedReadableFormProject, ValidatedReadableQueryProject
 from pygitweb.git_helpers import open_repo, resolve_mr_opener
 from pygitweb.permissions import Permission
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+from pygitweb.templates_env import render_page
 from pygitweb.validation import is_valid_ref_format
 
 
@@ -153,10 +152,8 @@ def merge_request_tag_response(
 		"notice": notice or "",
 		**status_fields,
 	}
-	title = f"Merge request: {jinja_escape(mr.title) or ''} — {jinja_escape(project) or ''}"
-	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
-	body = env.get_template("merge_request_tag.html").render(**ctx)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+	title = f"Merge request: {mr.title or ''} — {project or ''}"
+	return render_page("merge_request_tag.html", title=title, **ctx)
 
 
 @merge_router.post(
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index 77072ff..84046f6 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -22,7 +22,7 @@ from pygitweb.permissions import Permission
 from pygitweb.plugin_loader import apply_plugin_reload, list_available_plugins
 from pygitweb.settings_config import settings_batch_update
 from pygitweb.shutdown import request_graceful_shutdown
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_format
+from pygitweb.templates_env import Cell, env, html_format, render_page
 
 
 def _quote_path(path: str) -> str:
@@ -302,12 +302,12 @@ def settings_pygitweb_page(request: Request):
 	form_body = "\n".join(fields_html)
 	enabled = set(settings.ENABLED_PLUGINS)
 	plugin_rows = [{"name": name, "checked": name in enabled} for name in list_available_plugins()]
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - PyGitWeb settings", site_name=settings.SITE_NAME)
-	body = env.get_template("pygitweb_settings.html").render(
+	return render_page(
+		"pygitweb_settings.html",
+		title=f"{settings.SITE_NAME} - PyGitWeb settings",
 		form_body=form_body,
 		plugin_rows=plugin_rows,
 	)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
 @router.post("/pygitweb/submit", response_class=HTMLResponse)
@@ -349,12 +349,10 @@ async def settings_pygitweb_plugins(request: Request):
 async def settings_pygitweb_shutdown(request: Request, background_tasks: BackgroundTasks) -> HTMLResponse:
 	"""Gracefully stop the PyGitWeb server process."""
 	background_tasks.add_task(request_graceful_shutdown, request.app)
-	pre = PREAMBLE.render(
+	return render_page(
+		"shutdown_ack.html",
 		title=f"{settings.SITE_NAME} - Shutting down",
-		site_name=settings.SITE_NAME,
 	)
-	body = env.get_template("shutdown_ack.html").render()
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
 # ---------- Routes: PyGit2 ----------
@@ -365,9 +363,11 @@ def settings_pygit2_page(request: Request):
 	for name, val, kind, doc in _get_pygit2_values():
 		fields_html.append(_render_pygit2_field(name, val, kind, doc))
 	form_body = "\n".join(fields_html)
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - PyGit2 settings", site_name=settings.SITE_NAME)
-	body = env.get_template("pygit2_settings.html").render(form_body=form_body)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+	return render_page(
+		"pygit2_settings.html",
+		title=f"{settings.SITE_NAME} - PyGit2 settings",
+		form_body=form_body,
+	)
 
 
 @router.post("/pygit2/submit", response_class=HTMLResponse)
@@ -407,20 +407,17 @@ def settings_project_page(request: Request, name: ValidatedReadableSettingsProje
 	rows = [_project_config_table_row(k, v, i) for i, (k, v) in enumerate(entries)]
 	# One empty row for adding new
 	rows.append(_project_config_table_row("", "", len(entries)))
-	table_html = env.get_template("table.html").render(cols=["Key", "Value"], rows=rows)
-	pre = PREAMBLE.render(
-		title=f"{settings.SITE_NAME} - Project settings: {name}",
-		site_name=settings.SITE_NAME,
-	)
 	submit_url = f"/settings/project/{_quote_path(name)}/submit"
 	cancel_url = f"/project/{_quote_path(name)}"
-	body = env.get_template("project_settings.html").render(
+	return render_page(
+		"project_settings.html",
+		title=f"{settings.SITE_NAME} - Project settings: {name}",
 		project_title=name,
 		submit_url=submit_url,
 		cancel_url=cancel_url,
-		table_html=table_html,
+		cols=["Key", "Value"],
+		rows=rows,
 	)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
 @router.post("/project/{name:path}/submit", response_class=HTMLResponse)
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index 0654710..a016c15 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -41,7 +41,7 @@ from pygitweb.dependencies import ValidatedReadableQueryProject, filter_projects
 from pygitweb.gravatar import gravatar_url
 from pygitweb.permissions import Permission, PermissionPrincipal
 from pygitweb.projects import git_get_projects_list
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_link
+from pygitweb.templates_env import Cell, html_link, render_page
 
 # Well-known empty tree OID for boards/tasks when repo has no HEAD
 EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
@@ -355,25 +355,18 @@ def all_tasks_page(
 				t.get("assignee") or "Unassigned",
 				str(t.get("points") if t.get("points") is not None else ""),
 			])
-	table = env.get_template("table.html").render(
+	statuses = [
+		{"value": status, "label": status.replace("_", " ").title(), "selected": status_filter == status}
+		for status in BOARD_STATUS_ORDER
+	]
+	return render_page(
+		"tasks.html",
+		title=f"{settings.SITE_NAME} - Tasks",
+		status_filter=status_filter or "",
+		statuses=statuses,
 		cols=["Task", "Project", "Board", "Status", "Priority", "Assignee", "Points"],
 		rows=rows,
 	)
-	status_options = "".join(
-		f'<option value="{s}"{" selected" if status_filter == s else ""}>{s.replace("_", " ").title()}</option>'
-		for s in BOARD_STATUS_ORDER
-	)
-	filter_form = (
-		'<form method="get" action="/tasks" class="d-flex align-items-center gap-2 mb-3">'
-		'<label class="form-label mb-0" for="tasks-status-filter">Status</label>'
-		'<select id="tasks-status-filter" name="status" class="form-select form-select-sm" '
-		'style="width: auto;" onchange="this.form.submit()">'
-		f'<option value=""{" selected" if not status_filter else ""}>All</option>{status_options}'
-		"</select></form>"
-	)
-	body = table if rows else '<p class="text-muted">No tasks found.</p>'
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Tasks", site_name=settings.SITE_NAME)
-	return HTMLResponse(f"{pre}<h1>Tasks</h1>{filter_form}{body}{POSTAMBLE}")
 
 
 @task_router.get(
diff --git a/pygitweb/team.py b/pygitweb/team.py
index e15c0e3..8a2401e 100644
--- a/pygitweb/team.py
+++ b/pygitweb/team.py
@@ -25,7 +25,7 @@ from pygitweb.dependencies import filter_projects_by_read_access
 from pygitweb.permissions import Permission
 from pygitweb.projects import git_get_projects_list
 from pygitweb.tasks import get_all_project_tasks
-from pygitweb.templates_env import POSTAMBLE, PREAMBLE, Cell, env, html_link
+from pygitweb.templates_env import Cell, html_link, render_page
 
 team_router = APIRouter(tags=["team"])
 
@@ -107,10 +107,6 @@ def team_page(
 				(t.get("priority") or "").title(),
 				str(t.get("points") if t.get("points") is not None else ""),
 			])
-		table_html = env.get_template("table.html").render(
-			cols=["Task", "Project", "Board", "Status", "Priority", "Points"],
-			rows=rows,
-		)
 		can_edit = (not is_unassigned) and (can_edit_others or assignee == current_username)
 		capacity_url = f"/team/capacity?user={quote(assignee, safe='')}&week={quote(iso_week, safe='')}"
 		groups.append({
@@ -120,12 +116,15 @@ def team_page(
 			"over_capacity": capacity is not None and points_total > capacity,
 			"can_edit": can_edit,
 			"capacity_url": capacity_url,
-			"table_html": table_html,
+			"rows": rows,
 		})
 
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Team", site_name=settings.SITE_NAME)
-	body = env.get_template("team.html").render(iso_week=iso_week, groups=groups)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+	return render_page(
+		"team.html",
+		title=f"{settings.SITE_NAME} - Team",
+		iso_week=iso_week,
+		groups=groups,
+	)
 
 
 @team_router.get("/capacity", response_class=HTMLResponse)
@@ -144,13 +143,13 @@ def team_capacity_form(
 	if not _ISO_WEEK_RE.match(iso_week):
 		iso_week = current_iso_week()
 	current_points = get_user_week_capacity(target_user, iso_week)
-	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Set capacity", site_name=settings.SITE_NAME)
-	body = env.get_template("team_capacity_form.html").render(
+	return render_page(
+		"team_capacity_form.html",
+		title=f"{settings.SITE_NAME} - Set capacity",
 		target_user=target_user,
 		iso_week=iso_week,
 		current_points=current_points,
 	)
-	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
 @team_router.post("/capacity/submit", response_class=HTMLResponse)
diff --git a/pygitweb/templates/addproject.html b/pygitweb/templates/addproject.html
index 25b2acb..92b1c32 100644
--- a/pygitweb/templates/addproject.html
+++ b/pygitweb/templates/addproject.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 <h1 class="page-title">Add project</h1>
 {% if show_sign_in_notice %}
 <div class="alert board-task-error-hint py-2 px-3 mb-3" role="alert">
@@ -53,5 +55,7 @@
     </form>
   </div>
 </div>
-
+{% endblock %}
+{% block scripts %}
 <script type="module" src="/static/addproject.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/base.html b/pygitweb/templates/base.html
new file mode 100644
index 0000000..c928beb
--- /dev/null
+++ b/pygitweb/templates/base.html
@@ -0,0 +1,103 @@
+<!DOCTYPE html><html><head><title>{% block title %}{{ title | default('PyGitWeb') }}{% endblock %}</title>
+<script>document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e){ document.documentElement.setAttribute('data-bs-theme', e.matches ? 'dark' : 'light'); });</script>
+<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Mono:wght@400;500;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="/static/vendor/tabler-1.0.0-beta17.min.css">
+<link rel="stylesheet" href="/static/main.css"/>
+{% block head %}{% endblock %}
+<script defer src="/static/vendor/tabler-1.0.0-beta17.min.js"></script>
+<script defer src="/static/navbar-auth.js"></script>
+</head><body>
+<div class="page">
+  <aside class="navbar navbar-vertical navbar-expand-sm position-fixed">
+    <div class="container-fluid d-flex flex-column vh-100 overflow-y-auto">
+      <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
+        <span class="navbar-toggler-icon"></span>
+      </button>
+      <h1 class="navbar-brand navbar-brand-autodark py-3">
+        <a href="/" class="d-flex align-items-center text-reset">
+          <img src="/static/logo.svg" width="32" height="32" alt="Logo" class="navbar-brand-image me-2" style="object-fit: contain;">
+          <span class="nav-link-title">{{ site_name | default('PyGitWeb') }}</span>
+        </a>
+      </h1>
+      <div class="collapse navbar-collapse flex-grow-1" id="sidebar-menu">
+        <ul class="navbar-nav pt-lg-3">
+          <li class="nav-item">
+            <a class="nav-link" href="/">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-folder" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" /></svg></span>
+              <span class="nav-link-title">Projects</span>
+            </a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="/addproject">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-plus" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg></span>
+              <span class="nav-link-title">Add project</span>
+            </a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="/boards">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-kanban"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 1a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1z" /><path d="M9 8v7" /><path d="M15 8v4" /></svg></span>
+              <span class="nav-link-title">Boards</span>
+            </a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="/activity">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-activity"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg></span>
+              <span class="nav-link-title">Activity</span>
+            </a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="/team">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-users"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /><path d="M21 21v-2a4 4 0 0 0 -3 -3.85" /></svg></span>
+              <span class="nav-link-title">Team</span>
+            </a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="/activity">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-messages"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 14l-3 -3h-7a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1h9a1 1 0 0 1 1 1v10" /><path d="M14 15v2a1 1 0 0 1 -1 1h-7l-3 3v-10a1 1 0 0 1 1 -1h2" /></svg></span>
+              <span class="nav-link-title">Discussions</span>
+            </a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="/tasks">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-checklist" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8" /><path d="M20 12v6a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h9" /></svg></span>
+              <span class="nav-link-title">Tasks</span>
+            </a>
+          </li>
+          <li class="nav-item dropdown">
+            <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" role="button">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg></span>
+              <span class="nav-link-title">Settings</span>
+            </a>
+            <div class="dropdown-menu dropdown-menu-column dropdown-menu-column-1">
+              <a class="dropdown-item" href="/settings/pygitweb">PyGitWeb</a>
+              <a class="dropdown-item" href="/settings/pygit2">PyGit2</a>
+            </div>
+          </li>
+        </ul>
+      </div>
+      <div class="navbar-user mt-auto py-3 px-3 border-top" id="navbar-user" data-logged-in="false">
+        <a class="d-flex align-items-center text-reset text-decoration-none w-100" id="navbar-user-link" href="/login" aria-label="Account">
+          <span class="avatar avatar-sm bg-secondary me-2 d-flex align-items-center justify-content-center" id="navbar-user-avatar">
+            <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user" width="22" height="22" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
+          </span>
+          <div class="d-none d-xl-block text-start">
+            <div class="navbar-user-name text-body">Guest</div>
+            <div class="navbar-user-meta text-muted small">Not signed in</div>
+          </div>
+        </a>
+      </div>
+    </div>
+  </aside>
+  <div class="page-wrapper">
+    <div class="page-body">
+      <div class="container-xl">
+{% block content %}{% endblock %}
+      </div>
+    </div>
+  </div>
+</div>
+{% block scripts %}{% endblock %}
+</body></html>
diff --git a/pygitweb/templates/blob.html b/pygitweb/templates/blob.html
new file mode 100644
index 0000000..cbc39da
--- /dev/null
+++ b/pygitweb/templates/blob.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% from "macros.html" import link %}
+{% block content %}
+{% if heading %}<h1>{{ heading }}</h1>{% endif %}
+{% if project %}
+<p>Project: {{ link("/project/" ~ project, project) }}</p>
+{% endif %}
+<div class="blob-view{% if blame %} blame-view{% endif %}">
+{% if blame %}
+<div class="blame-commits" aria-label="Blame commits">{% for line in blame_lines %}{% if line %}<a href="{{ line.href }}" class="blame-commit-link" title="{{ line.title }}">{{ line.oid_short }}</a>{% endif %}{% if not loop.last %}{{ '\n' }}{% endif %}{% endfor %}</div>
+{% endif %}
+<div class="blob-line-nums" aria-hidden="true">{{ line_nums }}</div>
+<pre class="blob-content"><code class="hljs{% if lang %} language-{{ lang }}{% endif %}">{{ code }}</code></pre>
+</div>
+{% endblock %}
+{% block scripts %}
+<script defer src="/static/vendor/highlight-11.9.0.min.js"></script>
+<script type="module" src="/static/blob-view.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/blobdiff.html b/pygitweb/templates/blobdiff.html
index fb2a78c..c2f4f6d 100644
--- a/pygitweb/templates/blobdiff.html
+++ b/pygitweb/templates/blobdiff.html
@@ -1,9 +1,16 @@
+{% extends "base.html" %}
+{% block head %}
+<link rel="stylesheet" href="/static/vendor/diff2html-3.4.56.min.css">
+{% endblock %}
+{% block content %}
 <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' }}" data-empty-text="No changes between these blobs.">
   <div id="d2h-output"></div>
   <p id="d2h-empty" class="text-muted" style="display: none;"></p>
 </div>
-<link rel="stylesheet" href="/static/vendor/diff2html-3.4.56.min.css">
+{% endblock %}
+{% block scripts %}
 <script defer src="/static/vendor/diff2html-3.4.56-ui.min.js"></script>
 <script type="module" src="/static/diff-view.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/board.html b/pygitweb/templates/board.html
index c6bfd39..19b44c7 100644
--- a/pygitweb/templates/board.html
+++ b/pygitweb/templates/board.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 {# Board view: columns (dropzones) and task cards. Expects: project, project_url, board_name, board_url, columns [ { status, label, tasks, points_total } ] #}
 
 <template id="board-card-template">
@@ -50,6 +52,8 @@
   </div>
   {% endfor %}
 </div>
-
+{% endblock %}
+{% block scripts %}
 <script defer src="/static/vendor/showdown-2.1.0.min.js"></script>
 <script type="module" src="/static/board.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/commitdiff.html b/pygitweb/templates/commitdiff.html
index 53bb7d1..ec1c66e 100644
--- a/pygitweb/templates/commitdiff.html
+++ b/pygitweb/templates/commitdiff.html
@@ -1,9 +1,16 @@
+{% extends "base.html" %}
+{% block head %}
+<link rel="stylesheet" href="/static/vendor/diff2html-3.4.56.min.css">
+{% endblock %}
+{% block content %}
 <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' }}" data-empty-text="No changes in this commit.">
   <div id="d2h-output"></div>
   <p id="d2h-empty" class="text-muted" style="display: none;"></p>
 </div>
-<link rel="stylesheet" href="/static/vendor/diff2html-3.4.56.min.css">
+{% endblock %}
+{% block scripts %}
 <script defer src="/static/vendor/diff2html-3.4.56-ui.min.js"></script>
 <script type="module" src="/static/diff-view.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/login.html b/pygitweb/templates/login.html
index 47ba6c9..4b2539b 100644
--- a/pygitweb/templates/login.html
+++ b/pygitweb/templates/login.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 <h1 class="page-title">Sign in</h1>
 <div class="card">
   <div class="card-body">
@@ -35,3 +37,4 @@
     {% endif %}
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates/macros.html b/pygitweb/templates/macros.html
new file mode 100644
index 0000000..7a55fcd
--- /dev/null
+++ b/pygitweb/templates/macros.html
@@ -0,0 +1,12 @@
+{% macro link(href, text, id=none, style=none) -%}
+<a href="{{ href }}"{% if id %} id="{{ id }}"{% endif %}{% if style %} style="{{ style }}"{% endif %}>{{ text }}</a>
+{%- endmacro %}
+
+{% macro section(title, flush=false, open=true) %}
+<details class="summary-section card mt-3"{% if open %} open{% endif %}>
+<summary class="card-header summary-section-toggle">
+<h2 class="card-title mb-0">{{ title }}</h2>
+</summary>
+<div class="{{ 'card-body p-0' if flush else 'card-body' }}">{{ caller() }}</div>
+</details>
+{% endmacro %}
diff --git a/pygitweb/templates/merge_request_tag.html b/pygitweb/templates/merge_request_tag.html
index 32726f3..4a70db3 100644
--- a/pygitweb/templates/merge_request_tag.html
+++ b/pygitweb/templates/merge_request_tag.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 {# Merge request view for annotated MR tags. Expects: project, project_url, tag_name, tag_oid, tag_view_url, mr_title, mr_description, ours_ref, ours_url, theirs_ref, theirs_url, head_short, head_url, head_full, tagger, tagger_date, opener, created_at, updated_at, comments [ (short, url) ], merge_action_url, status_code, status_label, status_detail, show_merge_button #}
 
 <h1 class="page-title mb-3">Merge request</h1>
@@ -92,3 +94,4 @@
     </div>
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates/message.html b/pygitweb/templates/message.html
new file mode 100644
index 0000000..4f2d3d3
--- /dev/null
+++ b/pygitweb/templates/message.html
@@ -0,0 +1,6 @@
+{% extends "base.html" %}
+{% block content %}
+{% for paragraph in paragraphs %}
+<p{% if muted %} class="text-muted"{% endif %}>{{ paragraph }}</p>
+{% endfor %}
+{% endblock %}
diff --git a/pygitweb/templates/preamble.html b/pygitweb/templates/preamble.html
deleted file mode 100644
index 05efa30..0000000
--- a/pygitweb/templates/preamble.html
+++ /dev/null
@@ -1,95 +0,0 @@
-<!DOCTYPE html><html><head><title>{{title | default('PyGitWeb')}}</title>
-<script>document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e){ document.documentElement.setAttribute('data-bs-theme', e.matches ? 'dark' : 'light'); });</script>
-<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
-<link rel="preconnect" href="https://fonts.googleapis.com">
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Mono:wght@400;500;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
-<link rel="stylesheet" href="/static/vendor/tabler-1.0.0-beta17.min.css">
-<link rel="stylesheet" href="/static/main.css"/>
-<script defer src="/static/vendor/tabler-1.0.0-beta17.min.js"></script>
-<script defer src="/static/navbar-auth.js"></script>
-</head><body>
-<div class="page">
-  <aside class="navbar navbar-vertical navbar-expand-sm position-fixed">
-    <div class="container-fluid d-flex flex-column vh-100 overflow-y-auto">
-      <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
-        <span class="navbar-toggler-icon"></span>
-      </button>
-      <h1 class="navbar-brand navbar-brand-autodark py-3">
-        <a href="/" class="d-flex align-items-center text-reset">
-          <img src="/static/logo.svg" width="32" height="32" alt="Logo" class="navbar-brand-image me-2" style="object-fit: contain;">
-          <span class="nav-link-title">{{ site_name | default('PyGitWeb') }}</span>
-        </a>
-      </h1>
-      <div class="collapse navbar-collapse flex-grow-1" id="sidebar-menu">
-        <ul class="navbar-nav pt-lg-3">
-          <li class="nav-item">
-            <a class="nav-link" href="/">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-folder" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" /></svg></span>
-              <span class="nav-link-title">Projects</span>
-            </a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="/addproject">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-plus" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg></span>
-              <span class="nav-link-title">Add project</span>
-            </a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="/boards">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-kanban"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 1a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1z" /><path d="M9 8v7" /><path d="M15 8v4" /></svg></span>
-              <span class="nav-link-title">Boards</span>
-            </a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="/activity">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-activity"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg></span>
-              <span class="nav-link-title">Activity</span>
-            </a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="/team">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-users"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /><path d="M21 21v-2a4 4 0 0 0 -3 -3.85" /></svg></span>
-              <span class="nav-link-title">Team</span>
-            </a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="/activity">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-messages"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 14l-3 -3h-7a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1h9a1 1 0 0 1 1 1v10" /><path d="M14 15v2a1 1 0 0 1 -1 1h-7l-3 3v-10a1 1 0 0 1 1 -1h2" /></svg></span>
-              <span class="nav-link-title">Discussions</span>
-            </a>
-          </li>
-          <li class="nav-item">
-            <a class="nav-link" href="/tasks">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-checklist" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8" /><path d="M20 12v6a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h9" /></svg></span>
-              <span class="nav-link-title">Tasks</span>
-            </a>
-          </li>
-          <li class="nav-item dropdown">
-            <a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" role="button">
-              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></svg></span>
-              <span class="nav-link-title">Settings</span>
-            </a>
-            <div class="dropdown-menu dropdown-menu-column dropdown-menu-column-1">
-              <a class="dropdown-item" href="/settings/pygitweb">PyGitWeb</a>
-              <a class="dropdown-item" href="/settings/pygit2">PyGit2</a>
-            </div>
-          </li>
-        </ul>
-      </div>
-      <div class="navbar-user mt-auto py-3 px-3 border-top" id="navbar-user" data-logged-in="false">
-        <a class="d-flex align-items-center text-reset text-decoration-none w-100" id="navbar-user-link" href="/login" aria-label="Account">
-          <span class="avatar avatar-sm bg-secondary me-2 d-flex align-items-center justify-content-center" id="navbar-user-avatar">
-            <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user" width="22" height="22" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>
-          </span>
-          <div class="d-none d-xl-block text-start">
-            <div class="navbar-user-name text-body">Guest</div>
-            <div class="navbar-user-meta text-muted small">Not signed in</div>
-          </div>
-        </a>
-      </div>
-    </div>
-  </aside>
-  <div class="page-wrapper">
-    <div class="page-body">
-      <div class="container-xl">
diff --git a/pygitweb/templates/project_list.html b/pygitweb/templates/project_list.html
new file mode 100644
index 0000000..730d1d4
--- /dev/null
+++ b/pygitweb/templates/project_list.html
@@ -0,0 +1,28 @@
+{% extends "base.html" %}
+{% from "macros.html" import link %}
+{% block content %}
+<h1>Project List</h1>
+{% if notice == "hidden" %}
+<p class="text-warning">No projects are visible with your current authentication and permissions. Sign in or ask an administrator for repository access.</p>
+{% elif notice == "empty" %}
+<p>No projects found.</p>
+{% endif %}
+<table class="table table-vcenter card-table">
+<thead>
+<tr>
+<th>Project</th>
+<th>Description</th>
+<th>Board</th>
+</tr>
+</thead>
+<tbody>
+{% for project in projects %}
+<tr>
+<td>{{ link(project.href, project.path) }}</td>
+<td>{{ project.descr }}</td>
+<td>{{ link(project.board_href, project.board_text, style=project.board_style) }}</td>
+</tr>
+{% endfor %}
+</tbody>
+</table>
+{% endblock %}
diff --git a/pygitweb/templates/project_settings.html b/pygitweb/templates/project_settings.html
index 26bdb64..dcea64e 100644
--- a/pygitweb/templates/project_settings.html
+++ b/pygitweb/templates/project_settings.html
@@ -1,9 +1,11 @@
+{% extends "base.html" %}
+{% block content %}
 <h1 class="page-title">Project settings: {{ project_title }}</h1>
 <p class="text-muted">Repository config (local and worktree only).</p>
 <div class="card">
   <div class="card-body">
     <form action="{{ submit_url }}" method="post" class="needs-validation" novalidate>
-      {{ table_html | safe }}
+      {% include "table.html" %}
       <div class="form-footer mt-3">
         <button type="submit" class="btn btn-primary">Save</button>
         <a href="{{ cancel_url }}" class="btn btn-ghost-secondary">Cancel</a>
@@ -11,3 +13,4 @@
     </form>
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates/pygit2_settings.html b/pygitweb/templates/pygit2_settings.html
index 0b7eb8d..c6622d6 100644
--- a/pygitweb/templates/pygit2_settings.html
+++ b/pygitweb/templates/pygit2_settings.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 <h1 class="page-title">PyGit2 settings</h1>
 <div class="card">
   <div class="card-body">
@@ -10,3 +12,4 @@
     </form>
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates/pygitweb_settings.html b/pygitweb/templates/pygitweb_settings.html
index d4ad09b..bb83e23 100644
--- a/pygitweb/templates/pygitweb_settings.html
+++ b/pygitweb/templates/pygitweb_settings.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 <h1 class="page-title">PyGitWeb settings</h1>
 <div class="card">
   <div class="card-body">
@@ -42,3 +44,4 @@
     </form>
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates/readme_card.html b/pygitweb/templates/readme_card.html
new file mode 100644
index 0000000..34284e5
--- /dev/null
+++ b/pygitweb/templates/readme_card.html
@@ -0,0 +1,9 @@
+{% from "macros.html" import section %}
+{% call section("README") %}
+{% if markdown %}
+<div id="readme-container" class="markdown-body" data-readme-b64="{{ readme_b64 }}" data-blob-base="{{ blob_base }}"{% if blob_dir %} data-blob-dir="{{ blob_dir }}"{% endif %}></div>
+<script type="module" src="/static/readme-render.js"></script>
+{% else %}
+<pre class="readme-plain"><code>{{ plain }}</code></pre>
+{% endif %}
+{% endcall %}
diff --git a/pygitweb/templates/search.html b/pygitweb/templates/search.html
index 40d1a7c..ce41e49 100644
--- a/pygitweb/templates/search.html
+++ b/pygitweb/templates/search.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 {# Search UI page. Expects: project, project_enc, project_url, search_api_url. #}
 
 <h1 class="page-title d-flex align-items-center gap-2">
@@ -69,9 +71,11 @@
 
 <div id="search-status" class="search-status text-muted mb-2" aria-live="polite"></div>
 <div id="search-results" class="search-results" role="region" aria-label="Search results"></div>
-
+{% endblock %}
+{% block scripts %}
 <script>
   window.SEARCH_PROJECT = {{ project | tojson }};
   window.SEARCH_API_URL = {{ search_api_url | tojson }};
 </script>
 <script type="module" src="/static/search-page.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/shutdown_ack.html b/pygitweb/templates/shutdown_ack.html
index b6dc0f3..10ea577 100644
--- a/pygitweb/templates/shutdown_ack.html
+++ b/pygitweb/templates/shutdown_ack.html
@@ -1,6 +1,9 @@
+{% extends "base.html" %}
+{% block content %}
 <h1 class="page-title">Shutting down</h1>
 <div class="card">
   <div class="card-body">
     <p>PyGitWeb is shutting down gracefully. You can close this tab.</p>
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates/subpage.html b/pygitweb/templates/subpage.html
new file mode 100644
index 0000000..f002a78
--- /dev/null
+++ b/pygitweb/templates/subpage.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% block content %}
+<h1>{{ project }}</h1>
+{% if iframe_src %}
+<iframe class="subpage-frame" src="{{ iframe_src }}" title="{{ subpage_name }}"></iframe>
+{% else %}
+{{ content }}
+{% endif %}
+{% endblock %}
diff --git a/pygitweb/templates/summary.html b/pygitweb/templates/summary.html
new file mode 100644
index 0000000..f6b6afc
--- /dev/null
+++ b/pygitweb/templates/summary.html
@@ -0,0 +1,90 @@
+{% extends "base.html" %}
+{% from "macros.html" import link, section %}
+{% block head %}
+<script defer src="/static/vendor/showdown-2.1.0.min.js"></script>
+{% endblock %}
+{% block content %}
+<h1>{{ project }}</h1>
+{% if descr %}
+<p>{{ descr }}</p>
+{% endif %}
+{% call section("Project", flush=true) %}
+<table class="table table-vcenter card-table">
+<thead>
+<tr><th>Field</th><th>Value</th></tr>
+</thead>
+<tbody>
+<tr>
+<td>Owner</td>
+<td>{{ owner }}</td>
+</tr>
+<tr>
+<td>
+<label for="summary-ref-select">Ref</label>
+<select id="summary-ref-select">
+<option value="HEAD" selected>HEAD</option>
+</select>
+</td>
+<td>
+{{ link(ref.commit_href, ref.display, id="summary-ref-commit-link") }}:
+{{ link(ref.browse_href, "browse", id="summary-ref-browse-link") }} -
+{{ link(ref.log_href, "log", id="summary-ref-log-link") }} -
+{{ link(ref.shortlog_href, "shortlog", id="summary-ref-shortlog-link") }}
+</td>
+</tr>
+<tr>
+<td>Branches</td>
+<td>{{ link(project_url ~ "?a=heads", "view branches") }}</td>
+</tr>
+<tr>
+<td>Merge request</td>
+<td>
+{% if merge_request %}
+{% with project=merge_request.project, branches=merge_request.branches, theirs_default_ref=merge_request.theirs_default_ref, ours_default_ref=merge_request.ours_default_ref %}
+{% include "merge_request_summary_cell.html" %}
+{% endwith %}
+{% else %}
+<span class="text-muted">No branches.</span>
+{% endif %}
+</td>
+</tr>
+<tr>
+<td>Tags</td>
+<td>{{ link(project_url ~ "?a=tags", "view tags") }}</td>
+</tr>
+<tr>
+<td>Remotes</td>
+<td>{{ link(project_url ~ "?a=remotes", "view remotes") }}</td>
+</tr>
+<tr>
+<td>Board</td>
+<td>{{ link(board.href, board.text, style=board.style) }}</td>
+</tr>
+<tr>
+<td>Search</td>
+<td>{{ link(project_url_enc ~ "/search", "search files") }}</td>
+</tr>
+<tr>
+<td>Hooks</td>
+<td>
+{% with mode=hooks.mode, project_enc=hooks.project_enc, installed=hooks.installed, available=hooks.available %}
+{% include "hooks_summary_cell.html" %}
+{% endwith %}
+</td>
+</tr>
+{% for row in extra_rows %}
+<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
+{% endfor %}
+</tbody>
+</table>
+{% endcall %}
+{% call section("Files") %}
+<div id="summary-tree-container" class="summary-tree-table">{{ ref.tree_html }}</div>
+<p class="px-3 pb-3 mb-0">{{ link(ref.browse_href, "Browse full tree", id="summary-tree-browse-link") }}</p>
+{% endcall %}
+<div id="summary-readme-container" data-project="{{ project }}">{{ ref.readme_html }}</div>
+{% endblock %}
+{% block scripts %}
+<script type="module" src="/static/summary-ref-switcher.js"></script>
+<script src="/static/hook-install.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/table_page.html b/pygitweb/templates/table_page.html
new file mode 100644
index 0000000..a245a01
--- /dev/null
+++ b/pygitweb/templates/table_page.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% from "macros.html" import link %}
+{% block content %}
+{% if heading %}<h1>{{ heading }}</h1>{% endif %}
+{% if project %}
+<p>Project: {{ link("/project/" ~ project, project) }}</p>
+{% endif %}
+{% if rows %}
+{% include "table.html" %}
+{% elif empty_message %}
+<p{% if empty_muted %} class="text-muted"{% endif %}>{{ empty_message }}</p>
+{% elif cols is defined %}
+{% include "table.html" %}
+{% endif %}
+{% if pagination %}
+{% with current_page=pagination.current_page, pagecount=pagination.pagecount, has_prev=pagination.has_prev, has_next=pagination.has_next, prev_url=pagination.prev_url, next_url=pagination.next_url, total_pages=pagination.total_pages, page_links=pagination.page_links %}
+{% include "pagination.html" %}
+{% endwith %}
+{% endif %}
+{% endblock %}
diff --git a/pygitweb/templates/tasks.html b/pygitweb/templates/tasks.html
new file mode 100644
index 0000000..07878a9
--- /dev/null
+++ b/pygitweb/templates/tasks.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% block content %}
+<h1>Tasks</h1>
+<form method="get" action="/tasks" class="d-flex align-items-center gap-2 mb-3">
+<label class="form-label mb-0" for="tasks-status-filter">Status</label>
+<select id="tasks-status-filter" name="status" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
+<option value=""{% if not status_filter %} selected{% endif %}>All</option>
+{% for status in statuses %}
+<option value="{{ status.value }}"{% if status.selected %} selected{% endif %}>{{ status.label }}</option>
+{% endfor %}
+</select>
+</form>
+{% if rows %}
+{% include "table.html" %}
+{% else %}
+<p class="text-muted">No tasks found.</p>
+{% endif %}
+{% endblock %}
diff --git a/pygitweb/templates/team.html b/pygitweb/templates/team.html
index a49c806..82090a0 100644
--- a/pygitweb/templates/team.html
+++ b/pygitweb/templates/team.html
@@ -1,4 +1,6 @@
-{# Team overview: open tasks across projects, grouped by assignee. Expects: iso_week, groups [ { assignee, points_total, capacity, over_capacity, can_edit, capacity_url, table_html } ] #}
+{% extends "base.html" %}
+{% block content %}
+{# Team overview: open tasks across projects, grouped by assignee. Expects: iso_week, groups [ { assignee, points_total, capacity, over_capacity, can_edit, capacity_url, rows } ] #}
 
 <h1 class="page-title">Team</h1>
 <p class="text-muted">What everyone is currently working on, across every project you can read.</p>
@@ -29,7 +31,10 @@
     </div>
   </div>
   <div class="card-body p-0">
-    {{ group.table_html | safe }}
+    {% with cols=["Task", "Project", "Board", "Status", "Priority", "Points"], rows=group.rows %}
+    {% include "table.html" %}
+    {% endwith %}
   </div>
 </div>
 {% endfor %}
+{% endblock %}
diff --git a/pygitweb/templates/team_capacity_form.html b/pygitweb/templates/team_capacity_form.html
index c19f262..49ed594 100644
--- a/pygitweb/templates/team_capacity_form.html
+++ b/pygitweb/templates/team_capacity_form.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 {# Set a user's sprint point capacity for an ISO week. Expects: target_user, iso_week, current_points #}
 
 <h1 class="page-title">Set weekly capacity</h1>
@@ -23,3 +25,4 @@
     </form>
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates/tree.html b/pygitweb/templates/tree.html
new file mode 100644
index 0000000..eb203c9
--- /dev/null
+++ b/pygitweb/templates/tree.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% from "macros.html" import link %}
+{% block head %}
+{% if needs_showdown %}
+<script defer src="/static/vendor/showdown-2.1.0.min.js"></script>
+{% endif %}
+{% endblock %}
+{% block content %}
+<p class="breadcrumb">{{ link(project_href, project) }}{% for crumb in breadcrumbs %} / {{ link(crumb.href, crumb.label) }}{% endfor %}</p>
+<h1>Tree{% if path %} / {{ path }}{% endif %}</h1>
+{{ tree_html }}
+{{ readme_html }}
+{% endblock %}
diff --git a/pygitweb/templates/user_profile.html b/pygitweb/templates/user_profile.html
index ea55cc8..f2f0f90 100644
--- a/pygitweb/templates/user_profile.html
+++ b/pygitweb/templates/user_profile.html
@@ -1,3 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
 <h1 class="page-title">Account</h1>
 <div class="card">
   <div class="card-body">
@@ -8,3 +10,4 @@
     </p>
   </div>
 </div>
+{% endblock %}
diff --git a/pygitweb/templates_env.py b/pygitweb/templates_env.py
index 3a6886a..4ccc7d8 100644
--- a/pygitweb/templates_env.py
+++ b/pygitweb/templates_env.py
@@ -1,7 +1,8 @@
-"""Shared Jinja2 environment and preamble/postamble for pygitweb templates."""
+"""Shared Jinja2 environment and page rendering for pygitweb templates."""
 
 from collections.abc import Iterable
 
+from fastapi.responses import HTMLResponse
 from jinja2 import Environment, PackageLoader, select_autoescape
 from markupsafe import Markup
 
@@ -11,8 +12,6 @@ env = Environment(
 	trim_blocks=True,
 	lstrip_blocks=True,
 )
-PREAMBLE = env.get_template("preamble.html")
-POSTAMBLE = """</div></div></div></body></html>"""
 
 # A table cell is plain text, which the template escapes, or Markup, which it does not.
 Cell = str | Markup
@@ -20,6 +19,15 @@ Cell = str | Markup
 _ESCAPE_TEMPLATE = env.from_string("{{ value|e }}")
 
 
+def render_page(name: str, /, **ctx: object) -> HTMLResponse:
+	"""Render a page template. Templates extend base.html; site_name defaults from settings."""
+	from pygitweb.config import settings
+
+	page_ctx = dict(ctx)
+	page_ctx.setdefault("site_name", settings.SITE_NAME)
+	return HTMLResponse(env.get_template(name).render(**page_ctx))
+
+
 def jinja_escape(value: str | None) -> str | None:
 	if value is None:
 		return None
diff --git a/pygitweb/templates_env_test.py b/pygitweb/templates_env_test.py
index 164da21..ad7b92e 100644
--- a/pygitweb/templates_env_test.py
+++ b/pygitweb/templates_env_test.py
@@ -1,5 +1,5 @@
 from pygitweb.actions import project_board_link
-from pygitweb.templates_env import env, html_format, html_join, html_link, trusted_html
+from pygitweb.templates_env import env, html_format, html_join, html_link, render_page, trusted_html
 
 
 def test_table_escapes_plain_text() -> None:
@@ -37,6 +37,42 @@ def test_html_format_escapes_values() -> None:
 	assert str(html_format("<pre>{}</pre>", "<hi>")) == "<pre>&lt;hi&gt;</pre>"
 
 
+def test_render_page_uses_base_template_and_escapes_text() -> None:
+	resp = render_page("message.html", title="Hello <x>", paragraphs=["Hi <there>"])
+	text = resp.body.decode()
+	assert text.startswith("<!DOCTYPE html>")
+	assert "<title>Hello &lt;x&gt;</title>" in text
+	assert "Hi &lt;there&gt;" in text
+	assert text.count('<div class="page">') == 1
+	assert "</body></html>" in text
+	assert text.index('<div class="container-xl">') < text.index("Hi &lt;there&gt;")
+	assert text.index("Hi &lt;there&gt;") < text.index("</body></html>")
+
+
+def test_project_list_template_emits_links() -> None:
+	resp = render_page(
+		"project_list.html",
+		title="Projects",
+		notice=None,
+		projects=[
+			{
+				"href": "/project/a<b",
+				"path": "a<b",
+				"descr": "d&d",
+				"board_href": "/board/create?project=a%3Cb",
+				"board_text": "create board",
+				"board_style": None,
+			}
+		],
+	)
+	text = resp.body.decode()
+	assert 'href="/project/a&lt;b"' in text
+	assert ">a&lt;b</a>" in text
+	assert "d&amp;d" in text
+	assert ">create board</a>" in text
+	assert 'href="/board/create?project=a%3Cb">create board</a>' in text
+
+
 def test_project_board_link_points_at_existing_board(monkeypatch) -> None:
 	monkeypatch.setattr(
 		"pygitweb.actions.git_get_references",
