From cf991fcb50344a5d7ded6cd6b704119e4334e422 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:40:37 +0100 Subject: [PATCH 1/2] Add the tools that generate the icons, social image and hero images --- README.md | 7 ++++ tools/hero-images/README.md | 25 ++++++++++++++ tools/hero-images/hero.ts | 18 +++++++++++ tools/icons/README.md | 30 +++++++++++++++++ tools/icons/icons.ts | 63 ++++++++++++++++++++++++++++++++++++ tools/social-image/README.md | 27 ++++++++++++++++ tools/social-image/og.html | 49 ++++++++++++++++++++++++++++ tools/social-image/render.ts | 54 +++++++++++++++++++++++++++++++ 8 files changed, 273 insertions(+) create mode 100644 tools/hero-images/README.md create mode 100644 tools/hero-images/hero.ts create mode 100644 tools/icons/README.md create mode 100644 tools/icons/icons.ts create mode 100644 tools/social-image/README.md create mode 100644 tools/social-image/og.html create mode 100644 tools/social-image/render.ts diff --git a/README.md b/README.md index 9e8112e..777e076 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,11 @@ The landing page at [schuly.dev](https://schuly.dev), in six languages: German, `bun install`, then `bun start` for or `bun run build` for production. [Its docs](https://docs.schuly.dev/SchulyWebsite/) cover the rest. +## Regenerating images + +- [`tools/icons`](tools/icons): favicon, touch and manifest icons. +- [`tools/social-image`](tools/social-image): the `og-image` link preview. +- [`tools/hero-images`](tools/hero-images): the two hero screenshots. +- The framed phone screenshots (`public/assets/schuly-*-page-v2.*`) come from [`tools/store-screenshots`](https://github.com/schulydev/Schuly/tree/main/tools/store-screenshots) in the Schuly app repo. + [PolyForm Noncommercial 1.0.0](LICENSE) diff --git a/tools/hero-images/README.md b/tools/hero-images/README.md new file mode 100644 index 0000000..332368a --- /dev/null +++ b/tools/hero-images/README.md @@ -0,0 +1,25 @@ +# Hero images + +Makes the two screenshots in the hero section: +- `public/assets/webapp-overview-dark.webp` (1424×890) shows the web app inside the laptop frame. It is also the screenshot in the [social image](../social-image). +- `public/assets/hero-phone-home.webp` (600×1333) shows the app's home screen on the phone beside it. + +This script only crops and converts them. The screenshots themselves come from the other repos: +- **Web app:** `src/Schuly.Frontend/public/screens/overview-dark.png` in SchulyServer, made with its `tools/screenshots`. +- **Phone:** the unframed home screenshot (`home-dark.png`, 1080×2400) from `tools/store-screenshots` in the Schuly repo. The framed phone images in the screenshots section come from the same tool. + +## Prerequisites + +Bun, and `bun install` in the repo root (sharp is a dev dependency). + +## Regenerate + +```sh +bun tools/hero-images/hero.ts --web ../SchulyServer/src/Schuly.Frontend/public/screens/overview-dark.png --phone ../Schuly/tools/store-screenshots/out/raw/home-dark.png +``` + +Point `--web` and `--phone` at wherever those files are; either one can be left out. It writes to `public/assets/` by default, or to `--out `. + +## Licences + +Both screenshots show made-up data only (Mia Muster, Berufsfachschule Beispielstadt). diff --git a/tools/hero-images/hero.ts b/tools/hero-images/hero.ts new file mode 100644 index 0000000..0c53748 --- /dev/null +++ b/tools/hero-images/hero.ts @@ -0,0 +1,18 @@ +// Converts the two hero screenshots into the website's webp files. +// Usage: bun tools/hero-images/hero.ts --web --phone [--out ] +// --web SchulyServer's src/Schuly.Frontend/public/screens/overview-dark.png (1440x900) +// --phone an unframed 1080x2400 home screenshot from the Schuly repo's tools/store-screenshots +import { join, resolve } from 'node:path'; +import sharp from 'sharp'; + +const root = resolve(import.meta.dir, '../..'); +const arg = (name: string) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; }; +const out = resolve(arg('--out') ?? join(root, 'public/assets')); +const web = arg('--web'); +const phone = arg('--phone'); +if (!web && !phone) throw new Error('Pass --web , --phone or both.'); + +// Crop 16 px off the right edge (the page scrollbar) and 10 px off the bottom for a 16:10 laptop screen. +if (web) await sharp(web).extract({ left: 0, top: 0, width: 1424, height: 890 }).webp({ quality: 84 }).toFile(join(out, 'webapp-overview-dark.webp')); +if (phone) await sharp(phone).resize(600).webp({ quality: 84 }).toFile(join(out, 'hero-phone-home.webp')); +console.log(`Wrote ${[web && 'webapp-overview-dark.webp', phone && 'hero-phone-home.webp'].filter(Boolean).join(' and ')} to ${out}`); diff --git a/tools/icons/README.md b/tools/icons/README.md new file mode 100644 index 0000000..6fae8e8 --- /dev/null +++ b/tools/icons/README.md @@ -0,0 +1,30 @@ +# Icons + +Builds the website's icon set from `public/assets/app_icon.png` (849×845, the same artwork as the app icon in the Schuly repo). + +| Output | Size | How | +|---|---|---| +| `public/assets/app_icon_512.png` | 512×512 | padded to a square on transparent (the source isn't square), palette PNG | +| `public/assets/app_icon_64.png` | 64×64 | header, footer and download card | +| `public/assets/apple-touch-icon.png` | 180×180 | flattened on the site background `#07080a`, because iOS doesn't show transparency | +| `public/assets/icon-192.png` | 192×192 | manifest `any` | +| `public/assets/icon-maskable-512.png` | 512×512 | icon at 384 px on `#07080a`, inside the 80% safe zone, for the manifest `maskable` purpose | +| `public/favicon.ico` | 48, 32, 16 | 32-bit BMP entries | + +## Prerequisites + +Bun, and `bun install` in the repo root (sharp is a dev dependency). + +## Regenerate + +```sh +bun tools/icons/icons.ts +``` + +The options are `--src ` for another source image, `--out ` for the PNGs and `--favicon ` for the `.ico`. After you change the icon, also run [`../social-image`](../social-image), because the social image shows `app_icon_512.png`. + +Note: the `favicon.ico` in the repo is still Angular's default logo from the first commit. Running this script replaces it with the Schuly icon. + +## Licences + +The icon is Schuly's own artwork; the repo's licence applies. diff --git a/tools/icons/icons.ts b/tools/icons/icons.ts new file mode 100644 index 0000000..7b3fb13 --- /dev/null +++ b/tools/icons/icons.ts @@ -0,0 +1,63 @@ +// Builds the website's icon set from public/assets/app_icon.png. +// Usage: bun tools/icons/icons.ts [--src ] [--out ] [--favicon ] +import { writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import sharp from 'sharp'; + +const root = resolve(import.meta.dir, '../..'); +const arg = (name: string) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; }; +const src = resolve(arg('--src') ?? join(root, 'public/assets/app_icon.png')); +const out = resolve(arg('--out') ?? join(root, 'public/assets')); +const favicon = resolve(arg('--favicon') ?? join(root, 'public/favicon.ico')); +const BG = '#07080a'; // the site's background and manifest theme_color +const png = (quality: number) => ({ palette: true, quality, compressionLevel: 9, effort: 10 }) as const; + +// app_icon.png is 849x845, so the square sizes either pad it (contain) or crop the 2 px difference (cover, sharp's default). +await sharp(src).resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png(png(90)).toFile(join(out, 'app_icon_512.png')); +await sharp(src).resize(64).png().toFile(join(out, 'app_icon_64.png')); +await sharp(src).resize(180, 180).flatten({ background: BG }).png(png(95)).toFile(join(out, 'apple-touch-icon.png')); +await sharp(src).resize(192, 192).png(png(95)).toFile(join(out, 'icon-192.png')); + +// Maskable: the icon at 384 px (75%) on a dark square, inside the 80% safe zone. +const inner = await sharp(src).resize(384, 384).toBuffer(); +await sharp({ create: { width: 512, height: 512, channels: 4, background: BG } }).composite([{ input: inner, gravity: 'center' }]).png(png(95)).toFile(join(out, 'icon-maskable-512.png')); + +// favicon.ico: 48, 32 and 16 px as 32-bit BMP entries with an empty AND mask, the layout of the original file. +const sizes = [48, 32, 16]; +const entries: Buffer[] = []; +for (const s of sizes) { + const { data } = await sharp(src).resize(s, s, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const header = Buffer.alloc(40); + header.writeUInt32LE(40, 0); + header.writeInt32LE(s, 4); + header.writeInt32LE(s * 2, 8); // XOR image + AND mask + header.writeUInt16LE(1, 12); + header.writeUInt16LE(32, 14); + const pixels = Buffer.alloc(s * s * 4); + for (let y = 0; y < s; y++) { + for (let x = 0; x < s; x++) { + const i = (y * s + x) * 4; + const o = ((s - 1 - y) * s + x) * 4; // bottom-up rows, BGRA + pixels[o] = data[i + 2]; pixels[o + 1] = data[i + 1]; pixels[o + 2] = data[i]; pixels[o + 3] = data[i + 3]; + } + } + const mask = Buffer.alloc((Math.ceil(s / 32) * 4) * s); + entries.push(Buffer.concat([header, pixels, mask])); +} +const dir = Buffer.alloc(6 + 16 * sizes.length); +dir.writeUInt16LE(1, 2); +dir.writeUInt16LE(sizes.length, 4); +let offset = dir.length; +sizes.forEach((s, i) => { + const e = 6 + 16 * i; + dir.writeUInt8(s, e); + dir.writeUInt8(s, e + 1); + dir.writeUInt16LE(1, e + 4); + dir.writeUInt16LE(32, e + 6); + dir.writeUInt32LE(entries[i].length, e + 8); + dir.writeUInt32LE(offset, e + 12); + offset += entries[i].length; +}); +writeFileSync(favicon, Buffer.concat([dir, ...entries])); + +console.log(`Wrote app_icon_512, app_icon_64, apple-touch-icon, icon-192, icon-maskable-512 to ${out} and ${favicon}`); diff --git a/tools/social-image/README.md b/tools/social-image/README.md new file mode 100644 index 0000000..187ee9a --- /dev/null +++ b/tools/social-image/README.md @@ -0,0 +1,27 @@ +# Social image + +Makes the 1200×630 link preview `public/assets/og-image.png` and `og-image.webp`. + +`og.html` is the template. `render.ts` serves it on top of `public/`, so it can load the Inter font and two assets from there: +- `public/assets/app_icon_512.png`, from [`../icons`](../icons); +- `public/assets/webapp-overview-dark.webp`, from [`../hero-images`](../hero-images). + +It then screenshots the page with your installed Chrome or Edge in headless mode and compresses the result with sharp. + +## Prerequisites + +- Bun, and `bun install` in the repo root (sharp is a dev dependency). +- Google Chrome or Microsoft Edge. Nothing is downloaded. The script looks in the usual install folders; otherwise pass `--browser ` or set `CHROME_PATH`. + +## Regenerate + +```sh +bun tools/social-image/render.ts +``` + +It writes to `public/assets/` by default; `--out ` writes somewhere else. To change the text or layout, edit `og.html`. Opening it straight from disk won't load the font or the images, so render it to check. + +## Licences + +- The font is Inter (SIL Open Font License 1.1), the same file the site uses. +- The screenshot inside shows made-up data only (Mia Muster). diff --git a/tools/social-image/og.html b/tools/social-image/og.html new file mode 100644 index 0000000..dff5f3f --- /dev/null +++ b/tools/social-image/og.html @@ -0,0 +1,49 @@ + + + + + + + +
+
Schuly
+

Alle deine Schulen,in einer App.

+

Noten, Absenzen und Stundenplan aus Schulnetz und OdA Org. Android, iOS und Web.

+
+
+ + diff --git a/tools/social-image/render.ts b/tools/social-image/render.ts new file mode 100644 index 0000000..1bffa3a --- /dev/null +++ b/tools/social-image/render.ts @@ -0,0 +1,54 @@ +// Renders og.html (1200x630) with the installed Chrome or Edge and writes public/assets/og-image.png and .webp. +// Usage: bun tools/social-image/render.ts [--out ] [--browser ] +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import sharp from 'sharp'; + +const root = resolve(import.meta.dir, '../..'); +const arg = (name: string) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; }; +const outDir = resolve(arg('--out') ?? join(root, 'public/assets')); + +const candidates = [ + arg('--browser'), + process.env.CHROME_PATH, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + 'C:/Program Files/Microsoft/Edge/Application/msedge.exe', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/microsoft-edge', +]; +const browser = candidates.find((p) => p && existsSync(p)); +if (!browser) throw new Error('No Chrome or Edge found. Pass --browser or set CHROME_PATH.'); + +// og.html loads /fonts/... and /assets/... from public/, so serve public/ with the template on top. +const template = join(import.meta.dir, 'og.html'); +const server = Bun.serve({ + port: 0, + async fetch(req) { + const path = decodeURIComponent(new URL(req.url).pathname); + if (path === '/og.html') return new Response(Bun.file(template), { headers: { 'content-type': 'text/html; charset=utf-8' } }); + const file = Bun.file(join(root, 'public', path)); + return (await file.exists()) ? new Response(file) : new Response('not found', { status: 404 }); + }, +}); + +const tmp = mkdtempSync(join(tmpdir(), 'schuly-og-')); +const raw = join(tmp, 'og-raw.png'); +try { + const proc = Bun.spawn([browser, '--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1', '--window-size=1200,630', '--virtual-time-budget=5000', `--user-data-dir=${join(tmp, 'profile')}`, `--screenshot=${raw}`, `http://localhost:${server.port}/og.html`], { stdout: 'ignore', stderr: 'ignore' }); + await proc.exited; + if (!existsSync(raw)) throw new Error(`${browser} did not write a screenshot`); + + const meta = await sharp(raw).metadata(); + if (meta.width !== 1200 || meta.height !== 630) throw new Error(`Expected 1200x630, got ${meta.width}x${meta.height}`); + await sharp(raw).png({ palette: true, quality: 90, compressionLevel: 9, effort: 10 }).toFile(join(outDir, 'og-image.png')); + await sharp(raw).webp({ quality: 82 }).toFile(join(outDir, 'og-image.webp')); + console.log(`Wrote ${join(outDir, 'og-image.png')} and og-image.webp`); +} finally { + server.stop(true); + rmSync(tmp, { recursive: true, force: true }); +} From d2c612856265d0afaf910da429208a8e1714c74d Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:35:30 +0100 Subject: [PATCH 2/2] Use the icon tool for favicon.ico --- public/favicon.ico | Bin 6491 -> 15086 bytes tools/icons/README.md | 1 - tools/icons/favicon.py | 12 ------------ 3 files changed, 13 deletions(-) delete mode 100644 tools/icons/favicon.py diff --git a/public/favicon.ico b/public/favicon.ico index 2105d98d2595cd657620ca8fde57b05a20dd6416..8da7bd9539992d7a054bc3c10f504656ed67abc6 100644 GIT binary patch literal 15086 zcmeI3d6ZYhmB%~FOi-aWzzwxQ#eG*m6lht+aTzCxF*BJl#>rxmF>xl7qZtzkJKfML zG&?jjOGDGm(y}QoQE?>-Zr~C%B8Y(O(A~fHs_M??)_d>QKN{4WnK>u_%#U-adhb=$ z{oY&qy|=TnzLNE|tShd_BJG;BsC!mc&#bJhu3h8j+^nn*c$Z76-|zi;R@N8_TtFGB zWZezv`@iHT!?M1rq-A#w>)ihExV)Rg;@r}pD0gjG)c&PZS(iO#(m9fy18)T*^(+h<6v0S;pLzxXLV|9Zb=Imb{C!B zK`~|G)91`zg8SWybHCACoPVDw&Us(Tb3`WPIGIRV0ZnKxQgot7vC>I#+V+|ETIP84 zm(&|i{fcaniIDOJgDKAapk-YC{W7eeonlU~Cis)*eIrll78d0^CzEo8dQQe?TPSqn zvrUxOcgXR%1kd7#Tv4`UQu3a5sIR2$Wag80{X6T5JVJs_PO?Cl~AzWNHDye1mp6>%(+rreWAD| zh2*c26z@{{4Y|yiNKUPHj7*ubWy+xVBN+l6OR19F62-q~WZs#kG-tPzdYwBdAyrcB z#W^0k9Sa}TYeMi3M?<4dk`Uc0uG9$hJ-)M;S(l1iHdyTZYs6L+cyi?RUC6goac9b( z7|G)Mg*HaFXPnV~ELW1MY<^t%K8P`mXAXjo@v=|nYcL`2r!px|+=N^sCCuA4`0bdJ z=zsL47*&76(XnD4$@{T+5|o`T;j|0I)?N?BLE;wlWWC5n-<>6xaE2$%8|}x1y~HgY zBzEC-)VV-{($j@ClD3e?>mlwx&!?TAJKFbteq@YiHzCJJRj#y@%<4V9^ zs`xXAv1!J45;OfmF*7d}Gy8Hs7VLsvV(S56G`*9$RL&YsZzcp8gn|+bksb`C=IL+%h82Pv8 zx|5`)_m<$vhsAvIqPTFMI1vdOe=OnhyM5k;d&O*9FTuJ8B{ii7eV0!^{*7MQS3a%-B|Vm#jko*ToGBBm@8&N-bPcJzh5fyMauHoD>MavOp4zv z?hEEiB^>?@_QrE1RdpFyuai{mHzj!N0SPwEmGILI;)2gW+yv-7B8NW$a|`+h|B4@o zb4NsuY~|Zt@=fAWyCmHHf&?39N^0$|B(>mnX_?lGwwWL0-I-JE#8xwp7WYB_;7@Ru zf!`Clo|q@LG*zfG@{a6Tkjmoa$QQ@_cS~*%S9OMjOYf5K$R}c&wu?0f#My)3 z1(%WmbIYf|-zL)hiBFw`60ZCSeE@!Bkt5sF-=t*+b@ry~J9|i+*)Qhs$F#pwg5~#! zopiciyFC5~bEMaD^jn^Xu5%?U>)hVeM9b(=d9>{MVxo)_n}I)wWz4Ln@@77h_xM9G ze|T8z#M8vpgJ1Um#SNZ>oaQS#N=s>H=gPr4@ni@Yd`do+hj|YEf#N2gDdwr)fgc=A z=&5CQD>0TM92xJ>t>a&~`ezb8@~@f$f17$~>`6VvPoE&SdHVN^AMnH5@5>&0tXI{o z(!`FEDntJjiu;r{q%!c0Iq#MZX1zO$@$am1OjW+OW z(yi(@ld4YTZpRp7ofYP%&|eWHc~V`&9=S!_jB~|4qw6O>)m^YZCdD69qEpvQIo6(y zQ<+=LA=Za*Lj~{X1HPrlO;RtN(x%GJ&#oWk``k6&%$md4N%VP?o;f>JGRE{B^Ygew zpXmA5%|QR_(TO(h@q;-%4$!?h#0TSXo1GikbMIo^K1rm6b*{7%dCttszb9trtC1YK z9)$-p+EAUS{v-V6xzU^_&JF?%Pbp}nsTp~F&sj%GIX}NN zA;KRXK#%MLIv4oq+Gtr*!}nIQ-~Nz29hpabMI2{2JN#=AaW8NqzRI`LDDxu;|Fnua zI!BPh|46lOv|ss|mrL0@+kvSoI2B(8`LUwUXGm(|S*-2oek$dTOWj1KokJhyG5-Hc ztV@YKyq)vp>mGw)T$o4h7CYl|e@=7{XNmp&*CiZ(ngp}@NwDO*64ZT1QZuiYu=rH} z&HautoGH!BE7>2A<<%(?ey~;y=T&=n8$1FDpZX2!CjFdHJR0Hfke zh&t2S(9~|s;qGGQ_Z2(qGWzzMXn$88#V3di2NQRhVD=3XJU?E{j+ew8+2+q_dRDWX z-OP@cC3vx1g6e_XFR3?zD>!u>_69#sCb36OJ|DSy@XYv_$h=f+$JMLqyMXf^xHyZ; zbaeb>q%!F^^mB!FwQtKi`+Hfq;8qE@)H5bHqjAQRrjJDqzALi-EoigIq4!unIookY zv+Pmf+sh=Fe~Vb|TT;%tsAHJ6Wc1g?{T{i-ofzYXM_XN{pSwzG@;U5H;F^B!(ZqTq zGrB{X#)SU;yQJ)-E@G!}$9SyY@98o>bwZ+M9x_Ahe)vE9x*ljS=YNn*pH`I9n;gXbxk>!b&9;lN;@-Rnn~K0 zl5cUU=Z^68*@9xx@!Q~jUF5S@pbg|Virx9Fn0FS4Rob;al10DoQJIhfS#|z~8>g^N zF&`EV5ECAzJ-Vr7kC=JaGe(%3GtZClW$KeWa;2u8=f|}#qve!lT@caCI8B~4)pwrG zXW!@|=Iw>tRXz~e{W8Ru-?KsN$7{qs^=kweOTNlPk=v`QDtfR zNX%Pv(KU0Md8Kohjui75b9u&v(U|c3GIX2PV|PhS1IG--N!f&Elb?MN&yc2`^j%y= z+nW*Ne{Gt`0eF7)lGv@Q#XSBCF{NjMn{{&4zlnLbQNnjuGA6jkjO{3<;%qU<=C(ab z9b`VZMeN2&tbqlLp;P=mhX4}3To#>cW?vlX>$o~Rk9**S;%3HpJxJeuZZJkPt@>%tUulwct?leZhOKOBj0>p4hu?XmGr-~XlL<)d!gA1-X-47JAJ zlfn|_gYLgI@W$qJe7amx)!2&Wf_2_i5M}2>nkIzZQs!PNw&6AjcW`!eCe}CO+v^&< zw&`<7za{dtDP!6^Yg;;Jm^}`im``69yYyS=pRukOJU+#kLG!PaV9r&}dEAuIatSSn z^Vl*y{?-2sl$gZfXN^VISx8gJKgW5VYhzl}Cwe!J z{$O62RsX~prw*9$I;8!7lpHAlDyb}r8yS-^5*!rsexsPRKM<#5f2!pCn(ncbU!excA(_SY~Zm0CsI_F>9vsQ2q%ok0s+de(!3pGt$^kOx^WM zg1W-{(&(>+iqJv{yEWSA91zldS#)sEb4oIb8TT)+t7cnH2AihETO$j()K-0 z#j4cyIhJ9`0C2!#!Byy@P|S0`WsW>1=EF5&);}axzk6MyPwcXSUAwitwr#e3PI!|p z?&qYwP;7m_ea#E{oUJ~vu%UmEtR1XvhJwxXSb|r5Ov?8dQao`zwOQ)-+MejCmnXlD zsZR8pZ~r)LX!~04i}>ib#r;BAGl>1+hEm0@zrEA0&auk|z9y@O(8hpJ)Gw4t_3^W& zp4!Toyw6A*2GK?)WqeDwlf2XRGV^WQkDu%Mhq7`IHn9Pl4@~UQQL!if>q9k#m)I49 zw#u5Js;2;Aj-#)?8X-@j!}lOW?=WVH)>o3p5F2jcTn2na&f#=zV!5ieUn?xFi zIPAa1Lb7y_h2;N?v|qlgz50R9HVlCGKqD&#J9%s<=js8kx6}=|$Ya0bj<0E&{E~Qr zttHo<5Y!L;HF9i|)yT2-CMS(IieO8XrI4oo6H4l0kC)?7(tc8|5kAU$wc;MMGpHZ< zTd5g*lHyJDonTK=9ZMonU$}Wgvs)Sle;Zlm1`R{rH4TGP2ECDfE+I2(Xe(uw_3!KX zrlf7uAKjLJ7uO9N1_xpN;P=9YfiSSMbv2-IT3w+Vpf8Kl zO4cf=E@+n;m;GI0oK_Gku-%N$N!bbc`0DY=dde4l_QZ4R`;71YeZGWoIob+LQHO1& zD0_LTDDNM)mYi_{kkad8ns-t5HMS^wQxsE`O)NyV2!7IINd0~Z5l3)z+)l=~bY0rf zwkl||k~p2B?ALvxSYisPa*PD-C!iTDZPm&C`nj6P99?%L|p zHfqNvX2r)uB-oBl9sWw=W1kd5S@z*@Y|ageVebQZIkqh4t(bQdXQMG02`^1G=8BjR zI~IG|$abD*P==jrHg|ML^F9^`JQ{D~V}aVlX=!_2mg4L{G^#{7x_tpBHfh7JHY$&aPy!&*{6Mk~ln#J<+lnk3zgi zQeSQ3o_{)4hj@;XocnD_?mF-DpO9;PECaHe;ymmR=SVmeTOGDEgU!v%x>zi>C^Mx1 z`^s4oEc=0^8i@m0_I*!bh1#jXqGc!>V#Cr{mT)RG3Hw+PGHRcw?^okgG)|&4_i^Gp zK2<*e@g2_l!-?N>GqE3W9ye75@HO_X zmEiFQC8!xB;S|M*ex@J;_H)j~k=-yI^N{1?G>CI}*Oujnt^W6p=%~FoY$=A=o#5%w z*uR%b2tT{;U9KFT!IiVS#$u3$FO_kI94#32v3>APV=!8Fi{0}!csOHju9x81B9GtO z!)Tirg-F&4jZ-*zulP8NPRtKT{kg=#IBaURrnlIGAHgSp-+mH!`&bI@+xQSLgkevt zC{+@>)iHK&G1eh}s9W?=^nF#~b9~r^nB#e7%ppGwI1-!?%r% z(bilmcGnv;L0jrSZ`svK-k(l9z=la;o*(D=992gBy;_F4dS|e6;IFnOjq^dqBu=2I zm@e@nt6bH_i})O&3Oy z-jnZ|s?QG_l-`eQ4d-?2THfc1P1||D@yXIuj-OKPN$RUqQa_Bw7jSR23;TK9ad~Xz ztFo(n=Aa+{=80d4dGzN#@9kZ_k5pGNR{WRlz@M=V^!E2^?=#i-887dt(!`wA7@o+_ zduHSV((l}v?ZUp|=sVx8`b5*V0Vnp*$6}s*AezgpSLTWTEz^v4Jx!P?M1NwId zKBO;^AA6VDVpT?mk5eX}F3lAM@IMQ_1sT*8U4WtbN?0=Hv{md__Q3mM{`|ODeD~_h zv=3$BL+A*xKa#g4#C@&1pS#_Y%o}*dJ`dM&KrCyK@8j4XtZ^Iar)*{|^)`C7Nh&42 zKJ~3l!?(Tm$70s}2z&HRw85G*{44A=#8WQ%2mDKO;Pr%Gi^5IS5-$F>|K^6{Po2T| zrH*O*KK|jm(6{Osp0Ur)O)VgQmNXM{?WUZK4H;k2F~obF4>~uAy>b)qGpza8Rgc*OWtl!Z)&6+@3#*eS5wqx93&fF;}rsJ9n6Mxr#Y{vKV}@ zX8-$Q8^0IH;_dwS#?)6g;~Z(8g1<%W=iZmY`jqJp@qQ)xjlIps^?k6CHST?J_`K|f zVr+b@^ELS8Y6g4%oPDKK-0l}x2N>^^)%X{?X}Va(k6C_)@9)S@f{#RfMKilg^HlY- zz?VJixTI?q{GE=4&2z-QS|Q=wLipV*;TsEC?su}j?nF1l>dm_eJOl9iREeRipfkJt zyTtIlLj6`=iS5NQlqdupg!Edx=fk z&ygb%Zf?LYey0R8dUA%~OjOH#0w1{dg{h3%E^E7+P221-Ei*1-o?V9h5F1%S-nYQB zj+0`}ArpvWU3xeBnc5Yb;lxkpmfHH&zO0mDT=-ZC=BmMN_~DZh*8htH)3B{R`!Hji zx#fKo@S=U^d0ZmRv#^sNW9P>f4Hj43OTtGVU`%ghOqqzUt}EF0ylpm-JxRkOV2Qu6 z4rv{`>rDxtFBLnxCvC#p`xD^lZTFW;%j~Nhwr|&JJH7^6P%m`EdFOdzl_IY2LvEE< zd8w_@+os#3-ae`CDC5hL2ZtYjFN}%C*ZdyX$gYkFJ9|w}*4bV`WSfSsw^5TS2Flt; z?qq%~W=Y)}AyNBug2CH8!Ih*Q6TB1qEg0;3OU17Gp0{!PHWOR;{GLu0^^|Zy@4eV* zKb6J3;jMONwTqHZ2)}^e3zy#}7N4lbQLw^$xgtHlFTsOPQNP(EJH);@8{6kMeO*zX zP10U#YSlK3jr%>jq|cMGte;@pv}yZl+RoWm?NVUtiOv`|*dcZ9+kJ^0QaRGs%2)T< zSIV(p-GuG+il|OvZ%o?Dw39CDt9H^SO#KbNl-1aS>iPvo+Y{ldc17i1SBU+qzhb0c z&?*>{x2YOWOf6^VsP0TVe-XNIVF3U?m~%ZKM8Mn>y*XsPFTIf#Zi94_oT zz~2Gmzc*>B8`#@640;pWjL52?PKfJ2u07{?Hl8oDSrLDaY);k=ReSFiY~y_*-jPiw zvHM8lEhn0WA$MV)SxdTC?cSMon{*xw>3rI*Gl+Ky9rJxJcAdvkO9tM;daIU;No{2Q+%;gTk^ih#laJp!te3ots#|d933{wy49p zusCn1$|qwx-0I5o-p3ta{7Fv6o+v^Ly&ZFcSnh@Tw-K86arI+Qh{~uvpghNhV{`xB zR^+~@cSc*B7X-x}9e+=*mu^9Zu+IZE;S@|7}ot?o+NZ zzscWiIPcrV*#7kFtnq9+UgRA2v{+&~9OoyEr!$ZL2WtaozKT=1lX4#}@AxtIGydL; zvw0czgAJvuIQ;Zp#{JB0eNx#?>m6r)yXr?`SKj0A zo*b5=PsIPYs#7>WoGwlDRTR(0`A=ndlr!Qx^_*{ij^1uzt!95$s=Zb0qxXq>bDlVj z_0ZVXsRf)<^~`td%mRCV^+WN`h2{bm+?y7+U7KPOdlF7oFF z_5se)cIGwIrTp%jyY*r&qrX{-Cs9O!N9q*F!DSNV;Mmdwt>UItRnL pfkOp9g8B}8{5tEHTSgqRrk;qIVLj8lmVsUg>-+zm*8Si9{u@y;fWH6$ literal 6491 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 for another source image, `--out ` for the PNGs and `--favicon ` for the `.ico`. After you change the icon, also run [`../social-image`](../social-image), because the social image shows `app_icon_512.png`. -Note: the `favicon.ico` in the repo is still Angular's default logo from the first commit. Running this script replaces it with the Schuly icon. ## Licences diff --git a/tools/icons/favicon.py b/tools/icons/favicon.py deleted file mode 100644 index ba0ac9c..0000000 --- a/tools/icons/favicon.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Builds public/favicon.ico (16, 32 and 48 px) from public/assets/app_icon_512.png. - -Run from the repo root: python tools/icons/favicon.py (needs Pillow: pip install Pillow) -""" -from pathlib import Path - -from PIL import Image - -root = Path(__file__).resolve().parents[2] -src = Image.open(root / "public/assets/app_icon_512.png").convert("RGBA") -src.save(root / "public/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)]) -print("wrote public/favicon.ico")