feat(marke): Logo, Favicon und PWA-Icons per Skript (#002)

Die Bildmarke liegt als SVG in drei Varianten (dunkel, hell, mono) vor.
Rasterdateien erzeugt scripts/icons.mjs mit sharp und sie werden
eingecheckt, damit Build und Laufzeitabbild kein sharp brauchen. Unter
32 px dient eine reduzierte Marke ohne Zahlenring und Bolzen, weil die
Details dort zu Rauschen verschwimmen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:09:07 +02:00
co-authored by Claude Opus 5.5
parent 224386106b
commit a11ae7eef3
14 changed files with 160 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="7" fill="none" stroke="#E8ECEF" stroke-width="3"/>
<circle cx="16" cy="13.5" r="4.6" fill="#F0B43C"/>
<path d="M13.8 15.5 L12 23.5 H20 L18.2 15.5 Z" fill="#F0B43C"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#14181C" stroke-width="3.5"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#14181C" stroke-opacity="0.25" stroke-width="2" stroke-dasharray="3 3.2"/>
<circle cx="13.5" cy="13.5" r="2.2" fill="#14181C"/>
<circle cx="50.5" cy="13.5" r="2.2" fill="#14181C"/>
<circle cx="13.5" cy="50.5" r="2.2" fill="#14181C"/>
<circle cx="50.5" cy="50.5" r="2.2" fill="#14181C"/>
<circle cx="32" cy="27" r="7.5" fill="#B9780F"/>
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#B9780F"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
<rect x="2.25" y="2.25" width="59.5" height="59.5" rx="15.75" fill="#F0B43C"/>
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#0D1013" stroke-width="3.5"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#0D1013" stroke-opacity="0.28" stroke-width="2" stroke-dasharray="3 3.2"/>
<circle cx="13.5" cy="13.5" r="2.2" fill="#0D1013"/>
<circle cx="50.5" cy="13.5" r="2.2" fill="#0D1013"/>
<circle cx="13.5" cy="50.5" r="2.2" fill="#0D1013"/>
<circle cx="50.5" cy="50.5" r="2.2" fill="#0D1013"/>
<circle cx="32" cy="27" r="7.5" fill="#0D1013"/>
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#0D1013"/>
</svg>

After

Width:  |  Height:  |  Size: 753 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SSHKeyVault">
<rect x="4" y="4" width="56" height="56" rx="14" fill="none" stroke="#E8ECEF" stroke-width="3.5"/>
<circle cx="32" cy="32" r="19" fill="none" stroke="#E8ECEF" stroke-opacity="0.28" stroke-width="2" stroke-dasharray="3 3.2"/>
<circle cx="13.5" cy="13.5" r="2.2" fill="#E8ECEF"/>
<circle cx="50.5" cy="13.5" r="2.2" fill="#E8ECEF"/>
<circle cx="13.5" cy="50.5" r="2.2" fill="#E8ECEF"/>
<circle cx="50.5" cy="50.5" r="2.2" fill="#E8ECEF"/>
<circle cx="32" cy="27" r="7.5" fill="#F0B43C"/>
<path d="M28.6 30.5 L25.5 45 H38.5 L35.4 30.5 Z" fill="#F0B43C"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+99
View File
@@ -0,0 +1,99 @@
// Erzeugt alle Raster-Icons aus den SVG-Vorlagen in public/brand/ (#002).
//
// Die PNGs und das favicon.ico werden eingecheckt, damit Build und Docker-Abbild
// kein sharp zur Laufzeit brauchen. Nach jeder Logo-Aenderung: `pnpm icons`.
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
const BRAND = join(WURZEL, "public/brand");
const ICONS = join(WURZEL, "public/icons");
const APP = join(WURZEL, "src/app");
// Seitenhintergrund (--bg); App-Icons stehen auf dieser Flaeche statt transparent,
// weil iOS und Android transparente Bereiche sonst schwarz oder weiss fuellen.
const HINTERGRUND = "#0D1013";
/** Rendert ein SVG scharf in der Zielgroesse (density skaliert die Vektoren statt Pixel). */
async function rendern(svg, groesse) {
return sharp(svg, { density: Math.ceil((72 * groesse) / 32) * 2 })
.resize(groesse, groesse)
.png()
.toBuffer();
}
/** Logo mittig auf --bg mit Rand, damit es auf Homescreens nicht an die Kante stoesst. */
async function aufFlaeche(svg, groesse, anteil) {
const innen = Math.round(groesse * anteil);
const logo = await rendern(svg, innen);
return sharp({
create: { width: groesse, height: groesse, channels: 4, background: HINTERGRUND },
})
.composite([{ input: logo, gravity: "center" }])
.png()
.toBuffer();
}
/** Minimaler ICO-Container mit PNG-Eintraegen (seit Windows Vista von allen Browsern gelesen). */
function icoBauen(bilder) {
const kopf = Buffer.alloc(6);
kopf.writeUInt16LE(0, 0);
kopf.writeUInt16LE(1, 2);
kopf.writeUInt16LE(bilder.length, 4);
const eintraege = [];
let versatz = 6 + 16 * bilder.length;
for (const { groesse, daten } of bilder) {
const e = Buffer.alloc(16);
e.writeUInt8(groesse >= 256 ? 0 : groesse, 0);
e.writeUInt8(groesse >= 256 ? 0 : groesse, 1);
e.writeUInt8(0, 2);
e.writeUInt8(0, 3);
e.writeUInt16LE(1, 4);
e.writeUInt16LE(32, 6);
e.writeUInt32LE(daten.length, 8);
e.writeUInt32LE(versatz, 12);
versatz += daten.length;
eintraege.push(e);
}
return Buffer.concat([kopf, ...eintraege, ...bilder.map((b) => b.daten)]);
}
async function main() {
await mkdir(ICONS, { recursive: true });
const logo = await readFile(join(BRAND, "logo.svg"));
// Unter 32 px wird die reduzierte Variante ohne Zahlenring und Bolzen genutzt,
// sonst verschwimmen die Details zu grauem Rauschen.
const favicon = await readFile(join(BRAND, "favicon.svg"));
const icon192 = await aufFlaeche(logo, 192, 0.72);
const icon512 = await aufFlaeche(logo, 512, 0.72);
const apple = await aufFlaeche(logo, 180, 0.72);
const fav32 = await rendern(favicon, 32);
const fav16 = await rendern(favicon, 16);
const fav48 = await rendern(favicon, 48);
await writeFile(join(ICONS, "icon-192.png"), icon192);
await writeFile(join(ICONS, "icon-512.png"), icon512);
await writeFile(join(ICONS, "apple-touch-icon.png"), apple);
await writeFile(join(ICONS, "favicon-32.png"), fav32);
await writeFile(join(ICONS, "favicon-16.png"), fav16);
// Next-Metadaten-Dateien: favicon.ico, icon.svg und apple-icon.png in src/app
// erzeugen die <link>-Tags automatisch.
await writeFile(
join(APP, "favicon.ico"),
icoBauen([
{ groesse: 16, daten: fav16 },
{ groesse: 32, daten: fav32 },
{ groesse: 48, daten: fav48 },
]),
);
await copyFile(join(BRAND, "favicon.svg"), join(APP, "icon.svg"));
await writeFile(join(APP, "apple-icon.png"), apple);
console.log("Icons erzeugt: public/icons/*, src/app/favicon.ico, src/app/icon.svg, src/app/apple-icon.png");
}
await main();
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="7" fill="none" stroke="#E8ECEF" stroke-width="3"/>
<circle cx="16" cy="13.5" r="4.6" fill="#F0B43C"/>
<path d="M13.8 15.5 L12 23.5 H20 L18.2 15.5 Z" fill="#F0B43C"/>
</svg>

After

Width:  |  Height:  |  Size: 285 B

+20
View File
@@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
/** Web-App-Manifest; die Icons erzeugt scripts/icons.mjs (#002). */
export default function manifest(): MetadataRoute.Manifest {
return {
name: "SSHKeyVault",
short_name: "sshkv",
description: "Zentrale Verwaltung von SSH-Schlüsseln",
lang: "de",
start_url: "/",
display: "standalone",
background_color: "#0D1013",
theme_color: "#0D1013",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/brand/favicon.svg", sizes: "any", type: "image/svg+xml" },
],
};
}