fabrica

hanna/fabrica

feat(web): polish — signature cache, third theme, a11y, mobile 380px

6748436 · hanna committed on 2026-07-25

- Wire the moka SignatureCache (bounded, 10-min TTL) into AppState and the
  commit list / commit page, so list views stop re-verifying every row —
  the measured hot path (§18 caching where measured).
- Add a third embedded theme (nord) that sets only the documented --fb-*
  tokens, proving the themeability acceptance test: a complete UI from
  tokens alone. Verified the picker lists it and it renders.
- Accessibility: the drawer toggle now aria-controls the drawer.
- Mobile 380px: >=44px touch targets, no horizontal body scroll, and
  blob/diff/clone rows scroll within their own box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +115 −7UnifiedSplit
assets/base.css +33 −0
@@ -585,4 +585,37 @@ td.diff-empty {
585585 .page {
586586 padding: 0.75rem;
587587 }
588+ /* The body never scrolls horizontally; wide content scrolls within itself. */
589+ body {
590+ overflow-x: hidden;
591+ }
592+ /* Comfortable touch targets (>= 44px) for the primary interactive elements. */
593+ .topbar .tabs a,
594+ .repo-tabs a,
595+ .drawer nav a,
596+ .drawer .drawer-item,
597+ .btn,
598+ .icon-btn {
599+ min-height: 44px;
600+ display: inline-flex;
601+ align-items: center;
602+ }
603+ .icon-btn {
604+ justify-content: center;
605+ min-width: 44px;
606+ }
607+ .repo-tabs,
608+ .clone-row {
609+ overflow-x: auto;
610+ -webkit-overflow-scrolling: touch;
611+ }
612+ .clone-row input {
613+ min-width: 0;
614+ }
615+ /* Blob and diff tables scroll within their own box. */
616+ table.blob,
617+ table.diff {
618+ display: block;
619+ overflow-x: auto;
620+ }
588621 }
assets/themes/nord.css +46 −0
@@ -0,0 +1,46 @@
1+/* This Source Code Form is subject to the terms of the Mozilla Public
2+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+ *
5+ * nord — a third, community-style theme. It sets ONLY the documented --fb-*
6+ * tokens (docs/theming.md) and nothing else, which is the themeability
7+ * acceptance test: a complete, coherent UI from tokens alone, no base.css edits. */
8+
9+:root {
10+ --fb-color-scheme: dark;
11+
12+ /* Surfaces & text */
13+ --fb-bg: #2e3440;
14+ --fb-bg-raised: #3b4252;
15+ --fb-bg-inset: #434c5e;
16+ --fb-border: #4c566a;
17+ --fb-fg: #eceff4;
18+ --fb-fg-muted: #99a3b3;
19+
20+ /* Accents & status */
21+ --fb-accent: #88c0d0;
22+ --fb-accent-fg: #2e3440;
23+ --fb-success: #a3be8c;
24+ --fb-warning: #ebcb8b;
25+ --fb-danger: #bf616a;
26+
27+ /* Diff row backgrounds & gutters */
28+ --fb-diff-add-bg: rgba(163, 190, 140, 0.16);
29+ --fb-diff-del-bg: rgba(191, 97, 106, 0.16);
30+ --fb-diff-add-fg: #a3be8c;
31+ --fb-diff-del-fg: #bf616a;
32+
33+ /* Syntax highlighting */
34+ --fb-hl-keyword: #81a1c1;
35+ --fb-hl-function: #88c0d0;
36+ --fb-hl-type: #8fbcbb;
37+ --fb-hl-string: #a3be8c;
38+ --fb-hl-number: #b48ead;
39+ --fb-hl-comment: #616e88;
40+ --fb-hl-constant: #b48ead;
41+ --fb-hl-variable: #d8dee9;
42+ --fb-hl-operator: #81a1c1;
43+ --fb-hl-punctuation: #eceff4;
44+ --fb-hl-attribute: #8fbcbb;
45+ --fb-hl-tag: #81a1c1;
46+}
crates/git/src/signature.rs +6 −2
@@ -255,11 +255,15 @@ pub struct SignatureCache {
255255 }
256256
257257 impl SignatureCache {
258- /// A cache holding up to `capacity` results.
258+ /// A cache holding up to `capacity` results, each expiring after 10 minutes so
259+ /// a key change is reflected without an explicit invalidation.
259260 #[must_use]
260261 pub fn new(capacity: u64) -> Self {
261262 Self {
262- inner: moka::sync::Cache::new(capacity),
263+ inner: moka::sync::Cache::builder()
264+ .max_capacity(capacity)
265+ .time_to_live(std::time::Duration::from_secs(600))
266+ .build(),
263267 }
264268 }
265269
crates/web/src/layout.rs +2 −2
@@ -70,7 +70,7 @@ fn topbar(chrome: &Chrome) -> Markup {
7070 html! {
7171 header class="topbar" {
7272 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"
73- aria-expanded="false" { "☰" }
73+ aria-controls="drawer" aria-expanded="false" { "☰" }
7474 span class="slug" { a href="/" { (chrome.instance_name) } }
7575 nav class="tabs" aria-label="Primary" {}
7676 a class="icon-btn" href="/" aria-label="Home" { "⌂" }
@@ -81,7 +81,7 @@ fn topbar(chrome: &Chrome) -> Markup {
8181 /// The off-canvas navigation drawer.
8282 fn drawer(chrome: &Chrome) -> Markup {
8383 html! {
84- aside class="drawer" aria-label="Navigation" {
84+ aside id="drawer" class="drawer" aria-label="Navigation" {
8585 h2 { (chrome.instance_name) }
8686 nav {
8787 a href="/" { "Home" }
crates/web/src/lib.rs +4 −0
@@ -66,6 +66,9 @@ pub struct AppState {
6666 pub mailer: Arc<Mailer>,
6767 /// The instance signing secret (API tokens, future session signing).
6868 pub secret: Arc<String>,
69+ /// Commit/tag signature-verification cache (verification is expensive; list
70+ /// views reuse results, bounded and TTL'd).
71+ pub signatures: git::SignatureCache,
6972 /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it.
7073 assets: Arc<RwLock<Assets>>,
7174 }
@@ -84,6 +87,7 @@ impl AppState {
8487 store,
8588 mailer: Arc::new(mailer),
8689 secret: Arc::new(secret),
90+ signatures: git::SignatureCache::new(4096),
8791 assets: Arc::new(RwLock::new(assets)),
8892 })
8993 }
crates/web/src/repo.rs +6 −3
@@ -618,14 +618,16 @@ async fn commits_view(
618618 })
619619 .await?;
620620
621- // Verify signatures against all registered keys (bounded to this page's rows).
621+ // Verify signatures against all registered keys (bounded to this page's rows),
622+ // reusing the TTL'd signature cache.
622623 let keys = signing_keys(state.store.all_keys().await?);
624+ let cache = state.signatures.clone();
623625 let states = git_read(state, &ctx.repo.id, {
624626 let oids: Vec<git::Oid> = commits.iter().map(|c| c.oid.clone()).collect();
625627 move |repo| {
626628 Ok(oids
627629 .iter()
628- .map(|o| repo.signature_state(o, &keys))
630+ .map(|o| (*cache.get(repo, o, &keys)).clone())
629631 .collect::<Vec<_>>())
630632 }
631633 })
@@ -751,6 +753,7 @@ async fn load_commit(
751753 let oid = git::Oid::new(sha);
752754 let context_lines = state.config.ui.diff_context_lines;
753755 let sha_owned = sha.to_string();
756+ let cache = state.signatures.clone();
754757 git_read(state, repo_id, move |repo| {
755758 let detail = repo.commit(&oid)?;
756759 let base = detail.parents.first().cloned();
@@ -772,7 +775,7 @@ async fn load_commit(
772775 .into_iter()
773776 .map(|file| render_one_file(repo, &head_rev, base_rev.as_ref(), file))
774777 .collect();
775- let sig = repo.signature_state(&oid, &keys);
778+ let sig = (*cache.get(repo, &oid, &keys)).clone();
776779 Ok((detail, rendered, sig))
777780 })
778781 .await
docs/decisions.md +18 −0
@@ -65,6 +65,24 @@ file. There is no `server start` / `server stop` pair.
6565 **Rationale:** Foreground is correct for systemd and Docker, which own process
6666 lifecycle. This deviates from an earlier `server start`/`server stop` sketch.
6767
68+## 2026-07-25 — Polish: signature cache wired; `theme list`/`gc` CLI deferred
69+
70+**Decision:** The `moka` `SignatureCache` (bounded 4096, 10-minute TTL) is wired
71+into `AppState` and used by the commit list and commit page. The
72+`fabrica theme list` and `fabrica gc` CLI subcommands are **not** shipped.
73+
74+**Alternatives:** Also cache attributes/highlight; implement `theme list`/`gc`.
75+
76+**Rationale:** Signature verification is the measured hot path (list views verify
77+every row), so it is the one place caching earns its keep (§18 "moka where
78+measured"); the TTL bounds staleness after a key change without explicit
79+invalidation. `theme list` would need the embedded theme registry, which lives in
80+`web` and cannot be reached from `cli` (nothing may depend on `web`); the web theme
81+picker already lists every theme, so the CLI command is low value. `gc`
82+(trash reaping) is a maintenance convenience with no correctness impact; both are
83+tracked gaps, not blockers. The per-file highlight **timeout** guard (§8) also
84+remains deferred as noted earlier.
85+
6886 ## 2026-07-25 — API nested into web via `nest_service`; web may depend on api
6987
7088 **Decision:** The `/api/v1` JSON surface is a self-contained `Router` built by