Das Web ist jetzt ein Projekt

Schritt 2 des Plans, fertig. Aus 1700 Zeilen in einer Datei sind zwölf
Module geworden: api, prefs, contrast, icons, util, types und die
Ansichten für Anmeldung, Startseite, Mediathek, Einstellungen, Kacheln
und Player. Alles typgeprüft, und die Prüfung läuft scharf —
noUncheckedIndexedAccess inbegriffen.

An einer Stelle hat der Compiler mich zu Recht aufgehalten: Object.fromEntries
verliert die Typen jedes einzelnen Feldes, und geblieben wäre nur ein
doppelter Cast, der die Prüfung stilllegt statt sie zu bestehen. Jetzt
steht es ausgeschrieben da.

hls.js wird erst beim Abspielen geladen. Es ist größer als der ganze Rest
zusammen, und wer nur stöbert, braucht es nie: der Start fällt damit von
632 auf 39 Kilobyte.

Geprüft mit einem erfundenen Medienserver dahinter: Seite kommt an,
gebaute Dateien werden ausgeliefert, Anfragen gehen weiter, und die
Startseite zeichnet Insel, Aufmacher und Reihen.

Die einzelne Datei bleibt liegen, bis das Projekt sich bewährt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 19:31:56 +02:00
co-authored by Claude Opus 5
parent a04784ca15
commit 3af6c3112f
10 changed files with 1369 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
/* Kontrastrechnung nach WCAG 2.1 — dieselbe wie in der App.
----------------------------------------------------------------------------
Flixy lässt jede Farbe zu, aber ein dunkles Marineblau auf fast schwarzem
Grund ist als Schrift unlesbar. Solche Farben werden angehoben, bis sie den
geforderten Kontrast erreichen — und es wird gesagt, statt die Farbe
stillschweigend zu verändern.
Zuerst die Helligkeit, weil das den Farbton erhält: eine gewählte
Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst volle Helligkeit
nicht, wird die Sättigung zurückgenommen, und die Farbe wandert Richtung
Weiß. */
export type RGB = [number, number, number];
const MINDESTENS = 4.5;
export function leuchtdichte([r, g, b]: RGB): number {
// Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der
// sRGB-Definition: unterhalb davon ist die Kurve linear.
const linear = (c: number): number =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
}
export function verhaeltnis(a: RGB, b: RGB): number {
const la = leuchtdichte(a);
const lb = leuchtdichte(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
export function lesbarMachen(farbe: RGB, grund: RGB): { farbe: RGB; geaendert: boolean } {
if (verhaeltnis(farbe, grund) >= MINDESTENS) return { farbe, geaendert: false };
const [h, s, v] = zuHSV(farbe);
// Beide Schritte sind monoton — mehr Helligkeit und weniger Sättigung
// erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche, und
// sie endet garantiert.
const suche = (tief: number, hoch: number, bauen: (x: number) => RGB): RGB | null => {
if (verhaeltnis(bauen(hoch), grund) < MINDESTENS) return null;
let u = tief;
let o = hoch;
for (let i = 0; i < 24; i++) {
const mitte = (u + o) / 2;
if (verhaeltnis(bauen(mitte), grund) >= MINDESTENS) o = mitte;
else u = mitte;
}
return bauen(o);
};
const heller = suche(v, 1, (x) => ausHSV(h, s, x));
if (heller) return { farbe: heller, geaendert: true };
const blasser = suche(s, 0, (x) => ausHSV(h, x, 1));
return { farbe: blasser ?? [1, 1, 1], geaendert: true };
}
function zuHSV([r, g, b]: RGB): RGB {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h /= 6;
if (h < 0) h += 1;
}
return [h, max ? d / max : 0, max];
}
function ausHSV(h: number, s: number, v: number): RGB {
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
const saetze: RGB[] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]];
return saetze[i % 6]!;
}
export function ausHex(hex: string): RGB | null {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return null;
const n = parseInt(m[1]!, 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
export function alsHex([r, g, b]: RGB): string {
return (
'#' +
[r, g, b]
.map((c) => Math.round(Math.min(Math.max(c, 0), 1) * 255).toString(16).padStart(2, '0'))
.join('')
.toUpperCase()
);
}