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 {
585 .page {585 .page {
586 padding: 0.75rem;586 padding: 0.75rem;
587 }587 }
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 }
588}621}
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 {
255}255}
256256
257impl SignatureCache {257impl 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.
259 #[must_use]260 #[must_use]
260 pub fn new(capacity: u64) -> Self {261 pub fn new(capacity: u64) -> Self {
261 Self {262 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(),
263 }267 }
264 }268 }
265269
crates/web/src/layout.rs +2 −2
@@ -70,7 +70,7 @@ fn topbar(chrome: &Chrome) -> Markup {
70 html! {70 html! {
71 header class="topbar" {71 header class="topbar" {
72 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"72 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"
73 aria-expanded="false" { "☰" }73 aria-controls="drawer" aria-expanded="false" { "☰" }
74 span class="slug" { a href="/" { (chrome.instance_name) } }74 span class="slug" { a href="/" { (chrome.instance_name) } }
75 nav class="tabs" aria-label="Primary" {}75 nav class="tabs" aria-label="Primary" {}
76 a class="icon-btn" href="/" aria-label="Home" { "⌂" }76 a class="icon-btn" href="/" aria-label="Home" { "⌂" }
@@ -81,7 +81,7 @@ fn topbar(chrome: &Chrome) -> Markup {
81/// The off-canvas navigation drawer.81/// The off-canvas navigation drawer.
82fn drawer(chrome: &Chrome) -> Markup {82fn drawer(chrome: &Chrome) -> Markup {
83 html! {83 html! {
84 aside class="drawer" aria-label="Navigation" {84 aside id="drawer" class="drawer" aria-label="Navigation" {
85 h2 { (chrome.instance_name) }85 h2 { (chrome.instance_name) }
86 nav {86 nav {
87 a href="/" { "Home" }87 a href="/" { "Home" }
crates/web/src/lib.rs +4 −0
@@ -66,6 +66,9 @@ pub struct AppState {
66 pub mailer: Arc<Mailer>,66 pub mailer: Arc<Mailer>,
67 /// The instance signing secret (API tokens, future session signing).67 /// The instance signing secret (API tokens, future session signing).
68 pub secret: Arc<String>,68 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,
69 /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it.72 /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it.
70 assets: Arc<RwLock<Assets>>,73 assets: Arc<RwLock<Assets>>,
71}74}
@@ -84,6 +87,7 @@ impl AppState {
84 store,87 store,
85 mailer: Arc::new(mailer),88 mailer: Arc::new(mailer),
86 secret: Arc::new(secret),89 secret: Arc::new(secret),
90 signatures: git::SignatureCache::new(4096),
87 assets: Arc::new(RwLock::new(assets)),91 assets: Arc::new(RwLock::new(assets)),
88 })92 })
89 }93 }
crates/web/src/repo.rs +6 −3
@@ -618,14 +618,16 @@ async fn commits_view(
618 })618 })
619 .await?;619 .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.
622 let keys = signing_keys(state.store.all_keys().await?);623 let keys = signing_keys(state.store.all_keys().await?);
624 let cache = state.signatures.clone();
623 let states = git_read(state, &ctx.repo.id, {625 let states = git_read(state, &ctx.repo.id, {
624 let oids: Vec<git::Oid> = commits.iter().map(|c| c.oid.clone()).collect();626 let oids: Vec<git::Oid> = commits.iter().map(|c| c.oid.clone()).collect();
625 move |repo| {627 move |repo| {
626 Ok(oids628 Ok(oids
627 .iter()629 .iter()
628 .map(|o| repo.signature_state(o, &keys))630 .map(|o| (*cache.get(repo, o, &keys)).clone())
629 .collect::<Vec<_>>())631 .collect::<Vec<_>>())
630 }632 }
631 })633 })
@@ -751,6 +753,7 @@ async fn load_commit(
751 let oid = git::Oid::new(sha);753 let oid = git::Oid::new(sha);
752 let context_lines = state.config.ui.diff_context_lines;754 let context_lines = state.config.ui.diff_context_lines;
753 let sha_owned = sha.to_string();755 let sha_owned = sha.to_string();
756 let cache = state.signatures.clone();
754 git_read(state, repo_id, move |repo| {757 git_read(state, repo_id, move |repo| {
755 let detail = repo.commit(&oid)?;758 let detail = repo.commit(&oid)?;
756 let base = detail.parents.first().cloned();759 let base = detail.parents.first().cloned();
@@ -772,7 +775,7 @@ async fn load_commit(
772 .into_iter()775 .into_iter()
773 .map(|file| render_one_file(repo, &head_rev, base_rev.as_ref(), file))776 .map(|file| render_one_file(repo, &head_rev, base_rev.as_ref(), file))
774 .collect();777 .collect();
775 let sig = repo.signature_state(&oid, &keys);778 let sig = (*cache.get(repo, &oid, &keys)).clone();
776 Ok((detail, rendered, sig))779 Ok((detail, rendered, sig))
777 })780 })
778 .await781 .await
docs/decisions.md +18 −0
@@ -65,6 +65,24 @@ file. There is no `server start` / `server stop` pair.
65**Rationale:** Foreground is correct for systemd and Docker, which own process65**Rationale:** Foreground is correct for systemd and Docker, which own process
66lifecycle. This deviates from an earlier `server start`/`server stop` sketch.66lifecycle. 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
71into `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
77every row), so it is the one place caching earns its keep (§18 "moka where
78measured"); the TTL bounds staleness after a key change without explicit
79invalidation. `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
81picker 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
83tracked gaps, not blockers. The per-file highlight **timeout** guard (§8) also
84remains deferred as noted earlier.
85
68## 2026-07-25 — API nested into web via `nest_service`; web may depend on api86## 2026-07-25 — API nested into web via `nest_service`; web may depend on api
6987
70**Decision:** The `/api/v1` JSON surface is a self-contained `Router` built by88**Decision:** The `/api/v1` JSON surface is a self-contained `Router` built by