Polish web UI templates, fix clipboard on insecure contexts

Amolith created

- Extract task_table macro into templates/macros.html (DRY)
- Render markdown descriptions and logs via pulldown-cmark + ammonia
- Add friendly date display with <time> elements (JS-enhanced)
- Default project page to open-status filter
- Compact task detail layout (inline metadata, collapsible logs)
- Fix clipboard copy button: hidden by default, revealed only when
  navigator.clipboard is available (secure context), bound via
  addEventListener instead of inline onclick

Change summary

Cargo.lock             | 250 ++++++++++++++++++++++++++++++++++++++++++++
Cargo.toml             |   2 
src/cmd/webui.rs       |  49 ++++++++
templates/base.html    |  31 +++++
templates/macros.html  |  29 +++++
templates/project.html |  43 +-----
templates/task.html    | 116 ++++++--------------
7 files changed, 401 insertions(+), 119 deletions(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -59,6 +59,19 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "ammonia"
+version = "4.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "tendril",
+ "url",
+]
+
 [[package]]
 name = "anstream"
 version = "0.6.21"
@@ -802,6 +815,29 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "cssparser"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "ctr"
 version = "0.9.2"
@@ -963,6 +999,21 @@ dependencies = [
  "litrs",
 ]
 
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
 [[package]]
 name = "either"
 version = "1.15.0"
@@ -1135,6 +1186,16 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
 [[package]]
 name = "futures"
 version = "0.3.32"
@@ -1448,6 +1509,17 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever",
+ "match_token",
+]
+
 [[package]]
 name = "http"
 version = "1.4.0"
@@ -1972,6 +2044,12 @@ dependencies = [
  "twox-hash",
 ]
 
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
 [[package]]
 name = "magic-wormhole"
 version = "0.7.6"
@@ -2013,6 +2091,34 @@ dependencies = [
  "ws_stream_wasm",
 ]
 
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "matchers"
 version = "0.2.0"
@@ -2057,6 +2163,12 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
 [[package]]
 name = "noise-protocol"
 version = "0.2.0"
@@ -2287,6 +2399,58 @@ dependencies = [
  "rustc_version",
 ]
 
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.17"
@@ -2390,6 +2554,12 @@ dependencies = [
  "zerocopy",
 ]
 
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
 [[package]]
 name = "predicates"
 version = "3.1.4"
