From 9320092ebf62bdb671fac5d0ddfc420374a11c11 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 14 Sep 2026 17:04:40 +0200 Subject: [PATCH] Web: gleiche Gliederung, und eine Erfindung weniger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 des Abgleichs. "Plakate oder Querformat" fliegt raus — das ist keine Einstellung, sondern eine Entscheidung, die die App bewusst selbst trifft: Angefangenes erkennt man am Szenenbild wieder, Neues sucht man nach Plakaten aus. Ich hatte sie zur Wahl gemacht, ohne dass jemand danach gefragt hätte. Dafür die Abschnitte in der Reihenfolge der App — Darstellung, Startseite, Server — und die eigene Akzentfarbe als Hex-Feld, mit derselben Kontrastrechnung nach WCAG 2.1. Nachgerechnet: ein dunkles Marineblau landet auf genau 4.5, dem Grenzwert, und eine Farbe, die ihn ohnehin erreicht, bleibt unberührt. Gesagt wird es auch — eine Farbe stillschweigend zu verändern wäre schlechter, als sie abzulehnen. Co-Authored-By: Claude Opus 5 --- web/frontend/index.html | 154 +++++++++++++++++++++++++++++++++++----- 1 file changed, 137 insertions(+), 17 deletions(-) diff --git a/web/frontend/index.html b/web/frontend/index.html index c79d954..e8f6bcb 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -292,6 +292,20 @@ img { display: block; } /* ---- Einstellungen -------------------------------------------------------- */ .settings { max-width: 620px; margin: 0 auto; padding: 0 var(--s-margin) var(--s-section); } .settings h2 { font-size: 28px; letter-spacing: -.02em; margin: 0 0 var(--s-section); } +.settings .group { + font-size: 13px; font-weight: 600; color: var(--text-3); + text-transform: uppercase; letter-spacing: .06em; + margin: var(--s-section) 0 var(--s-snug); padding-left: var(--s-tight); +} +.settings .group:first-of-type { margin-top: 0; } +.hexrow { display: flex; gap: var(--s-snug); margin-top: var(--s-regular); } +.hexrow input { + flex: 1; background: var(--elevated); border: 1px solid var(--separator); + border-radius: var(--r-control); color: var(--text); padding: 9px var(--s-regular); + font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 14px; +} +.hexrow input:focus { outline: none; border-color: var(--accent); } +.hint { font-size: 12px; color: var(--text-3); margin: var(--s-snug) 0 0; min-height: 16px; } .setting { background: var(--surface); border: 1px solid var(--separator); border-radius: var(--r-sheet); padding: var(--s-loose); margin-bottom: var(--s-regular); @@ -503,7 +517,6 @@ const P = { heroHeight: +(localStorage.getItem('flixy.heroHeight') || 82), combineContinue: localStorage.getItem('flixy.combineContinue') !== 'false', hideWatched: localStorage.getItem('flixy.hideWatched') === 'true', - rowLayout: localStorage.getItem('flixy.rowLayout') || 'poster', // 'poster' | 'wide' }; function applyPrefs() { @@ -644,6 +657,83 @@ function go(key) { } /* ---------- Einstellungen ---------- */ +/* 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ß. */ +const Contrast = { + minimumForText: 4.5, + + luminance([r, g, b]) { + const lin = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }, + + ratio(a, b) { + const la = Contrast.luminance(a), lb = Contrast.luminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); + }, + + /// Hebt die Farbe gerade so weit an, dass sie auf dem Grund lesbar wird. + legible(rgb, background) { + if (Contrast.ratio(rgb, background) >= Contrast.minimumForText) return { rgb, changed: false }; + + let [h, sat, v] = rgbToHsv(rgb); + // Beide Schritte sind monoton, deshalb genügt eine binäre Suche. + const find = (lo, hi, build) => { + if (Contrast.ratio(build(hi), background) < Contrast.minimumForText) return null; + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + if (Contrast.ratio(build(mid), background) >= Contrast.minimumForText) hi = mid; else lo = mid; + } + return build(hi); + }; + + const brighter = find(v, 1, (x) => hsvToRgb(h, sat, x)); + if (brighter) return { rgb: brighter, changed: true }; + + const paler = find(sat, 0, (x) => hsvToRgb(h, x, 1)); + return { rgb: paler || [1, 1, 1], changed: true }; + }, +}; + +function rgbToHsv([r, g, b]) { + const max = Math.max(r, g, b), min = Math.min(r, g, b), 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 hsvToRgb(h, s, v) { + const i = Math.floor(h * 6), f = h * 6 - i; + const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + const sets = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]]; + return sets[i % 6]; +} + +const hexToRgb = (hex) => { + 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]; +}; + +const rgbToHex = ([r, g, b]) => + '#' + [r, g, b].map((c) => Math.round(Math.min(Math.max(c, 0), 1) * 255) + .toString(16).padStart(2, '0')).join('').toUpperCase(); + function renderSettings() { const c = el('content'); c.classList.add('pad-top'); @@ -651,12 +741,31 @@ function renderSettings() {

Einstellungen

+

Darstellung

+

Akzentfarbe

Dieselbe Palette wie in der App.

+
+ + +
+

+
+

Kachelgröße

+

Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite + und in den Mediatheken. Gilt nur auf diesem Gerät.

+
+ + +
+
+ +

Startseite

+

Aufmacher

Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden; @@ -682,23 +791,12 @@ function renderSettings() { „Weiterschauen“ und „Als Nächstes“ zusammenlegen -

- - -
-
-

Kachelgröße

-

Wie groß Plakate und Vorschaubilder erscheinen.

-
- - -
-
+

Server

-

Server

+

Verbindung

@@ -708,7 +806,7 @@ function renderSettings() { PALETTE.forEach(([name, hex]) => { const b = document.createElement('button'); b.className = 'swatch' + (hex.toLowerCase() === P.accent.toLowerCase() ? ' on' : ''); - b.style.background = hex; b.title = name; + b.style.background = hex; b.title = name; b.dataset.hex = hex; b.onclick = () => { setPref('accent', hex); renderSettings(); }; sw.appendChild(b); }); @@ -721,7 +819,6 @@ function renderSettings() { }); }; segment('set-herosource', 'heroSource'); - segment('set-rowlayout', 'rowLayout'); // Regler, die alle gleich funktionieren. const slider = (id, key, unit) => { @@ -744,6 +841,26 @@ function renderSettings() { z.value = P.cardW; zo.textContent = P.cardW + ' px'; z.oninput = () => { zo.textContent = z.value + ' px'; setPref('cardW', +z.value); }; + // Eigene Farbe, mit derselben Prüfung wie in der App. + const hex = el('set-hex'), note = el('set-hex-note'); + hex.value = P.accent; + const applyHex = () => { + const rgb = hexToRgb(hex.value); + if (!rgb) { note.textContent = 'Das ist kein Farbwert — erwartet wird etwa #C04AC9.'; return; } + const { rgb: fixed, changed } = Contrast.legible(rgb, hexToRgb('#0B0B0D')); + const value = rgbToHex(fixed); + setPref('accent', value); + note.textContent = changed + ? `Auf ${value} aufgehellt — die gewählte Farbe wäre auf dem dunklen Grund nicht lesbar gewesen.` + : ''; + hex.value = value; + // Die Kachel der Palette verliert ihre Markierung, sobald die Farbe nicht + // mehr aus ihr stammt. + [...el('set-accent').children].forEach((b) => b.classList.toggle('on', b.dataset.hex === value)); + }; + el('set-hex-apply').onclick = applyHex; + hex.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyHex(); } }; + el('set-server').textContent = S.base.replace(/^https?:\/\//, ''); } @@ -784,7 +901,10 @@ async function renderHome() { // Gesehenes ausblenden gilt nur für Neuzugänge: in „Weiterschauen" wäre // es sinnlos, dort ist alles angefangen. items: (latest[i] || []).filter((it) => !P.hideWatched || !it.UserData?.Played), - wide: P.rowLayout === 'wide', + // Plakate, nicht Querformat — und das ist keine Einstellung, sondern + // dieselbe Entscheidung wie in der App: Angefangenes erkennt man am + // Szenenbild wieder, Neues sucht man nach Plakaten aus. + wide: false, }))); rows = rows.filter((r) => r.items.length);