fabrica

hanna/fabrica

feat(web): inline SVG icon set and drop the footer

04276f5 · hanna committed on 2026-07-25

Vendor Lucide (ISC) UI glyphs and Simple Icons (CC0) brand marks as an
inline-SVG `icons` module so no page makes a network request (§9.5), and
replace the emoji glyphs in the top bar, drawer, and tree listing. Remove
the page footer per operator preference.

The full glyph set is vendored up front; a temporary `allow(dead_code)`
covers the variants the profile, explore, and settings views wire in next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +273 −41UnifiedSplit
assets/base.css +33 −12
@@ -51,6 +51,14 @@ code, pre, .mono {
5151 font-family: var(--fb-font-mono);
5252 }
5353
54+/* Inline icons inherit colour and scale to the current font size. */
55+.icon {
56+ width: 1em;
57+ height: 1em;
58+ flex: none;
59+ vertical-align: -0.125em;
60+}
61+
5462 /* Accessibility: visible focus, skip link. */
5563 :focus-visible {
5664 outline: 2px solid var(--fb-accent);
@@ -126,6 +134,10 @@ code, pre, .mono {
126134 .icon-btn:hover {
127135 background: var(--fb-bg-inset);
128136 }
137+.icon-btn .icon {
138+ width: 1.15rem;
139+ height: 1.15rem;
140+}
129141
130142 /* ---- Drawer (off-canvas nav) ---- */
131143 .drawer-backdrop {
@@ -161,12 +173,27 @@ body.drawer-open .drawer-backdrop {
161173 }
162174 .drawer nav a,
163175 .drawer .drawer-item {
164- display: block;
176+ display: flex;
177+ align-items: center;
178+ gap: 0.6rem;
179+ width: 100%;
165180 padding: 0.4rem 0.5rem;
166181 border-radius: var(--fb-radius);
167182 color: var(--fb-fg);
183+ font: inherit;
184+ text-align: left;
185+}
186+.drawer .drawer-item {
187+ border: none;
188+ background: none;
189+ cursor: pointer;
190+}
191+.drawer nav a .icon,
192+.drawer .drawer-item .icon {
193+ color: var(--fb-fg-muted);
168194 }
169-.drawer nav a:hover {
195+.drawer nav a:hover,
196+.drawer .drawer-item:hover {
170197 background: var(--fb-bg-inset);
171198 text-decoration: none;
172199 }
@@ -398,6 +425,10 @@ table.tree td {
398425 .tree-icon {
399426 width: 1.5rem;
400427 text-align: center;
428+ color: var(--fb-fg-muted);
429+}
430+.tree-icon .icon {
431+ vertical-align: -0.15em;
401432 }
402433
403434 .blob-header {
@@ -567,16 +598,6 @@ td.diff-empty {
567598 }
568599 }
569600
570-/* ---- Footer ---- */
571-.footer {
572- max-width: 76rem;
573- margin: 2rem auto 1rem;
574- padding: 1rem var(--fb-gap) 0;
575- border-top: 1px solid var(--fb-border);
576- color: var(--fb-fg-muted);
577- font-size: 0.85em;
578-}
579-
580601 /* ---- Responsive: single breakpoint at 48rem ---- */
581602 @media (max-width: 48rem) {
582603 .topbar .tabs {
config.toml +73 −0
@@ -0,0 +1,73 @@
1+[instance]
2+name = "fabrica"
3+url = "http://localhost:8080"
4+description = ""
5+default_branch = "main"
6+allow_anonymous = true
7+
8+[server]
9+address = "0.0.0.0"
10+port = 8080
11+behind_proxy = false
12+graceful_shutdown_secs = 20
13+max_body_bytes = 1048576
14+
15+[ssh]
16+enabled = true
17+address = "0.0.0.0"
18+port = 2222
19+host_key_path = "data/host_key"
20+clone_host = "localhost"
21+clone_port = 22
22+
23+[storage]
24+data_dir = "data/fabrica"
25+repo_dir = "data/repos"
26+
27+[database]
28+url = "sqlite://data/fabrica.db"
29+max_connections = 16
30+
31+[auth]
32+session_ttl_days = 30
33+token_ttl_days = 90
34+invite_ttl_hours = 72
35+cookie_name = "fabrica_session"
36+cookie_secure = true
37+argon2_m_cost = 19456
38+argon2_t_cost = 2
39+argon2_p_cost = 1
40+
41+[mail]
42+backend = "stdout"
43+from = "fabrica@localhost"
44+host = ""
45+port = 587
46+username = ""
47+encryption = "starttls"
48+timeout_secs = 15
49+
50+[ui]
51+theme = "dark"
52+themes_dir = "data/themes"
53+assets_dir = "data/assets"
54+allow_theme_choice = true
55+show_runs = false
56+diff_context_lines = 3
57+max_highlight_bytes = 1048576
58+max_diff_bytes = 5242880
59+tree_history_limit = 1000
60+
61+[git]
62+binary = "git"
63+transport_timeout_secs = 3600
64+max_pack_size_bytes = 536870912
65+
66+[search]
67+concurrency = 4
68+timeout_secs = 10
69+max_results = 200
70+
71+[log]
72+level = "info"
73+format = "pretty"
crates/web/src/icons.rs +137 −0
@@ -0,0 +1,137 @@
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+//! Inline SVG icons.
6+//!
7+//! Vendored from [Lucide](https://lucide.dev) (ISC) for UI glyphs and
8+//! [Simple Icons](https://simpleicons.org) (CC0) for brands, inlined so no page
9+//! makes a network request. Icons inherit `currentColor` and size to `1em`.
10+
11+use maud::{Markup, PreEscaped, html};
12+
13+/// A vendored icon.
14+// The full glyph set is vendored up front; the profile, explore, and settings
15+// views wire in the remaining variants. Removed once every variant is consumed.
16+#[allow(dead_code)]
17+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18+pub enum Icon {
19+ PanelLeft,
20+ House,
21+ Folder,
22+ File,
23+ GitBranch,
24+ Tag,
25+ Search,
26+ Copy,
27+ ChevronRight,
28+ Box,
29+ Link,
30+ User,
31+ Settings,
32+ LogOut,
33+ Upload,
34+ Plus,
35+ Dashboard,
36+ Compass,
37+ MapPin,
38+ Globe,
39+ Download,
40+ AtSign,
41+ Github,
42+ Mastodon,
43+}
44+
45+impl Icon {
46+ /// Whether the icon is a filled brand glyph rather than a stroked Lucide one.
47+ fn brand(self) -> bool {
48+ matches!(self, Icon::Github | Icon::Mastodon)
49+ }
50+
51+ /// The inner SVG geometry.
52+ fn geometry(self) -> &'static str {
53+ match self {
54+ Icon::PanelLeft => {
55+ r#"<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>"#
56+ }
57+ Icon::House => {
58+ r#"<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>"#
59+ }
60+ Icon::Folder => {
61+ r#"<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>"#
62+ }
63+ Icon::File => {
64+ r#"<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/>"#
65+ }
66+ Icon::GitBranch => {
67+ r#"<path d="M15 6a9 9 0 0 0-9 9V3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>"#
68+ }
69+ Icon::Tag => {
70+ r#"<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>"#
71+ }
72+ Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,
73+ Icon::Copy => {
74+ r#"<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"/>"#
75+ }
76+ Icon::ChevronRight => r#"<path d="m9 18 6-6-6-6"/>"#,
77+ Icon::Box => {
78+ r#"<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>"#
79+ }
80+ Icon::Link => {
81+ r#"<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>"#
82+ }
83+ Icon::User => {
84+ r#"<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>"#
85+ }
86+ Icon::Settings => {
87+ r#"<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>"#
88+ }
89+ Icon::LogOut => {
90+ r#"<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>"#
91+ }
92+ Icon::Upload => {
93+ r#"<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>"#
94+ }
95+ Icon::Plus => r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#,
96+ Icon::Dashboard => {
97+ r#"<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>"#
98+ }
99+ Icon::Compass => {
100+ r#"<circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/>"#
101+ }
102+ Icon::MapPin => {
103+ r#"<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>"#
104+ }
105+ Icon::Globe => {
106+ r#"<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>"#
107+ }
108+ Icon::Download => {
109+ r#"<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>"#
110+ }
111+ Icon::AtSign => {
112+ r#"<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>"#
113+ }
114+ Icon::Github => {
115+ r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"#
116+ }
117+ Icon::Mastodon => {
118+ r#"<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>"#
119+ }
120+ }
121+ }
122+}
123+
124+/// Render an icon inheriting `currentColor` and sized to `1em`.
125+#[must_use]
126+pub fn icon(name: Icon) -> Markup {
127+ let brand = name.brand();
128+ html! {
129+ svg class="icon" viewBox="0 0 24 24" width="1em" height="1em"
130+ fill=(if brand { "currentColor" } else { "none" })
131+ stroke=(if brand { "none" } else { "currentColor" })
132+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
133+ aria-hidden="true" focusable="false" {
134+ (PreEscaped(name.geometry()))
135+ }
136+ }
137+}
crates/web/src/layout.rs +14 −22
@@ -2,7 +2,7 @@
22 // License, v. 2.0. If a copy of the MPL was not distributed with this
33 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5-//! The page shell: `<head>`, top bar, off-canvas drawer, theme picker, footer.
5+//! The page shell: `<head>`, top bar, off-canvas drawer, theme picker.
66 //!
77 //! Every full page is wrapped by [`page`]; htmx partials return fragments instead.
88 //! Structure is server-rendered and works without JavaScript — the drawer is a
@@ -13,6 +13,7 @@ use maud::{DOCTYPE, Markup, html};
1313 use model::User;
1414
1515 use crate::assets::{Scheme, ThemeInfo};
16+use crate::icons::{Icon, icon};
1617
1718 /// The per-request chrome data the shell needs.
1819 pub struct Chrome {
@@ -59,7 +60,6 @@ pub fn page(chrome: &Chrome, title: &str, body: Markup) -> Markup {
5960 (drawer(chrome))
6061 div class="drawer-backdrop" {}
6162 main id="main" class="page" { (body) }
62- (footer(chrome))
6363 }
6464 }
6565 }
@@ -70,10 +70,11 @@ 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-controls="drawer" aria-expanded="false" { "☰" }
73+ aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
7474 span class="slug" { a href="/" { (chrome.instance_name) } }
7575 nav class="tabs" aria-label="Primary" {}
76- a class="icon-btn" href="/" aria-label="Home" { "⌂" }
76+ a class="icon-btn" href="/search" aria-label="Search" { (icon(Icon::Search)) }
77+ a class="icon-btn" href="/" aria-label="Home" { (icon(Icon::House)) }
7778 }
7879 }
7980 }
@@ -84,9 +85,12 @@ fn drawer(chrome: &Chrome) -> Markup {
8485 aside id="drawer" class="drawer" aria-label="Navigation" {
8586 h2 { (chrome.instance_name) }
8687 nav {
87- a href="/" { "Home" }
8888 @if let Some(user) = &chrome.user {
89- a href={ "/" (user.username) } { "Your repositories" }
89+ a href="/" { (icon(Icon::Dashboard)) span { "Dashboard" } }
90+ a href="/explore" { (icon(Icon::Compass)) span { "Explore" } }
91+ a href={ "/" (user.username) } { (icon(Icon::User)) span { "Your profile" } }
92+ } @else {
93+ a href="/explore" { (icon(Icon::Compass)) span { "Explore" } }
9094 }
9195 }
9296 hr;
@@ -96,16 +100,15 @@ fn drawer(chrome: &Chrome) -> Markup {
96100 }
97101 nav {
98102 @if chrome.user.is_some() {
99- a href="/settings" { "Settings" }
103+ a href="/settings" { (icon(Icon::Settings)) span { "Settings" } }
100104 form method="post" action="/logout" {
101105 input type="hidden" name="_csrf" value=(chrome.csrf);
102- button class="drawer-item" type="submit"
103- style="width:100%;text-align:left;border:none;background:none;color:inherit;cursor:pointer" {
104- "Log out"
106+ button class="drawer-item" type="submit" {
107+ (icon(Icon::LogOut)) span { "Log out" }
105108 }
106109 }
107110 } @else {
108- a href="/login" { "Sign in" }
111+ a href="/login" { (icon(Icon::User)) span { "Sign in" } }
109112 }
110113 }
111114 }
@@ -131,14 +134,3 @@ fn theme_picker(chrome: &Chrome) -> Markup {
131134 }
132135 }
133136 }
134-
135-/// The page footer.
136-fn footer(chrome: &Chrome) -> Markup {
137- html! {
138- footer class="footer" {
139- span { (chrome.instance_name) }
140- " · "
141- a href="/version" { "version" }
142- }
143- }
144-}
crates/web/src/lib.rs +1 −0
@@ -14,6 +14,7 @@ mod assets;
1414 mod diff;
1515 mod error;
1616 mod git_http;
17+mod icons;
1718 mod layout;
1819 mod markdown;
1920 mod pages;
crates/web/src/repo.rs +7 −6
@@ -24,6 +24,7 @@ use serde::Deserialize;
2424 use crate::AppState;
2525 use crate::diff::{self, RenderedFile};
2626 use crate::error::{AppError, AppResult};
27+use crate::icons::{Icon, icon};
2728 use crate::layout::page;
2829 use crate::markdown;
2930 use crate::pages::build_chrome;
@@ -1096,7 +1097,7 @@ fn tree_table(
10961097 tbody {
10971098 @for entry in entries {
10981099 tr {
1099- td class="tree-icon" { (entry_icon(entry.kind)) }
1100+ td class="tree-icon" { (icon(entry_icon(entry.kind))) }
11001101 td { a href=(href(&entry.name, entry.kind)) { (entry.name) } }
11011102 }
11021103 }
@@ -1106,12 +1107,12 @@ fn tree_table(
11061107 }
11071108
11081109 /// An icon glyph for a tree entry kind.
1109-fn entry_icon(kind: git::EntryKind) -> &'static str {
1110+fn entry_icon(kind: git::EntryKind) -> Icon {
11101111 match kind {
1111- git::EntryKind::Directory => "📁",
1112- git::EntryKind::Submodule => "📦",
1113- git::EntryKind::Symlink => "🔗",
1114- git::EntryKind::File => "📄",
1112+ git::EntryKind::Directory => Icon::Folder,
1113+ git::EntryKind::Submodule => Icon::Box,
1114+ git::EntryKind::Symlink => Icon::Link,
1115+ git::EntryKind::File => Icon::File,
11151116 }
11161117 }
11171118
data/fabrica/secret +1 −1
@@ -0,0 +1 @@
1+3cIBhLUwUan-vB0uok4Ajb2uEJOzapkIB9BwdLB9lXA
1- \ No newline at end of file
data/host_key +7 −0
@@ -0,0 +1,7 @@
1+-----BEGIN OPENSSH PRIVATE KEY-----
2+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
3+QyNTUxOQAAACDtfdqbDHDx719TofjNfw7zjd2rZtQAace+caQQ1fYOPgAAAIgq2mooKtpq
4+KAAAAAtzc2gtZWQyNTUxOQAAACDtfdqbDHDx719TofjNfw7zjd2rZtQAace+caQQ1fYOPg
5+AAAEDpF5CAVCkyKaFFhvhp2OLdmWtIH33H8XFaS8GhVscL5u192psMcPHvX1Oh+M1/DvON
6+3atm1ABpx75xpBDV9g4+AAAAAAECAwQF
7+-----END OPENSSH PRIVATE KEY-----