@@ -2449,6 +2619,24 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "pulldown-cmark"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+dependencies = [
+ "bitflags",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
 [[package]]
 name = "quick_cache"
 version = "0.6.18"
@@ -2838,6 +3026,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
 [[package]]
 name = "sized-chunks"
 version = "0.6.5"
@@ -2910,6 +3104,31 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
 [[package]]
 name = "strsim"
 version = "0.11.1"
@@ -3000,6 +3219,17 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
 [[package]]
 name = "termtree"
 version = "0.5.1"
@@ -3285,6 +3515,12 @@ dependencies = [
  "web-time",
 ]
 
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.24"
@@ -3514,6 +3750,18 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
@@ -3801,6 +4049,7 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
 name = "yatd"
 version = "0.1.3"
 dependencies = [
+ "ammonia",
  "anyhow",
  "askama",
  "assert_cmd",
@@ -3812,6 +4061,7 @@ dependencies = [
  "loro",
  "magic-wormhole",
  "predicates",
+ "pulldown-cmark",
  "serde",
  "serde_json",
  "shell-words",

Cargo.toml ๐Ÿ”—

@@ -23,6 +23,8 @@ tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] }
 fs2 = "0.4"
 axum = "0.8"
 askama = "0.15"
+pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
+ammonia = "4"
 
 [dev-dependencies]
 assert_cmd = "2"

src/cmd/webui.rs ๐Ÿ”—

@@ -15,6 +15,33 @@ use crate::score;
 
 const PAGE_SIZE: usize = 25;
 
+/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
+/// for the noscript fallback.  Returns the original string unchanged on parse
+/// failure so the page still renders something sensible.
+fn friendly_date(iso: &str) -> String {
+    chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ")
+        .map(|dt| dt.format("%-d %b %Y %H:%M").to_string())
+        .unwrap_or_else(|_| iso.to_string())
+}
+
+/// Render a markdown string to sanitised HTML.
+///
+/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that
+/// untrusted user input can be displayed safely.  Images are stripped
+/// entirely โ€” only structural/inline markup is allowed through.
+fn render_markdown(src: &str) -> String {
+    use pulldown_cmark::{html::push_html, Parser};
+
+    let parser = Parser::new(src);
+    let mut raw_html = String::new();
+    push_html(&mut raw_html, parser);
+
+    ammonia::Builder::default()
+        .rm_tags(&["img"])
+        .clean(&raw_html)
+        .to_string()
+}
+
 // ---------------------------------------------------------------------------
 // Shared state
 // ---------------------------------------------------------------------------
@@ -59,6 +86,8 @@ struct TaskRow {
     priority: String,
     effort: String,
     title: String,
+    created_at: String,
+    created_at_display: String,
 }
 
 /// View-model for the task detail page.
@@ -72,13 +101,16 @@ struct TaskView {
     priority: String,
     effort: String,
     created_at: String,
+    created_at_display: String,
     updated_at: String,
+    updated_at_display: String,
     labels: Vec<String>,
     logs: Vec<LogView>,
 }
 
 struct LogView {
     timestamp: String,
+    timestamp_display: String,
     message: String,
 }
 
@@ -268,8 +300,12 @@ struct ProjectQuery {
 async fn project_handler(
     State(state): State<AppState>,
     AxumPath(name): AxumPath<String>,
-    Query(query): Query<ProjectQuery>,
+    Query(mut query): Query<ProjectQuery>,
 ) -> Response {
+    // Default to showing open tasks when no status filter is specified.
+    if query.status.is_none() {
+        query.status = Some("open".to_string());
+    }
     let root = state.data_root.clone();
     let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
         let all_projects = list_projects_safe(&root);
@@ -407,6 +443,8 @@ async fn project_handler(
                 priority: db::priority_label(t.priority).to_string(),
                 effort: db::effort_label(t.effort).to_string(),
                 title: t.title.clone(),
+                created_at_display: friendly_date(&t.created_at),
+                created_at: t.created_at.clone(),
             })
             .collect();
 
@@ -484,6 +522,8 @@ async fn task_handler(
                 priority: db::priority_label(t.priority).to_string(),
                 effort: db::effort_label(t.effort).to_string(),
                 title: t.title.clone(),
+                created_at_display: friendly_date(&t.created_at),
+                created_at: t.created_at.clone(),
             })
             .collect();
 
@@ -491,20 +531,23 @@ async fn task_handler(
             full_id: task.id.as_str().to_string(),
             short_id: task.id.short(),
             title: task.title.clone(),
-            description: task.description.clone(),
+            description: render_markdown(&task.description),
             task_type: task.task_type.clone(),
             status: db::status_label(task.status).to_string(),
             priority: db::priority_label(task.priority).to_string(),
             effort: db::effort_label(task.effort).to_string(),
+            created_at_display: friendly_date(&task.created_at),
             created_at: task.created_at.clone(),
+            updated_at_display: friendly_date(&task.updated_at),
             updated_at: task.updated_at.clone(),
             labels: task.labels.clone(),
             logs: task
                 .logs
                 .iter()
                 .map(|l| LogView {
+                    timestamp_display: friendly_date(&l.timestamp),
                     timestamp: l.timestamp.clone(),
-                    message: l.message.clone(),
+                    message: render_markdown(&l.message),
                 })
                 .collect(),
         };

templates/base.html ๐Ÿ”—

@@ -7,6 +7,35 @@
   <link rel="stylesheet" href="/static/oat.min.css">
   <link rel="stylesheet" href="/static/td.css">
   <script src="/static/oat.min.js" defer></script>
+  <script defer>
+    document.addEventListener("DOMContentLoaded", () => {
+      document.querySelectorAll("time[datetime]").forEach(el => {
+        const d = new Date(el.getAttribute("datetime"));
+        if (!isNaN(d)) {
+          el.textContent = d.toLocaleString(undefined, {
+            day: "numeric", month: "short", year: "numeric",
+            hour: "2-digit", minute: "2-digit"
+          });
+        }
+      });
+
+      // Reveal copy-to-clipboard buttons only when the API is available
+      // (requires a secure context: HTTPS or localhost).
+      if (navigator.clipboard) {
+        const copyIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
+        const checkIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 15 2 2 4-4"/><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
+        document.querySelectorAll(".js-copy-id").forEach(btn => {
+          btn.hidden = false;
+          btn.addEventListener("click", () => {
+            navigator.clipboard.writeText(btn.dataset.copy).then(() => {
+              btn.innerHTML = checkIcon;
+              setTimeout(() => { btn.innerHTML = copyIcon; }, 1500);
+            });
+          });
+        });
+      }
+    });
+  </script>
 </head>
 <body data-sidebar-layout>
   <a href="#main-content" class="skip-link">Skip to main content</a>
@@ -30,7 +59,7 @@
   </aside>
 
   <main id="main-content" tabindex="-1">
-    <div class="p-4">
+    <div class="container p-4">
       {% block content %}{% endblock %}
     </div>
   </main>

templates/macros.html ๐Ÿ”—

@@ -0,0 +1,29 @@
+{% macro task_table(project_name, tasks, caption) %}
+<div class="table">
+  <table>
+    <caption class="sr-only">{{ caption }}</caption>
+    <thead>
+      <tr>
+        <th scope="col">ID</th>
+        <th scope="col">Status</th>
+        <th scope="col">Priority</th>
+        <th scope="col">Effort</th>
+        <th scope="col">Title</th>
+        <th scope="col">Created</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for t in tasks %}
+      <tr>
+        <td><a href="/projects/{{ project_name }}/tasks/{{ t.full_id }}"><code>{{ t.short_id }}</code></a></td>
+        <td><span class="badge{% if t.status == "closed" %} success{% elif t.status == "in_progress" %} secondary{% endif %}">{{ t.status }}</span></td>
+        <td>{{ t.priority }}</td>
+        <td>{{ t.effort }}</td>
+        <td>{{ t.title }}</td>
+        <td><time datetime="{{ t.created_at }}">{{ t.created_at_display }}</time></td>
+      </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+</div>
+{% endmacro %}

