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
14 files changed, +119 -19
+4-5backend/src/web/layout.rs
| @@ -133,9 +133,8 @@ fn head_markup( | ||
| 133 | 133 | meta property="og:site_name" content="irongit"; |
| 134 | 134 | meta property="og:url" content={ (base) (path) }; |
| 135 | 135 | 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"))); | |
| 139 | 138 | @if noindex { |
| 140 | 139 | meta name="robots" content="noindex"; |
| 141 | 140 | } |
| @@ -154,7 +153,7 @@ fn nav_markup(viewer: Option<&Viewer>, path: &str) -> Markup { | ||
| 154 | 153 | header class="border-b border-edge bg-surface-raised" { |
| 155 | 154 | div class="mx-auto flex h-11 max-w-[1280px] items-center gap-3 px-4" { |
| 156 | 155 | 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)) | |
| 158 | 157 | span class="tracking-tight" { "irongit" } |
| 159 | 158 | } |
| 160 | 159 | nav class="flex items-center gap-0.5 text-[13px]" { |
| @@ -209,7 +208,7 @@ pub fn error_page(status: StatusCode, message: &str) -> Response { | ||
| 209 | 208 | Some(shell) => fill( |
| 210 | 209 | &shell, |
| 211 | 210 | &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, | |
| 213 | 212 | Some(&body.0), |
| 214 | 213 | ), |
| 215 | 214 | None => html! { (DOCTYPE) html { body { (body) } } }.0, |
+1-1backend/src/web/profile.rs
| @@ -36,7 +36,7 @@ pub struct ProfileQuery { | ||
| 36 | 36 | |
| 37 | 37 | pub async fn show(ctx: Ctx, Path(name): Path<String>, Query(query): Query<ProfileQuery>, request: Request) -> AppResult<Response> { |
| 38 | 38 | 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. | |
| 40 | 40 | return Ok(frontend::serve(axum::extract::State(ctx.state.clone()), crate::auth::MaybeViewer(ctx.viewer.clone()), request.uri().clone()).await); |
| 41 | 41 | }; |
| 42 | 42 | if account.name != name { |
+3-6backend/src/web/ui.rs
| @@ -4,14 +4,11 @@ use chrono::{DateTime, Utc}; | ||
| 4 | 4 | use maud::{Markup, PreEscaped, html}; |
| 5 | 5 | use sha2::{Digest, Sha256}; |
| 6 | 6 | |
| 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. | |
| 7 | 9 | pub fn logo(size: u32) -> Markup { |
| 8 | 10 | 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" }; | |
| 15 | 12 | } |
| 16 | 13 | } |
| 17 | 14 |
+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; | ||
| 22 | 22 | <head> |
| 23 | 23 | <meta charset="utf-8" /> |
| 24 | 24 | <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" /> | |
| 26 | 30 | <script is:inline> |
| 27 | 31 | try { |
| 28 | 32 | 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()) |