diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bcc4669..986cba32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- App titles now take their color from the background behind them, per icon over + a wallpaper. + - An app can now be dropped between two folders, on the top or bottom edge of a folder row. diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index 4c26d695..cc399704 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -148,6 +148,12 @@ "/js/jitter.js": [ "./ui/js/jitter.js" ], + "/js/label-contrast.js": [ + "./ui/js/label-contrast.js" + ], + "/js/label-contrast.js?v=*": [ + "./ui/js/label-contrast.js" + ], "/js/layout.js": [ "./ui/js/layout.js" ], diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index 90b8218c..edfadc3f 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -15,6 +15,10 @@ --icon-sheen: linear-gradient(135deg,rgba(255,255,255,.10) 0%,transparent 60%); --label-color: rgba(255,255,255,.90); --label-shadow: 0 1px 4px rgba(0,0,0,.70); + /* The tone the labels take on a background measured as light. The shadow + is a hairline: the colour carries the contrast, not the shadow. */ + --label-color-dark: rgba(0,0,0,.92); + --label-shadow-dark: 0 0 2px rgba(255,255,255,.55); --bounce-spring: cubic-bezier(.34,1.56,.64,1); --page-ease: cubic-bezier(.77,0,.175,1); /* The page behind the wallpaper, and the boot veil over the unstyled @@ -194,6 +198,13 @@ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + /* Labels measured against a light background. The tone is set on the + element by the dashboard, never by the theme: the background is the + user's, not the system's. */ + .ilabel[data-tone="dark"], .dyn-mob-label[data-tone="dark"], .dyn-fold-label[data-tone="dark"] { + color:var(--label-color-dark); text-shadow:var(--label-shadow-dark); + } + /* ── Badge — reference size, scaled by --sc (desktop overrides below) ── */ .badge { position:absolute; @@ -720,6 +731,9 @@ a:focus-visible, button:focus-visible, [role="button"]:focus-visible, --label-shadow: 0 1px 4px rgba(0,0,0,.95); } .ilabel, .dyn-mob-label, .dyn-fold-label, .dyn-fold-inner-label { color:var(--label-primary); } + .ilabel[data-tone="dark"], .dyn-mob-label[data-tone="dark"], .dyn-fold-label[data-tone="dark"] { + color:#000000; text-shadow:0 0 2px rgba(255,255,255,.95); + } .ctrl { color:var(--label-primary); background:rgba(255,255,255,.22); } .srn { color:var(--label-primary); } .srh, .sr-section { color:rgba(235,235,245,.85); } diff --git a/ui/index.html b/ui/index.html index 746b7b4b..412fc00d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -24,7 +24,7 @@ Stackyard - +

Stackyard

@@ -53,5 +53,5 @@

Stackyard

- + diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index d4180610..a764a070 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -38,6 +38,14 @@ import { trapFocus } from '/js/dialog.js?v=05935547'; import { jitter } from '/js/jitter.js?v=4edf48f2'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; +import { + applyLabelTones, + loadSamplingImage, + parseCssColor, + relativeLuminance, + sampleImage, + toneForLuminances, +} from '/js/label-contrast.js?v=322fb66e'; /* Recomputed, never stored: the window can cross the breakpoint after load. */ let MOB = isMobileLayout(); @@ -437,6 +445,43 @@ function syncMobPages() { } } +/** The background as the label tones see it: a sampled wallpaper grid, or one + tone for a solid colour. Null on both means the labels keep their default. */ +let bgTone = { grid: null, tone: null }; + +/** Re-tone the labels. Cheap: it re-reads the sampled grid, never the image. */ +function retone() { + requestAnimationFrame(() => applyLabelTones(bgTone)); +} + +function toneForColor(color) { + const rgb = parseCssColor(color); + if (!rgb) return null; + return toneForLuminances([relativeLuminance(rgb[0], rgb[1], rgb[2])]); +} + +/** The readable copy of the current wallpaper, held so a resize re-samples + without fetching again. */ +let _bgSample = null; + +async function sampleWallpaper(url, brightness) { + const img = await loadSamplingImage(url); + _bgSample = img ? { img, brightness } : null; + bgTone = { grid: img ? sampleImage(img, window.innerWidth, window.innerHeight, brightness) : null, tone: null }; + retone(); +} + +/* The wallpaper is sized to cover the viewport, so a resize moves which part of + it every label sits on. */ +function resampleBg() { + if (!_bgSample) return retone(); + bgTone = { + grid: sampleImage(_bgSample.img, window.innerWidth, window.innerHeight, _bgSample.brightness), + tone: null, + }; + retone(); +} + async function applyBg() { const root = document.documentElement; try { @@ -446,10 +491,15 @@ async function applyBg() { root.style.setProperty('--bg-image', 'none'); root.style.setProperty('--bg-color', safeColor); root.style.setProperty('--bg-brightness', '1'); + bgTone = { grid: null, tone: toneForColor(safeColor) }; + retone(); } else if (bg.type === 'url' && bg.url) { - root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(bg.url)}')`); + const url = sanitizeCssUrl(bg.url); + const brightness = Number(bg.brightness ?? 0.62); + root.style.setProperty('--bg-image', `url('${url}')`); root.style.setProperty('--bg-color', '#0d1117'); - root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); + root.style.setProperty('--bg-brightness', String(brightness)); + sampleWallpaper(url, brightness); } else if (bg.type === 'unsplash') { let url = readWallpaperCache(storeGet(WALLPAPER_STORE), bg, Date.now()); if (!url) { @@ -460,11 +510,13 @@ async function applyBg() { } if (url) { const shown = url; + const brightness = Number(bg.brightness ?? 0.62); const img = new Image(); img.onload = () => { root.style.setProperty('--bg-image', `url('${sanitizeCssUrl(shown)}')`); root.style.setProperty('--bg-color', '#0d1117'); - root.style.setProperty('--bg-brightness', String(bg.brightness ?? 0.62)); + root.style.setProperty('--bg-brightness', String(brightness)); + sampleWallpaper(sanitizeCssUrl(shown), brightness); }; img.src = shown; } @@ -710,12 +762,14 @@ async function boot() { buildMobile(); syncMobPages(); goTo(restorePage(storeGet(PAGE_STORE), totalPages), null, false); + retone(); }), ); } else { document.body.classList.remove('is-mob'); buildDesktop(); goTo(restorePage(storeGet(PAGE_STORE), totalPages), null, false); + retone(); } }; buildLayout(); @@ -807,6 +861,7 @@ async function boot() { clearTimeout(_rt); _rt = setTimeout(() => { if (MOB) buildLayout(); + resampleBg(); }, 150); }, { passive: true }, @@ -817,7 +872,10 @@ async function boot() { pixel: a rebuild tears down and remounts every widget iframe. */ let _dz, _slots = desktopSlots(); + let _rs; window.addEventListener('resize', () => { + clearTimeout(_rs); + _rs = setTimeout(resampleBg, 200); if (MOB) return; clearTimeout(_dz); _dz = setTimeout(() => { @@ -828,6 +886,7 @@ async function boot() { _slots = slots; buildDesktop(); goTo(Math.min(pg, totalPages - 1), null, false); + retone(); }, 200); }); diff --git a/ui/js/label-contrast.js b/ui/js/label-contrast.js new file mode 100644 index 00000000..56a0197d --- /dev/null +++ b/ui/js/label-contrast.js @@ -0,0 +1,229 @@ +// @ts-check +/* Icon label colour, chosen from the background behind each label. + + The pure half of this module has no DOM, no fetch and no module state. The + DOM half is at the bottom and is named so. */ + +/** WCAG 2.1 relative luminance of an sRGB triple. + + @param {number} r @param {number} g @param {number} b 0-255 + @returns {number} 0-1 */ +export function relativeLuminance(r, g, b) { + const lin = (/** @type {number} */ c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +/** @param {number} a @param {number} b two relative luminances + @returns {number} the WCAG contrast ratio, 1-21 */ +export function contrastRatio(a, b) { + const [hi, lo] = a >= b ? [a, b] : [b, a]; + return (hi + 0.05) / (lo + 0.05); +} + +/** Luminance of the two label tones. Both carry a shadow, so the text is not + pure black or pure white. */ +const TONE_LUM = { + light: relativeLuminance(255, 255, 255), + dark: relativeLuminance(0, 0, 0), +}; + +/** The tone that keeps the most contrast against the worst patch it covers. + + @param {ArrayLike} lums the background luminances under the label + @returns {'light'|'dark'} */ +export function toneForLuminances(lums) { + if (!lums || lums.length === 0) return 'light'; + let worstLight = Infinity, + worstDark = Infinity; + for (let i = 0; i < lums.length; i++) { + worstLight = Math.min(worstLight, contrastRatio(TONE_LUM.light, lums[i])); + worstDark = Math.min(worstDark, contrastRatio(TONE_LUM.dark, lums[i])); + } + return worstDark > worstLight ? 'dark' : 'light'; +} + +/** The part of an image `background-size:cover` actually shows. + + @param {number} iw @param {number} ih the image's natural size + @param {number} vw @param {number} vh the box it covers + @returns {{sx:number, sy:number, sw:number, sh:number}} */ +export function coverSourceRect(iw, ih, vw, vh) { + if (!(iw > 0 && ih > 0 && vw > 0 && vh > 0)) return { sx: 0, sy: 0, sw: iw || 1, sh: ih || 1 }; + const scale = Math.max(vw / iw, vh / ih); + const sw = Math.min(iw, vw / scale), + sh = Math.min(ih, vh / scale); + return { sx: (iw - sw) / 2, sy: (ih - sh) / 2, sw, sh }; +} + +/** CSS `filter:brightness()` multiplies each sRGB channel. + + @param {number} c 0-255 @param {number} amount @returns {number} */ +export function applyBrightness(c, amount) { + return Math.max(0, Math.min(255, c * amount)); +} + +/** Per-cell luminance of an image already drawn at the grid's own size. + + @param {ArrayLike} data RGBA, `cols * rows` pixels + @param {number} cols @param {number} rows + @param {number} brightness the wallpaper brightness the page renders at + @returns {number[]} */ +export function gridFromPixels(data, cols, rows, brightness = 1) { + const out = []; + for (let i = 0; i < cols * rows; i++) { + const p = i * 4; + out.push( + relativeLuminance( + applyBrightness(data[p], brightness), + applyBrightness(data[p + 1], brightness), + applyBrightness(data[p + 2], brightness), + ), + ); + } + return out; +} + +/** The grid cells a viewport rectangle covers, clamped to the grid. + + @param {{left:number, top:number, right:number, bottom:number}} rect + @param {number} vw @param {number} vh the viewport + @param {number} cols @param {number} rows + @returns {number[]} indices into a `gridFromPixels` array */ +export function cellsForRect(rect, vw, vh, cols, rows) { + if (!(vw > 0 && vh > 0)) return []; + const clamp = (/** @type {number} */ n, /** @type {number} */ max) => Math.max(0, Math.min(max - 1, n)); + const c0 = clamp(Math.floor((rect.left / vw) * cols), cols), + c1 = clamp(Math.ceil((rect.right / vw) * cols) - 1, cols), + r0 = clamp(Math.floor((rect.top / vh) * rows), rows), + r1 = clamp(Math.ceil((rect.bottom / vh) * rows) - 1, rows); + const out = []; + for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) out.push(r * cols + c); + return out; +} + +/** The tone for one rectangle of the viewport. + + @param {number[]} grid @param {number} cols @param {number} rows + @param {number} vw @param {number} vh + @param {{left:number, top:number, right:number, bottom:number}} rect + @returns {'light'|'dark'} */ +export function toneForRect(grid, cols, rows, vw, vh, rect) { + return toneForLuminances(cellsForRect(rect, vw, vh, cols, rows).map(i => grid[i])); +} + +/* ── The DOM half ─────────────────────────────────────────────────────────── */ + +export const GRID_COLS = 32; +export const GRID_ROWS = 18; + +/** Resolve any CSS colour to an sRGB triple. + + A canvas keeps its previous fillStyle when the value does not parse, so an + unreadable value has to be told apart from a real one. Two different seeds + cannot both be the answer. + + @param {string} value @returns {[number, number, number]|null} */ +export function parseCssColor(value) { + if (typeof value !== 'string' || !value.trim()) return null; + const read = (/** @type {string} */ seed) => { + const cv = document.createElement('canvas'); + cv.width = cv.height = 1; + const ctx = cv.getContext('2d'); + if (!ctx) return null; + ctx.fillStyle = seed; + ctx.fillStyle = value; + ctx.fillRect(0, 0, 1, 1); + const d = ctx.getImageData(0, 0, 1, 1).data; + return /** @type {[number, number, number]} */ ([d[0], d[1], d[2]]); + }; + const a = read('#000000'), + b = read('#ffffff'); + if (!a || !b) return null; + if (a[0] !== b[0] || a[1] !== b[1] || a[2] !== b[2]) return null; + return a; +} + +/** Read an image into a luminance grid. + + @param {HTMLImageElement} img a loaded image + @param {number} vw @param {number} vh the viewport it covers + @param {number} brightness + @returns {number[]|null} null when the image cannot be read */ +export function sampleImage(img, vw, vh, brightness) { + try { + const { sx, sy, sw, sh } = coverSourceRect(img.naturalWidth, img.naturalHeight, vw, vh); + const cv = document.createElement('canvas'); + cv.width = GRID_COLS; + cv.height = GRID_ROWS; + const ctx = cv.getContext('2d', { willReadFrequently: true }); + if (!ctx) return null; + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, GRID_COLS, GRID_ROWS); + return gridFromPixels(ctx.getImageData(0, 0, GRID_COLS, GRID_ROWS).data, GRID_COLS, GRID_ROWS, brightness); + } catch { + return null; + } +} + +/** Load a second copy of the wallpaper that a canvas is allowed to read. + + Requesting the displayed image with CORS would fail the whole wallpaper on a + host that does not allow it. + + @param {string} url @returns {Promise} */ +export function loadSamplingImage(url) { + return new Promise(resolve => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = url; + }); +} + +/** Every label the dashboard draws over the background. */ +const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label'; + +/** A folder overlay lays its own dark scrim over the background, so the labels + on it are not reading the background at all. */ +const SCRIM = '.folder-overlay, .folder-overlay-mobile'; + +/** Where a label sits once its page is the one on screen. Pages are one + viewport wide and slide horizontally, so an off-screen page's labels land on + the same patch of the fixed wallpaper as the page in view. + + @param {Element} label @param {Element|null} page + @returns {{left:number, top:number, right:number, bottom:number}} */ +function labelViewportRect(label, page) { + const r = label.getBoundingClientRect(); + if (!page) return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; + const p = page.getBoundingClientRect(); + return { left: r.left - p.left, top: r.top - p.top, right: r.right - p.left, bottom: r.bottom - p.top }; +} + +/** Tone every label against the current background. + + @param {{grid: number[]|null, tone: 'light'|'dark'|null}} bg the sampled + wallpaper, or a single tone for a solid colour + @param {Document|HTMLElement} [root] */ +export function applyLabelTones(bg, root = document) { + const vw = window.innerWidth, + vh = window.innerHeight; + for (const label of root.querySelectorAll(LABEL_SELECTOR)) { + if (label.closest(SCRIM)) { + label.removeAttribute('data-tone'); + } else if (bg.grid) { + const page = label.closest('.page'); + label.setAttribute( + 'data-tone', + toneForRect(bg.grid, GRID_COLS, GRID_ROWS, vw, vh, labelViewportRect(label, page)), + ); + } else if (bg.tone) { + label.setAttribute('data-tone', bg.tone); + } else { + label.removeAttribute('data-tone'); + } + } +} diff --git a/ui/test/label-contrast.test.mjs b/ui/test/label-contrast.test.mjs new file mode 100644 index 00000000..1df63330 --- /dev/null +++ b/ui/test/label-contrast.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + applyBrightness, + cellsForRect, + contrastRatio, + coverSourceRect, + gridFromPixels, + relativeLuminance, + toneForLuminances, + toneForRect, +} from '../js/label-contrast.js'; + +test('relative luminance matches the WCAG anchors', () => { + assert.equal(relativeLuminance(255, 255, 255), 1); + assert.equal(relativeLuminance(0, 0, 0), 0); + assert.ok(Math.abs(relativeLuminance(119, 119, 119) - 0.1845) < 0.001); +}); + +test('contrast ratio spans 1 to 21', () => { + assert.ok(Math.abs(contrastRatio(1, 0) - 21) < 1e-9); + assert.equal(contrastRatio(0.5, 0.5), 1); + assert.equal(contrastRatio(1, 0), contrastRatio(0, 1)); +}); + +test('a light background takes dark labels and a dark one takes light labels', () => { + assert.equal(toneForLuminances([relativeLuminance(240, 240, 240)]), 'dark'); + assert.equal(toneForLuminances([relativeLuminance(20, 22, 26)]), 'light'); +}); + +test('a patch of mixed brightness takes the tone with the better worst case', () => { + const mixed = [relativeLuminance(255, 255, 255), relativeLuminance(90, 90, 90)]; + assert.equal(toneForLuminances(mixed), 'dark'); +}); + +test('no samples leaves the labels light', () => { + assert.equal(toneForLuminances([]), 'light'); +}); + +test('cover crops the long axis and keeps the centre', () => { + const wide = coverSourceRect(2000, 1000, 1000, 1000); + assert.deepEqual(wide, { sx: 500, sy: 0, sw: 1000, sh: 1000 }); + const tall = coverSourceRect(1000, 2000, 1000, 1000); + assert.deepEqual(tall, { sx: 0, sy: 500, sw: 1000, sh: 1000 }); + assert.deepEqual(coverSourceRect(800, 600, 1600, 1200), { sx: 0, sy: 0, sw: 800, sh: 600 }); +}); + +test('brightness multiplies the channel and clamps', () => { + assert.equal(applyBrightness(100, 0.5), 50); + assert.equal(applyBrightness(200, 2), 255); + assert.equal(applyBrightness(10, 0), 0); +}); + +test('the page brightness is part of the measurement', () => { + const white = [255, 255, 255, 255]; + assert.equal(toneForLuminances(gridFromPixels(white, 1, 1, 1)), 'dark'); + assert.equal(toneForLuminances(gridFromPixels(white, 1, 1, 0.25)), 'light'); +}); + +test('a rectangle maps to every cell it covers', () => { + const rect = { left: 0, top: 0, right: 50, bottom: 50 }; + assert.deepEqual(cellsForRect(rect, 100, 100, 2, 2), [0]); + assert.deepEqual(cellsForRect({ left: 0, top: 0, right: 100, bottom: 100 }, 100, 100, 2, 2), [0, 1, 2, 3]); +}); + +test('a rectangle off the edge of the viewport still lands on the grid', () => { + const cells = cellsForRect({ left: -40, top: -40, right: 10, bottom: 10 }, 100, 100, 2, 2); + assert.deepEqual(cells, [0]); + assert.deepEqual(cellsForRect({ left: 90, top: 90, right: 400, bottom: 400 }, 100, 100, 2, 2), [3]); +}); + +test('a rectangle with no viewport to sit in yields no cells', () => { + assert.deepEqual(cellsForRect({ left: 0, top: 0, right: 1, bottom: 1 }, 0, 0, 2, 2), []); +}); + +test('two labels on one photo can take different tones', () => { + /* Left half white, right half near black. */ + const grid = [relativeLuminance(255, 255, 255), relativeLuminance(10, 10, 10)]; + const left = { left: 0, top: 0, right: 40, bottom: 100 }; + const right = { left: 60, top: 0, right: 100, bottom: 100 }; + assert.equal(toneForRect(grid, 2, 1, 100, 100, left), 'dark'); + assert.equal(toneForRect(grid, 2, 1, 100, 100, right), 'light'); +});