templates/project.html ๐Ÿ”—

@@ -1,4 +1,5 @@
 {% extends "base.html" %}
+{% import "macros.html" as macros %}
 
 {% block title %}td โ€” {{ project_name }}{% endblock %}
 
@@ -13,26 +14,24 @@
 
 <h1>{{ project_name }}</h1>
 
-<div class="container">
-  <div class="row">
+<div class="row">
     <article class="card col-4">
-      <h2>Open</h2>
+      <p class="text-light">Open</p>
       <p><strong>{{ stats_open }}</strong></p>
     </article>
     <article class="card col-4">
-      <h2>In progress</h2>
+      <p class="text-light">In progress</p>
       <p><strong>{{ stats_in_progress }}</strong></p>
     </article>
     <article class="card col-4">
-      <h2>Closed</h2>
+      <p class="text-light">Closed</p>
       <p><strong>{{ stats_closed }}</strong></p>
     </article>
-  </div>
 </div>
 
 {% if !next_up.is_empty() %}
 <details open class="mt-4">
-  <summary><h2>Next up</h2></summary>
+  <summary>Next up</summary>
   <div class="table">
     <table>
       <caption class="sr-only">Top scored tasks recommended to work on next</caption>
@@ -59,8 +58,8 @@
 </details>
 {% endif %}
 
-<details open class="mt-4">
-  <summary><h2>Tasks</h2></summary>
+<details{% if next_up.is_empty() || filter_priority.is_some() || filter_effort.is_some() || filter_label.is_some() || !filter_search.is_empty() || filter_status.as_deref() != Some("open") %} open{% endif %} class="mt-4">
+  <summary>Tasks</summary>
 
   <form method="get" action="/projects/{{ project_name }}" class="hstack gap-2 mt-2 mb-4">
     <div data-field>
@@ -109,31 +108,7 @@
   {% if page_tasks.is_empty() %}
   <p class="text-light">No tasks match the current filters.</p>
   {% else %}
-  <div class="table">
-    <table>
-      <caption class="sr-only">Task list for project {{ project_name }}</caption>
-      <thead>
-        <tr>
-          <th scope="col">ID</th>
-          <th scope="col">Status</th>
-          <th scope="col">Priority</th>
-          <th scope="col">Effort</th>
-          <th scope="col">Title</th>
-        </tr>
-      </thead>
-      <tbody>
-        {% for t in page_tasks %}
-        <tr>
-          <td><a href="/projects/{{ project_name }}/tasks/{{ t.full_id }}"><code>{{ t.short_id }}</code></a></td>
-          <td><span class="badge{% if t.status == "closed" %} success{% elif t.status == "in_progress" %} secondary{% endif %}">{{ t.status }}</span></td>
-          <td>{{ t.priority }}</td>
-          <td>{{ t.effort }}</td>
-          <td>{{ t.title }}</td>
-        </tr>
-        {% endfor %}
-      </tbody>
-    </table>
-  </div>
+  {% call macros::task_table(project_name, page_tasks, "Task list for project") %}{% endcall %}
 
   {% if total_pages > 1 %}
   <nav aria-label="Task list pagination" class="mt-4">

templates/task.html ๐Ÿ”—

@@ -1,4 +1,5 @@
 {% extends "base.html" %}
+{% import "macros.html" as macros %}
 
 {% block title %}td โ€” {{ task.title }}{% endblock %}
 
@@ -10,50 +11,26 @@
     <li><a href="/projects/{{ project_name }}" class="unstyled">{{ project_name }}</a></li>
     <li aria-hidden="true">/</li>
     <li><a href="/projects/{{ project_name }}/tasks/{{ task.full_id }}" class="unstyled" aria-current="page"><strong>{{ task.short_id }}</strong></a></li>
