Use typed paths for commit URLs and add commit view styling
- Add commit_url field to ChangeRow and CommitParent, generated in
  handlers from the repo name instead of hardcoded in templates
- Rename /commit/ route to /commits/ (plural)
- Add CSS for commit detail page (subject, body, meta, diff)
- Add CSS for repo list page
- Fix file view code surface padding and background
- Update all template links to use commit_url

Assisted-by: Owl Alpha via pi
change rzuuzvmxplmwmzpzmvtwxzrknwmnuqlv
commit f201534b663b153c17d868427a6de7a825683082
author Alpha Chen <alpha@kejadlen.dev>
date
parent xqruvknu
diff --git a/quire-server/src/quire/web/handlers/commit.rs b/quire-server/src/quire/web/handlers/commit.rs
index a9431c7..13cb9ee 100644
--- a/quire-server/src/quire/web/handlers/commit.rs
+++ b/quire-server/src/quire/web/handlers/commit.rs
@@ -61,10 +61,10 @@ pub async fn commit_view(
 
         let timestamp_ms: i64 = timestamp_str.parse().ok().map(|secs: i64| secs * 1000)?;
 
-        let parents: Vec<CommitParent> = parents_str
+        let parent_shars: Vec<String> = parents_str
             .split_whitespace()
             .filter(|s| !s.is_empty())
-            .map(|p| CommitParent { sha: p.to_string() })
+            .map(|p| p.to_string())
             .collect();
 
         let diff = reader
@@ -78,18 +78,26 @@ pub async fn commit_view(
             timestamp_ms,
             subject,
             body,
-            parents,
+            parent_shars,
             diff,
         ))
     })
     .await
     .unwrap_or(None);
 
