feat: Flimmer-Branding mit Logo, Icons und Design-Tokens
Logo als SVG in drei Varianten (Icon, Icon+Wortmarke horizontal, monochrom); die Wortmarke ist als Pfad konstruiert und braucht daher keine installierte Schrift. Daraus generiert scripts/generate-icons.ts Favicon (SVG + ICO), PWA-Icons, Maskable-Icons, Apple-Touch-Icon und OG-Bild. Die Farbwelt liegt als CSS-Variablen in globals.css und wird ueber @theme inline an Tailwind durchgereicht — Dark/Light und spaetere Themes bauen darauf auf. Alle Kombinationen erfuellen WCAG AA.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Erzeugt Favicon und PWA-Icons aus den SVG-Quellen unter public/brand/.
|
||||
*
|
||||
* npm run icons
|
||||
*
|
||||
* Quelle ist immer das Vektor-Logo — die Rasterdateien sind reine Ableitungen
|
||||
* und werden bei Bedarf neu erzeugt.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const BRAND = join(ROOT, "public", "brand");
|
||||
const ICONS = join(ROOT, "public", "icons");
|
||||
|
||||
const ICON_SVG = join(BRAND, "flimmer-icon.svg");
|
||||
const MASKABLE_SVG = join(BRAND, "flimmer-icon-maskable.svg");
|
||||
|
||||
/** Rendert ein SVG in ein quadratisches PNG. */
|
||||
async function png(svgPath: string, size: number): Promise<Buffer> {
|
||||
const svg = readFileSync(svgPath);
|
||||
return sharp(svg, { density: Math.max(72, Math.ceil((size / 128) * 96) * 4) })
|
||||
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimaler ICO-Container mit eingebetteten PNGs.
|
||||
* Alle Browser ab IE11 verstehen PNG-in-ICO; das spart eine Abhängigkeit.
|
||||
*/
|
||||
function buildIco(images: { size: number; data: Buffer }[]): Buffer {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); // reserviert
|
||||
header.writeUInt16LE(1, 2); // Typ 1 = Icon
|
||||
header.writeUInt16LE(images.length, 4);
|
||||
|
||||
const entries: Buffer[] = [];
|
||||
let offset = 6 + images.length * 16;
|
||||
|
||||
for (const img of images) {
|
||||
const entry = Buffer.alloc(16);
|
||||
entry.writeUInt8(img.size >= 256 ? 0 : img.size, 0); // Breite (0 = 256)
|
||||
entry.writeUInt8(img.size >= 256 ? 0 : img.size, 1); // Höhe
|
||||
entry.writeUInt8(0, 2); // Farbpalette
|
||||
entry.writeUInt8(0, 3); // reserviert
|
||||
entry.writeUInt16LE(1, 4); // Farbebenen
|
||||
entry.writeUInt16LE(32, 6); // Bits pro Pixel
|
||||
entry.writeUInt32LE(img.data.length, 8);
|
||||
entry.writeUInt32LE(offset, 12);
|
||||
entries.push(entry);
|
||||
offset += img.data.length;
|
||||
}
|
||||
|
||||
return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
|
||||
}
|
||||
|
||||
const PWA_SIZES = [64, 96, 128, 192, 256, 384, 512];
|
||||
const FAVICON_SIZES = [16, 32, 48];
|
||||
|
||||
async function main() {
|
||||
mkdirSync(ICONS, { recursive: true });
|
||||
|
||||
// PWA-Icons (transparenter Rand, „any"-Purpose)
|
||||
for (const size of PWA_SIZES) {
|
||||
const data = await png(ICON_SVG, size);
|
||||
writeFileSync(join(ICONS, `icon-${size}.png`), data);
|
||||
console.log(` icons/icon-${size}.png`);
|
||||
}
|
||||
|
||||
// Maskable (randlose Fläche, Motiv in der Sicherheitszone)
|
||||
for (const size of [192, 512]) {
|
||||
const data = await png(MASKABLE_SVG, size);
|
||||
writeFileSync(join(ICONS, `maskable-${size}.png`), data);
|
||||
console.log(` icons/maskable-${size}.png`);
|
||||
}
|
||||
|
||||
// Apple Touch Icon — iOS mag keine Transparenz, deshalb deckend
|
||||
const apple = await sharp(readFileSync(MASKABLE_SVG), { density: 576 })
|
||||
.resize(180, 180)
|
||||
.flatten({ background: "#12101d" })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
writeFileSync(join(ICONS, "apple-touch-icon.png"), apple);
|
||||
console.log(" icons/apple-touch-icon.png");
|
||||
|
||||
// Favicon: SVG (scharf in jeder Größe) + ICO (Fallback, Lesezeichenleisten)
|
||||
writeFileSync(join(ROOT, "public", "favicon.svg"), readFileSync(ICON_SVG));
|
||||
console.log(" favicon.svg");
|
||||
|
||||
const icoImages = [];
|
||||
for (const size of FAVICON_SIZES) {
|
||||
icoImages.push({ size, data: await png(ICON_SVG, size) });
|
||||
}
|
||||
writeFileSync(join(ROOT, "public", "favicon.ico"), buildIco(icoImages));
|
||||
console.log(" favicon.ico");
|
||||
|
||||
// Open-Graph-Bild für geteilte Links
|
||||
const og = await sharp({
|
||||
create: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
channels: 4,
|
||||
background: { r: 12, g: 10, b: 20, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.composite([
|
||||
{
|
||||
input: await sharp(readFileSync(join(BRAND, "flimmer-logo.svg")), { density: 300 })
|
||||
.resize({ width: 760 })
|
||||
.png()
|
||||
.toBuffer(),
|
||||
gravity: "centre",
|
||||
},
|
||||
])
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
writeFileSync(join(ROOT, "public", "og-image.png"), og);
|
||||
console.log(" og-image.png");
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => console.log("Icons erzeugt."),
|
||||
(err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user