Web: gleiche Gliederung, und eine Erfindung weniger

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 17:04:40 +02:00
co-authored by Claude Opus 5
parent df75fa98a5
commit 9320092ebf
+137 -17
View File
@@ -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() {
<div class="settings">
<h2>Einstellungen</h2>
<h3 class="group">Darstellung</h3>
<div class="setting">
<h4>Akzentfarbe</h4>
<p>Dieselbe Palette wie in der App.</p>
<div class="swatches" id="set-accent"></div>
<div class="hexrow">
<input type="text" id="set-hex" placeholder="#C04AC9" maxlength="7" spellcheck="false">
<button class="btn" id="set-hex-apply">Übernehmen</button>
</div>
<p class="hint" id="set-hex-note"></p>
</div>
<div class="setting">
<h4>Kachelgröße</h4>
<p>Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite
und in den Mediatheken. Gilt nur auf diesem Gerät.</p>
<div class="range-row">
<input type="range" id="set-zoom" min="100" max="280" step="10">
<output id="set-zoom-out"></output>
</div>
</div>
<h3 class="group">Startseite</h3>
<div class="setting">
<h4>Aufmacher</h4>
<p>Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden;
@@ -682,23 +791,12 @@ function renderSettings() {
„Weiterschauen“ und „Als Nächstes“ zusammenlegen</label>
<label class="check"><input type="checkbox" id="set-hidewatched">
Gesehenes aus den Neuzugängen ausblenden</label>
<div class="seg" id="set-rowlayout" style="margin-top:var(--s-regular)">
<button data-v="poster">Plakate</button>
<button data-v="wide">Querformat</button>
</div>
</div>
<div class="setting">
<h4>Kachelgröße</h4>
<p>Wie groß Plakate und Vorschaubilder erscheinen.</p>
<div class="range-row">
<input type="range" id="set-zoom" min="100" max="280" step="10">
<output id="set-zoom-out"></output>
</div>
</div>
<h3 class="group">Server</h3>
<div class="setting">
<h4>Server</h4>
<h4>Verbindung</h4>
<p id="set-server"></p>
<button class="btn" onclick="signOut()">Abmelden</button>
</div>
@@ -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);