Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@ The landing page at [schuly.dev](https://schuly.dev), in six languages: German,

`bun install`, then `bun start` for <http://localhost:4200> 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)
Binary file modified public/favicon.ico
Binary file not shown.
25 changes: 25 additions & 0 deletions tools/hero-images/README.md
Original file line number Diff line number Diff line change
@@ -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 <dir>`.

## Licences

Both screenshots show made-up data only (Mia Muster, Berufsfachschule Beispielstadt).
18 changes: 18 additions & 0 deletions tools/hero-images/hero.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Converts the two hero screenshots into the website's webp files.
// Usage: bun tools/hero-images/hero.ts --web <overview-dark.png> --phone <home-dark.png> [--out <dir>]
// --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 <png>, --phone <png> 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}`);
29 changes: 29 additions & 0 deletions tools/icons/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 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 <png>` for another source image, `--out <dir>` for the PNGs and `--favicon <path>` for the `.ico`. After you change the icon, also run [`../social-image`](../social-image), because the social image shows `app_icon_512.png`.


## Licences

The icon is Schuly's own artwork; the repo's licence applies.
12 changes: 0 additions & 12 deletions tools/icons/favicon.py

This file was deleted.

63 changes: 63 additions & 0 deletions tools/icons/icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Builds the website's icon set from public/assets/app_icon.png.
// Usage: bun tools/icons/icons.ts [--src <png>] [--out <dir>] [--favicon <path>]
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}`);
27 changes: 27 additions & 0 deletions tools/social-image/README.md
Original file line number Diff line number Diff line change
@@ -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 <path>` or set `CHROME_PATH`.

## Regenerate

```sh
bun tools/social-image/render.ts
```

It writes to `public/assets/` by default; `--out <dir>` 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).
49 changes: 49 additions & 0 deletions tools/social-image/og.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
@font-face { font-family: Inter; src: url(/fonts/inter-latin-wght-normal.woff2) format('woff2'); font-weight: 100 900; }
* { margin: 0; box-sizing: border-box; }
html, body { width: 1200px; height: 630px; overflow: hidden; }
body {
position: relative;
font-family: Inter, system-ui, sans-serif;
color: #f1f2f4;
background:
radial-gradient(60% 70% at 85% 30%, rgba(8, 136, 248, 0.28), transparent 70%),
radial-gradient(50% 60% at 0% 100%, rgba(78, 168, 255, 0.10), transparent 70%),
#07080a;
}
body::before {
content: ''; position: absolute; inset: 0;
background-image: radial-gradient(rgba(255,255,255,0.07) 1px, transparent 1px);
background-size: 22px 22px;
-webkit-mask-image: linear-gradient(90deg, #000 0%, transparent 60%);
}
.copy { position: absolute; left: 72px; top: 0; bottom: 0; width: 520px; display: flex; flex-direction: column; justify-content: center; }
.brand { display: flex; align-items: center; gap: 16px; margin-bottom: 44px; }
.brand img { width: 64px; height: 64px; border-radius: 16px; }
.brand span { font-size: 34px; font-weight: 650; letter-spacing: -0.03em; }
h1 { font-size: 64px; line-height: 1.04; font-weight: 640; letter-spacing: -0.045em; }
h1 .dim { display: block; color: #8a8f99; }
p { margin-top: 28px; font-size: 22px; line-height: 1.45; color: #a3a7b0; }
.shot {
position: absolute; left: 640px; top: 92px; width: 760px; height: 476px;
border-radius: 18px; overflow: hidden;
border: 1px solid rgba(255,255,255,0.13);
box-shadow: 0 40px 120px -20px rgba(8,136,248,0.45), 0 0 0 8px rgba(255,255,255,0.03);
background: #0a0b0e;
}
.shot img { width: 100%; height: 100%; object-fit: cover; object-position: left top; display: block; }
</style>
</head>
<body>
<div class="copy">
<div class="brand"><img src="/assets/app_icon_512.png" alt=""><span>Schuly</span></div>
<h1>Alle deine Schulen,<span class="dim">in einer App.</span></h1>
<p>Noten, Absenzen und Stundenplan aus Schulnetz und OdA Org. Android, iOS und Web.</p>
</div>
<div class="shot"><img src="/assets/webapp-overview-dark.webp" alt=""></div>
</body>
</html>
54 changes: 54 additions & 0 deletions tools/social-image/render.ts
Original file line number Diff line number Diff line change
@@ -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 <dir>] [--browser <path>]
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 <path> 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 });
}
Loading