-    let (sha, author, email, timestamp_ms, subject, body, parents, diff) = match result {
+    let (sha, author, email, timestamp_ms, subject, body, parent_shas, diff) = match result {
         Some(data) => data,
         None => return StatusCode::NOT_FOUND.into_response(),
     };
 
+    let parents: Vec<CommitParent> = parent_shas
+        .into_iter()
+        .map(|p| CommitParent {
+            commit_url: format!("/{repo_display}/commits/{p}"),
+            sha: p,
+        })
+        .collect();
+
     let sha_short = if sha.len() >= 8 {
         sha[..8].to_string()
     } else {
diff --git a/quire-server/src/quire/web/handlers/git.rs b/quire-server/src/quire/web/handlers/git.rs
index 8853c61..bece1e1 100644
--- a/quire-server/src/quire/web/handlers/git.rs
+++ b/quire-server/src/quire/web/handlers/git.rs
@@ -33,13 +33,13 @@ impl<'a> RepoView<'a> {
     }
 
     /// Read all summary data from the repo for the home page.
-    pub(super) fn read_all(&self) -> GitData {
+    pub(super) fn read_all(&self, repo: &str) -> GitData {
         (
             self.head_info(),
             self.readme(),
             self.bookmarks(),
             self.tags(),
-            self.recent_changes(),
+            self.recent_changes(repo),
         )
     }
 
@@ -114,11 +114,11 @@ impl<'a> RepoView<'a> {
             .collect()
     }
 
-    pub(super) fn recent_changes(&self) -> Vec<ChangeRow> {
-        self.recent_changes_for(None)
+    pub(super) fn recent_changes(&self, repo: &str) -> Vec<ChangeRow> {
+        self.recent_changes_for(None, repo)
     }
 
-    pub(super) fn recent_changes_for(&self, path: Option<&str>) -> Vec<ChangeRow> {
+    pub(super) fn recent_changes_for(&self, path: Option<&str>, repo: &str) -> Vec<ChangeRow> {
         let mut args = vec!["log", "-12", "--format=%H|%s|%cr"];
         if let Some(p) = path
             && !p.is_empty()
@@ -131,8 +131,10 @@ impl<'a> RepoView<'a> {
         out.lines()
             .filter_map(|line| {
                 let mut parts = line.splitn(3, '|');
+                let sha = parts.next()?.to_string();
                 Some(ChangeRow {
-                    sha: parts.next()?.to_string(),
+                    commit_url: format!("/{repo}/commits/{sha}"),
+                    sha,
                     description: parts.next().unwrap_or("").to_string(),
                     age: parts.next().unwrap_or("").to_string(),
                 })
diff --git a/quire-server/src/quire/web/handlers/log_view.rs b/quire-server/src/quire/web/handlers/log_view.rs
index 2e946e5..9f21e8e 100644
--- a/quire-server/src/quire/web/handlers/log_view.rs
+++ b/quire-server/src/quire/web/handlers/log_view.rs
@@ -23,9 +23,10 @@ pub async fn log_view(
         _ => return StatusCode::NOT_FOUND.into_response(),
     };
 
+    let repo_d = repo_display.clone();
     let (changes, bookmark, sha_short) = tokio::task::spawn_blocking(move || {
         let reader = RepoView::new(&git_repo);
-        let changes = reader.recent_changes();
+        let changes = reader.recent_changes(&repo_d);
         let bookmark = reader
             .run(&["symbolic-ref", "--short", "HEAD"])
             .unwrap_or_else(|| "main".to_string());
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
index 4223024..256df98 100644
--- a/quire-server/src/quire/web/handlers/repo.rs
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -52,8 +52,9 @@ pub async fn repo_home(
         Err(_) => vec![],
     };
 
+    let rd = repo_display.clone();
     let (head, readme_html, bookmarks, tags, recent_changes) =
-        tokio::task::spawn_blocking(move || RepoView::new(&git_repo).read_all())
+        tokio::task::spawn_blocking(move || RepoView::new(&git_repo).read_all(&rd))
             .await
             .unwrap_or_default();
 
diff --git a/quire-server/src/quire/web/handlers/tree.rs b/quire-server/src/quire/web/handlers/tree.rs
index 9fe374f..c7344ba 100644
--- a/quire-server/src/quire/web/handlers/tree.rs
+++ b/quire-server/src/quire/web/handlers/tree.rs
@@ -39,6 +39,7 @@ async fn tree_or_file_at_path(quire: Quire, repo: String, path: String, authed:
     };
 
     let path_clone = path.clone();
+    let repo_d = repo_display.clone();
     let result = tokio::task::spawn_blocking(move || {
         let reader = RepoView::new(&git_repo);
 
@@ -46,7 +47,7 @@ async fn tree_or_file_at_path(quire: Quire, repo: String, path: String, authed:
         if let Some(tree_data) = read_tree_data(&reader, &path_clone) {
             let bookmarks = reader.bookmarks();
             let tags = reader.tags();
-            let recent_changes = reader.recent_changes_for(Some(&path_clone));
+            let recent_changes = reader.recent_changes_for(Some(&path_clone), &repo_d);
             Some(Ok((tree_data, bookmarks, tags, recent_changes)))
         } else {
             // ls-tree failed — try reading as a file blob.
diff --git a/quire-server/src/quire/web/mod.rs b/quire-server/src/quire/web/mod.rs
index 5f822e8..28919eb 100644
--- a/quire-server/src/quire/web/mod.rs
+++ b/quire-server/src/quire/web/mod.rs
@@ -84,7 +84,7 @@ pub mod paths {
     }
 
     #[derive(TypedPath, Deserialize)]
-    #[typed_path("/{repo}/commit/{sha}")]
+    #[typed_path("/{repo}/commits/{sha}")]
     pub struct CommitPath {
         pub repo: String,
         pub sha: String,
diff --git a/quire-server/src/quire/web/templates.rs b/quire-server/src/quire/web/templates.rs
index 43ce791..b386b8a 100644
--- a/quire-server/src/quire/web/templates.rs
+++ b/quire-server/src/quire/web/templates.rs
@@ -382,6 +382,7 @@ pub struct ChangeRow {
     pub sha: String,
     pub description: String,
     pub age: String,
+    pub commit_url: String,
 }
 
 impl ChangeRow {
@@ -553,6 +554,7 @@ impl CommitTemplate {
 
 pub struct CommitParent {
     pub sha: String,
+    pub commit_url: String,
 }
 
 impl CommitParent {
diff --git a/quire-server/static/style.css b/quire-server/static/style.css
index a015891..4d1bf8f 100644
--- a/quire-server/static/style.css
+++ b/quire-server/static/style.css
@@ -614,6 +614,15 @@ html.dark {
   color: var(--mutedFaint);
 }
 
+.change-mini-desc-link {
+  color: inherit;
+  text-decoration: none;
+}
+
+.change-mini-desc-link:hover {
+  color: var(--accent);
+}
+
 /* ── CI run list (full page) ───────────────────────────────────── */
 
 .ci-run-list {
@@ -787,16 +796,16 @@ html.dark {
   line-height: 1.7;
   display: grid;
   grid-template-columns: auto 1fr;
+  background: var(--code);
 }
 
 .code-gutter {
-  background: var(--code);
-  padding: 18px 14px 18px 56px;
+  padding: 16px 10px 16px 12px;
   color: var(--mutedFaint);
   text-align: right;
   font-variant-numeric: tabular-nums;
   user-select: none;
-  border-right: 1px solid var(--rule2);
+  border-right: 1px solid var(--rule);
 }
 
 .code-line-num {
@@ -804,20 +813,20 @@ html.dark {
 }
 
 .code-body {
-  padding: 18px 56px 18px 20px;
+  padding: 16px 16px 16px 12px;
   overflow: auto;
   margin: 0;
-  white-space: pre-wrap;
-  word-break: break-all;
+  white-space: pre;
   font-family: inherit;
   font-size: inherit;
   line-height: inherit;
-  background: var(--code) !important;
+  background: transparent !important;
   border-radius: 0 !important;
 }
 
-.code-body > code[data-lang] {
-  display: block;
+.code-body > code[data-lang],
+.code-body > code[data-lang] * {
+  background: transparent !important;
 }
 
 /* ── Arborium syntax highlighting integration ──────────────────── */
@@ -1200,6 +1209,134 @@ pre code[data-lang] {
   color: var(--muted);
 }
 
+/* ── Commit detail ─────────────────────────────────────────────── */
+
+.commit-detail {
+  padding: var(--space-l) var(--space-xl);
+  min-width: 0;
+}
+
+.commit-message {
+  margin-bottom: var(--space-m);
+}
+
+.commit-subject {
+  font-size: 20px;
+  font-weight: 600;
+  line-height: 1.3;
+  color: var(--ink);
+  margin-bottom: var(--space-s);
+}
+
+.commit-body {
+  font-family: var(--font-humanist);
+  font-size: 15px;
+  line-height: 1.7;
+  color: var(--muted);
+  white-space: pre-wrap;
+  word-break: break-word;
+  margin: 0;
+}
+
+.commit-meta-list {
+  font-family: var(--font-mono);
+  font-size: 12.5px;
+  line-height: 2;
+  margin-bottom: var(--space-l);
+}
+
+.commit-meta-row {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-s);
+}
+
+.commit-meta-key {
+  color: var(--mutedFaint);
+  min-width: 70px;
+  font-size: 11px;
+  letter-spacing: 0.8px;
+  text-transform: uppercase;
+}
+
+.commit-meta-val {
+  color: var(--ink);
+}
+
+.commit-meta-val .change-id {
+  margin-right: var(--space-2xs);
+}
+
+.commit-diff {
+  font-family: var(--font-mono);
+  font-size: 13px;
+  line-height: 1.7;
+  background: var(--code);
+  padding: var(--space-s) var(--space-m);
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  overflow: auto;
+  border-left: 2px solid var(--accent);
+}
+
+/* ── Repo list ──────────────────────────────────────────────────── */
+
+.repo-list-page {
+  padding: var(--space-l) var(--space-xl);
+  max-width: 820px;
+}
+
+.repo-list-heading {
+  font-size: 22px;
+  font-weight: 600;
+  margin: 0 0 var(--space-m);
+  color: var(--ink);
+}
+
+.repo-list {
+  border-top: 1px solid var(--rule);
+}
+
+.repo-list-item {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-s);
+  padding: var(--space-xs) 0;
+  border-bottom: 1px solid var(--rule);
+  text-decoration: none;
+  color: var(--ink);
+}
+
+.repo-list-item:hover {
+  color: var(--accent);
+}
+
+.repo-list-name {
+  font-family: var(--font-mono);
+  font-size: 14px;
+  font-weight: 500;
+  color: var(--ink);
+}
+
+.repo-list-item:hover .repo-list-name {
+  color: var(--accent);
+}
+
+.repo-list-desc {
+  font-size: 14px;
+  color: var(--muted);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.repo-list-empty {
+  color: var(--muted);
+  font-style: italic;
+  padding: var(--space-l) 0;
+}
+
 /* ── Mobile (≤768px) ───────────────────────────────────────────── */
 
 @media (max-width: 768px) {
diff --git a/quire-server/templates/ci/run_detail.html b/quire-server/templates/ci/run_detail.html
index ca8b964..1be27c6 100644
--- a/quire-server/templates/ci/run_detail.html
+++ b/quire-server/templates/ci/run_detail.html
@@ -11,7 +11,7 @@
 <nav class="repo-section-nav">
   {% include "_repo_section_nav.html" %}
   <span class="repo-position">
-    <a class="ci-commit-link" href="/{{ repo }}/commit/{{ run.sha }}">{{ run.sha_short() }}</a>
+    <a class="ci-commit-link" href="/{{ repo }}/commits/{{ run.sha }}">{{ run.sha_short() }}</a>
     <span class="repo-meta-dot">·</span>
     <span>{{ run.branch_short() }}</span>
     <span class="repo-meta-dot">·</span>
diff --git a/quire-server/templates/commit.html b/quire-server/templates/commit.html
index cb330c2..b0ff93d 100644
--- a/quire-server/templates/commit.html
+++ b/quire-server/templates/commit.html
@@ -49,7 +49,7 @@
         <span class="commit-meta-key">parent{% if parents.len() > 1 %}s{% endif %}</span>
         <span class="commit-meta-val">
           {% for p in parents %}
-          <a class="change-id" href="/{{ repo }}/commit/{{ p.sha_full() }}" title="commit {{ p.sha_full() }}">
+          <a class="change-id" href="{{ p.commit_url }}" title="commit {{ p.sha_full() }}">
             <span class="change-head">{{ p.sha_head() }}</span><span class="change-tail">{{ p.sha_tail() }}</span>
           </a>
           {% endfor %}
diff --git a/quire-server/templates/file.html b/quire-server/templates/file.html
index 976f349..adaf4c3 100644
--- a/quire-server/templates/file.html
+++ b/quire-server/templates/file.html
@@ -66,7 +66,7 @@
     <div class="side-block">
       <div class="side-block-title">Last change</div>
       <div class="file-last-change-side">
-        <a class="change-id" href="/{{ repo }}/log" title="commit {{ last_change_sha }}">
+        <a class="change-id" href="/{{ repo }}/commits/{{ last_change_sha }}" title="commit {{ last_change_sha }}">
           <span class="change-head">{{ last_change_head }}</span><span class="change-tail">{{ last_change_tail }}</span>
         </a>
         <div class="file-last-change-msg">{{ last_change_msg }}</div>
diff --git a/quire-server/templates/log.html b/quire-server/templates/log.html
index 2cee7fa..62f9867 100644
--- a/quire-server/templates/log.html
+++ b/quire-server/templates/log.html
@@ -23,10 +23,10 @@
 <div class="log-body">
   {% for change in changes %}
   <div class="log-row">
-    <a class="log-sha" href="/{{ repo }}/commit/{{ change.sha_full() }}" title="commit {{ change.sha_full() }}">
+    <a class="log-sha" href="{{ change.commit_url }}" title="commit {{ change.sha_full() }}">
       <span class="change-head">{{ change.change_head() }}</span><span class="change-tail">{{ change.change_tail() }}</span>
     </a>
-    <a class="log-subject" href="/{{ repo }}/commit/{{ change.sha_full() }}">
+    <a class="log-subject" href="{{ change.commit_url }}">
       {% if change.description.is_empty() %}<span class="no-desc">(no description set)</span>{% else %}{{ change.description }}{% endif %}
     </a>
     <span class="log-age"><time>{{ change.age }}</time></span>
diff --git a/quire-server/templates/repo_home.html b/quire-server/templates/repo_home.html
index 8bc2023..9d948d6 100644
--- a/quire-server/templates/repo_home.html
+++ b/quire-server/templates/repo_home.html
@@ -16,7 +16,7 @@
     <span class="bookmark-glyph">※</span>
     <span class="bookmark-name">{{ h.bookmark }}</span>
     <span class="repo-meta-sep">→</span>
-    <a class="change-id" href="/{{ repo }}/commit/{{ h.sha }}" title="commit {{ h.sha_short() }}">
+    <a class="change-id" href="/{{ repo }}/commits/{{ h.sha }}" title="commit {{ h.sha_short() }}">
       <span class="change-head">{{ h.change_head() }}</span><span class="change-tail">{{ h.change_tail() }}</span>
     </a>
     <span class="commit-id-secondary">{{ h.sha_short() }}</span>
@@ -115,13 +115,13 @@
         {% for ch in recent_changes %}
         <div class="change-mini-row">
           <div class="change-mini-header">
-            <a class="change-id" href="/{{ repo }}/commit/{{ ch.sha_full() }}" title="commit {{ ch.sha_full() }}">
+            <a class="change-id" href="{{ ch.commit_url }}" title="commit {{ ch.sha_full() }}">
               <span class="change-head">{{ ch.change_head() }}</span><span class="change-tail">{{ ch.change_tail() }}</span>
             </a>
             <span class="change-mini-age">{{ ch.age }}</span>
           </div>
           <div class="change-mini-desc">
-            <a class="change-mini-desc-link" href="/{{ repo }}/commit/{{ ch.sha_full() }}">
+            <a class="change-mini-desc-link" href="{{ ch.commit_url }}">
             {% if ch.description.is_empty() %}<span class="no-desc">(no description set)</span>{% else %}{{ ch.description }}{% endif %}
             </a>
           </div>
diff --git a/quire-server/templates/tree.html b/quire-server/templates/tree.html
index eeb9db8..e0cce17 100644
--- a/quire-server/templates/tree.html
+++ b/quire-server/templates/tree.html
@@ -69,7 +69,7 @@
   {# RIGHT — recent commit log for this ref #}
   <aside class="tree-sidebar">
     {% for change in recent_changes %}
-    <a class="tree-log-item" href="/{{ repo }}/commit/{{ change.sha_full() }}">
+    <a class="tree-log-item" href="{{ change.commit_url }}">
       <div class="tree-log-subject">{{ change.description }}</div>
       <div class="tree-log-meta">
         <span class="tree-log-sha">{{ change.change_head() }}{{ change.change_tail() }}</span>