+    <li>
+      <button class="outline small js-copy-id" hidden aria-label="Copy task ID {{ task.short_id }}" data-copy="{{ task.short_id }}"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button>
+    </li>
   </ol>
 </nav>
 
 <article class="card mt-4">
-  <header>
+  <header class="hstack items-center gap-2">
     <h1>{{ task.title }}</h1>
     <span class="badge{% if task.status == "closed" %} success{% elif task.status == "in_progress" %} secondary{% endif %}">{{ task.status }}</span>
   </header>
 
   {% if !task.description.is_empty() %}
-  <p>{{ task.description }}</p>
+  <div>{{ task.description|safe }}</div>
   {% endif %}
 
-  <div class="table mt-4">
-    <table>
-      <caption class="sr-only">Task metadata</caption>
-      <tbody>
-        <tr>
-          <th scope="row">ID</th>
-          <td><code>{{ task.short_id }}</code></td>
-        </tr>
-        <tr>
-          <th scope="row">Type</th>
-          <td>{{ task.task_type }}</td>
-        </tr>
-        <tr>
-          <th scope="row">Priority</th>
-          <td>{{ task.priority }}</td>
-        </tr>
-        <tr>
-          <th scope="row">Effort</th>
-          <td>{{ task.effort }}</td>
-        </tr>
-        <tr>
-          <th scope="row">Created</th>
-          <td><time>{{ task.created_at }}</time></td>
-        </tr>
-        <tr>
-          <th scope="row">Updated</th>
-          <td><time>{{ task.updated_at }}</time></td>
-        </tr>
-      </tbody>
-    </table>
-  </div>
+  <hr>
+  <footer>
+    <p class="text-light">{{ task.task_type }} ยท {{ task.priority }} priority ยท {{ task.effort }} effort<br>Created <time datetime="{{ task.created_at }}">{{ task.created_at_display }}</time> ยท Updated <time datetime="{{ task.updated_at }}">{{ task.updated_at_display }}</time></p>
+  </footer>
 
   {% if !task.labels.is_empty() %}
   <section class="mt-4" aria-label="Labels">
@@ -66,57 +43,34 @@
   </section>
   {% endif %}
 
-  {% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %}
-  <section class="mt-4" aria-label="Blockers">
-    <h2>Blockers</h2>
-    <ul>
-      {% for b in blockers_open %}
-      <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge warning">open</span></li>
-      {% endfor %}
-      {% for b in blockers_resolved %}
-      <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge success">resolved</span></li>
-      {% endfor %}
-    </ul>
-  </section>
-  {% endif %}
-
-  {% if !subtasks.is_empty() %}
-  <section class="mt-4" aria-label="Subtasks">
-    <h2>Subtasks</h2>
-    <div class="table">
-      <table>
-        <caption class="sr-only">Subtasks of {{ task.title }}</caption>
-        <thead>
-          <tr>
-            <th scope="col">ID</th>
-            <th scope="col">Status</th>
-            <th scope="col">Title</th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for s in subtasks %}
-          <tr>
-            <td><a href="/projects/{{ project_name }}/tasks/{{ s.full_id }}"><code>{{ s.short_id }}</code></a></td>
-            <td><span class="badge{% if s.status == "closed" %} success{% elif s.status == "in_progress" %} secondary{% endif %}">{{ s.status }}</span></td>
-            <td>{{ s.title }}</td>
-          </tr>
-          {% endfor %}
-        </tbody>
-      </table>
-    </div>
-  </section>
-  {% endif %}
-
   {% if !task.logs.is_empty() %}
-  <section class="mt-4" aria-label="Work log">
-    <h2>Work log</h2>
+  <details class="mt-4">
+    <summary>Work log</summary>
     {% for log in task.logs %}
-    <details>
-      <summary><time>{{ log.timestamp }}</time></summary>
-      <p>{{ log.message }}</p>
-    </details>
+    <div><time datetime="{{ log.timestamp }}">{{ log.timestamp_display }}</time> โ€” {{ log.message|safe }}</div>
     {% endfor %}
-  </section>
+  </details>
   {% endif %}
 </article>
+
+{% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %}
+<article class="card mt-4">
+  <h2>Blockers</h2>
+  <ul>
+    {% for b in blockers_open %}
+    <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge warning">open</span></li>
+    {% endfor %}
+    {% for b in blockers_resolved %}
+    <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge success">resolved</span></li>
+    {% endfor %}
+  </ul>
+</article>
+{% endif %}
+
+{% if !subtasks.is_empty() %}
+<article class="card mt-4">
+  <h2>Subtasks</h2>
+  {% call macros::task_table(project_name, subtasks, "Subtasks") %}{% endcall %}
+</article>
+{% endif %}
 {% endblock %}