irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

Vault-door logo: green keyed out, favicons, touch icon, manifest, default preview image

huncholanehuncholaneauthored
parent 649719ecommit 520a1d6d75ae9c66651892a11ffefaeb28c490e0Browse files

14 files changed, +119 -19

+4-5backend/src/web/layout.rs
@@ -133,9 +133,8 @@ fn head_markup(
133133 meta property="og:site_name" content="irongit";
134134 meta property="og:url" content={ (base) (path) };
135135 link rel="canonical" href={ (base) (path) };
136- @if let Some(image) = og_image {
137- meta property="og:image" content=(image);
138- }
136+ // The logo stands in for pages without their own preview image.
137+ meta property="og:image" content=(og_image.map(str::to_string).unwrap_or_else(|| format!("{base}/logo.png")));
139138 @if noindex {
140139 meta name="robots" content="noindex";
141140 }
@@ -154,7 +153,7 @@ fn nav_markup(viewer: Option<&Viewer>, path: &str) -> Markup {
154153 header class="border-b border-edge bg-surface-raised" {
155154 div class="mx-auto flex h-11 max-w-[1280px] items-center gap-3 px-4" {
156155 a href="/" class="flex items-center gap-2 font-semibold text-ink no-underline hover:no-underline" {
157- (ui::logo(20))
156+ (ui::logo(24))
158157 span class="tracking-tight" { "irongit" }
159158 }
160159 nav class="flex items-center gap-0.5 text-[13px]" {
@@ -209,7 +208,7 @@ pub fn error_page(status: StatusCode, message: &str) -> Response {
209208 Some(shell) => fill(
210209 &shell,
211210 &html! { title { (title) " · irongit" } meta name="robots" content="noindex"; }.0,
212- &html! { header class="border-b border-edge bg-surface-raised" { div class="mx-auto flex h-11 max-w-[1280px] items-center gap-2 px-4" { a href="/" class="flex items-center gap-2 font-semibold text-ink no-underline" { (ui::logo(20)) "irongit" } } } }.0,
211+ &html! { header class="border-b border-edge bg-surface-raised" { div class="mx-auto flex h-11 max-w-[1280px] items-center gap-2 px-4" { a href="/" class="flex items-center gap-2 font-semibold text-ink no-underline" { (ui::logo(24)) "irongit" } } } }.0,
213212 Some(&body.0),
214213 ),
215214 None => html! { (DOCTYPE) html { body { (body) } } }.0,
+1-1backend/src/web/profile.rs
@@ -36,7 +36,7 @@ pub struct ProfileQuery {
3636
3737 pub async fn show(ctx: Ctx, Path(name): Path<String>, Query(query): Query<ProfileQuery>, request: Request) -> AppResult<Response> {
3838 let Some(account) = Account::by_name(&ctx.state.db, &name).await? else {
39- // Not an account: maybe a static file such as /favicon.svg.
39+ // Not an account: maybe a static file such as /favicon.ico.
4040 return Ok(frontend::serve(axum::extract::State(ctx.state.clone()), crate::auth::MaybeViewer(ctx.viewer.clone()), request.uri().clone()).await);
4141 };
4242 if account.name != name {
+3-6backend/src/web/ui.rs
@@ -4,14 +4,11 @@ use chrono::{DateTime, Utc};
44 use maud::{Markup, PreEscaped, html};
55 use sha2::{Digest, Sha256};
66
7+/// The vault-door mark (frontend/public/logo-128.png, built by
8+/// scripts/gen-brand.py), sharp up to about 40 px on 3x screens.
79 pub fn logo(size: u32) -> Markup {
810 html! {
9- svg width=(size) height=(size) viewBox="0 0 32 32" aria-hidden="true" {
10- rect width="32" height="32" rx="4" fill="#0b0f16" {}
11- rect x="6" y="20" width="20" height="5" rx="1" fill="#93a0b4" {}
12- rect x="10" y="15" width="12" height="5" fill="#93a0b4" {}
13- rect x="13" y="6" width="6" height="9" fill="#f07a1a" {}
14- }
11+ img src="/logo-128.png" width=(size) height=(size) alt="" aria-hidden="true" class="shrink-0" style={ "width:" (size) "px;height:" (size) "px" };
1512 }
1613 }
1714
+0-0brand/logo-source.jpg

Binary file changed.

+0-0frontend/public/apple-touch-icon.png

Binary file changed.

+0-0frontend/public/favicon-32.png

Binary file changed.

+0-0frontend/public/favicon.ico

Binary file changed.

+0-6frontend/public/favicon.svg
@@ -1,6 +0,0 @@
1-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2- <rect width="32" height="32" rx="4" fill="#0b0f16"/>
3- <rect x="6" y="20" width="20" height="5" rx="1" fill="#93a0b4"/>
4- <rect x="10" y="15" width="12" height="5" fill="#93a0b4"/>
5- <rect x="13" y="6" width="6" height="9" fill="#f07a1a"/>
6-</svg>
+0-0frontend/public/icon-192.png

Binary file changed.

+0-0frontend/public/logo-128.png

Binary file changed.

+0-0frontend/public/logo.png

Binary file changed.

+13-0frontend/public/manifest.webmanifest
@@ -0,0 +1,13 @@
1+{
2+ "name": "irongit",
3+ "short_name": "irongit",
4+ "description": "Git hosting and a container registry.",
5+ "start_url": "/",
6+ "display": "browser",
7+ "background_color": "#0b0f16",
8+ "theme_color": "#0b0f16",
9+ "icons": [
10+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
11+ { "src": "/logo.png", "sizes": "512x512", "type": "image/png" }
12+ ]
13+}
+5-1frontend/src/layouts/Layout.astro
@@ -22,7 +22,11 @@ const { title, description, contained = true } = Astro.props;
2222 <head>
2323 <meta charset="utf-8" />
2424 <meta name="viewport" content="width=device-width, initial-scale=1" />
25- <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
25+ <link rel="icon" href="/favicon.ico" sizes="48x48" />
26+ <link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
27+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
28+ <link rel="manifest" href="/manifest.webmanifest" />
29+ <meta name="theme-color" content="#0b0f16" />
2630 <script is:inline>
2731 try {
2832 const theme = localStorage.getItem("ig-theme");
+93-0scripts/gen-brand.py
@@ -0,0 +1,93 @@
1+#!/usr/bin/env python3
2+"""Builds the irongit icons from brand/logo-source.jpg (the vault door on a
3+flat green background).
4+
5+The background is keyed out by how green each pixel is; edge pixels are
6+unmixed (P = a*F + (1-a)*G solved for a and F) so the outline keeps its real
7+colour instead of a green fringe. Output goes to frontend/public:
8+
9+ logo.png 512 px, transparent, for pages and Open Graph
10+ logo-128.png the nav and auth pages (crisp up to 3x at ~40 px)
11+ icon-192.png web app manifest sizes
12+ apple-touch-icon.png 180 px on the site's dark surface (iOS ignores alpha)
13+ favicon.ico 16, 32 and 48 px
14+ favicon-32.png modern browsers
15+
16+ python3 -I scripts/gen-brand.py
17+"""
18+
19+import sys
20+from pathlib import Path
21+
22+import numpy as np
23+from PIL import Image
24+
25+ROOT = Path(__file__).resolve().parent.parent
26+SOURCE = ROOT / "brand" / "logo-source.jpg"
27+OUT = ROOT / "frontend" / "public"
28+SURFACE = (11, 15, 22) # --color-surface, dark theme
29+
30+
31+def key_out_green(path: Path) -> Image.Image:
32+ rgb = np.asarray(Image.open(path).convert("RGB")).astype(np.float64)
33+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
34+
35+ # Background colour from the corners, which are always background here.
36+ edge = 60
37+ corners = np.concatenate([rgb[:edge, :edge], rgb[:edge, -edge:], rgb[-edge:, :edge], rgb[-edge:, -edge:]]).reshape(-1, 3)
38+ bg = np.median(corners, axis=0)
39+ bg_green = bg[1] - max(bg[0], bg[2])
40+
41+ # Greenness: 0 for the neutral greys of the vault, bg_green for background.
42+ greenness = g - np.maximum(r, b)
43+ lo, hi = 0.12 * bg_green, 0.88 * bg_green
44+ alpha = np.clip((hi - greenness) / (hi - lo), 0.0, 1.0)
45+
46+ # Unmix the background out of partially covered pixels.
47+ a = alpha[..., None]
48+ fg = np.where(a > 0.02, (rgb - (1.0 - a) * bg) / np.maximum(a, 1e-3), 0.0)
49+ fg = np.clip(fg, 0, 255)
50+ # The artwork is neutral; remove any green spill left in the edge colours.
51+ spill_cap = np.maximum(fg[..., 0], fg[..., 2]) + 3
52+ fg[..., 1] = np.minimum(fg[..., 1], spill_cap)
53+
54+ rgba = np.dstack([fg, alpha * 255]).round().astype(np.uint8)
55+ image = Image.fromarray(rgba, "RGBA")
56+
57+ # Crop to the ink and square it up with transparent padding.
58+ ys, xs = np.where(alpha > 0.5)
59+ x0, x1, y0, y1 = xs.min(), xs.max() + 1, ys.min(), ys.max() + 1
60+ image = image.crop((x0, y0, x1, y1))
61+ side = max(image.size)
62+ pad = round(side * 0.02)
63+ square = Image.new("RGBA", (side + 2 * pad, side + 2 * pad), (0, 0, 0, 0))
64+ square.paste(image, ((square.width - image.width) // 2, (square.height - image.height) // 2), image)
65+ print(f"background {bg.round().astype(int).tolist()}, artwork {x1 - x0}x{y1 - y0}, square {square.width}px")
66+ return square
67+
68+
69+def resized(image: Image.Image, size: int) -> Image.Image:
70+ return image.resize((size, size), Image.Resampling.LANCZOS)
71+
72+
73+def main() -> int:
74+ master = key_out_green(SOURCE)
75+ OUT.mkdir(parents=True, exist_ok=True)
76+ resized(master, 512).save(OUT / "logo.png", optimize=True)
77+ resized(master, 128).save(OUT / "logo-128.png", optimize=True)
78+ resized(master, 192).save(OUT / "icon-192.png", optimize=True)
79+ resized(master, 32).save(OUT / "favicon-32.png", optimize=True)
80+
81+ touch = Image.new("RGBA", (180, 180), SURFACE + (255,))
82+ inner = resized(master, 152)
83+ touch.paste(inner, (14, 14), inner)
84+ touch.convert("RGB").save(OUT / "apple-touch-icon.png", optimize=True)
85+
86+ resized(master, 256).save(OUT / "favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
87+ for name in ["logo.png", "logo-128.png", "icon-192.png", "favicon-32.png", "apple-touch-icon.png", "favicon.ico"]:
88+ print(f" {name:22} {(OUT / name).stat().st_size:>7} bytes")
89+ return 0
90+
91+
92+if __name__ == "__main__":
93+ sys.exit(main())