diff --git a/app/static_delivery.py b/app/static_delivery.py index f49a0fdc..2cb56687 100644 --- a/app/static_delivery.py +++ b/app/static_delivery.py @@ -2,6 +2,7 @@ import gzip import hashlib +import mimetypes import re from functools import lru_cache, wraps from pathlib import Path @@ -166,6 +167,9 @@ def init_static_delivery(app) -> None: Dynamic HTML and API responses are deliberately excluded so secrets, CSRF tokens, and user-specific data never share a compression context. """ + # Keep the image type deterministic on hosts without a WebP MIME mapping. + mimetypes.add_type("image/webp", ".webp") + if not isinstance(app.session_interface, SecureCookieSessionInterface): raise RuntimeError( "static delivery requires Flask's secure-cookie session interface" diff --git a/static/css/style.css b/static/css/style.css index 8a9e1ae2..2b8ea93b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -143,7 +143,7 @@ html { } body[data-theme="glass"] { - --theme-background-image-source: url("../images/theme-backgrounds/carbon-glass.png?v=95ff9eb547df4609"); + --theme-background-image-source: url("../images/theme-backgrounds/carbon-glass.webp?v=aeff5686958867eb"); --theme-background-opacity: 0.07; } @@ -176,7 +176,7 @@ body[data-theme="retro"] { --term-foreground: #eee4d6; --term-yellow: #dca144; --term-bright-yellow: #efbd65; - --theme-background-image-source: url("../images/theme-backgrounds/retro-amber.png?v=6dfcfa4b2da2ed89"); + --theme-background-image-source: url("../images/theme-backgrounds/retro-amber.webp?v=4c7adcbb43328f96"); --theme-background-opacity: 0.16; } @@ -207,7 +207,7 @@ body[data-theme="solar"] { --term-foreground: #e8f0f8; --term-blue: #65a0ed; --term-bright-blue: #8ab9f3; - --theme-background-image-source: url("../images/theme-backgrounds/navy-topography.png?v=2fc2ee91cd3a367a"); + --theme-background-image-source: url("../images/theme-backgrounds/navy-topography.webp?v=c09fca322ebc06d4"); --theme-background-opacity: 0.065; } @@ -256,7 +256,7 @@ body[data-theme="paper"] { --term-bright-magenta: #9165ab; --term-bright-cyan: #328b94; --term-bright-white: #15283a; - --theme-background-image-source: url("../images/theme-backgrounds/paper-blueprint.png?v=6446debbede0159c"); + --theme-background-image-source: url("../images/theme-backgrounds/paper-blueprint.webp?v=c1c005024abddf81"); --theme-background-opacity: 0.18; --theme-surface-shell: color-mix(in srgb, var(--bg-secondary) 62%, transparent); --theme-surface-modal: color-mix(in srgb, var(--bg-modal) 68%, transparent); @@ -305,7 +305,7 @@ body[data-theme="noir"] { --term-bright-magenta: #deaeff; --term-bright-cyan: #a0f0ff; --term-bright-white: #ffffff; - --theme-background-image-source: url("../images/theme-backgrounds/noir-architecture.png?v=19e5587f9a64e767"); + --theme-background-image-source: url("../images/theme-backgrounds/noir-architecture.webp?v=3ad241ea4b2938e0"); --theme-background-opacity: 0.06; } @@ -352,7 +352,7 @@ body[data-theme="arctic-ice"] { --term-bright-magenta: #dac8ff; --term-bright-cyan: #56d8f0; --term-bright-white: #ffffff; - --theme-background-image-source: url("../images/theme-backgrounds/arctic-frost.png?v=9ce8ec48bcfb1cec"); + --theme-background-image-source: url("../images/theme-backgrounds/arctic-frost.webp?v=7ff6c26a7d20a2b0"); --theme-background-opacity: 0.07; } @@ -398,7 +398,7 @@ body[data-theme="rose-gold"] { --term-bright-magenta: #ffb8d4; --term-bright-cyan: #a0f0dc; --term-bright-white: #ffffff; - --theme-background-image-source: url("../images/theme-backgrounds/rose-brushed-metal.png?v=b52519a406770ed3"); + --theme-background-image-source: url("../images/theme-backgrounds/rose-brushed-metal.webp?v=6b3edcac58d0da32"); --theme-background-opacity: 0.06; } @@ -444,7 +444,7 @@ body[data-theme="cyberpunk-neon"] { --term-bright-magenta: #f070ff; --term-bright-cyan: #33f5ff; --term-bright-white: #ffffff; - --theme-background-image-source: url("../images/theme-backgrounds/neon-circuit.png?v=8b50e5a15d57eedb"); + --theme-background-image-source: url("../images/theme-backgrounds/neon-circuit.webp?v=d02f4565dca68ba1"); --theme-background-opacity: 0.14; } @@ -491,7 +491,7 @@ body[data-theme="emerald-matrix"] { --term-bright-magenta: #70f0d4; --term-bright-cyan: #6ee7b7; --term-bright-white: #ffffff; - --theme-background-image-source: url("../images/theme-backgrounds/matrix-signal.png?v=6c7915bd78ae16d6"); + --theme-background-image-source: url("../images/theme-backgrounds/matrix-signal.webp?v=39615c6caac5725f"); --theme-background-opacity: 0.18; } @@ -538,7 +538,7 @@ body[data-theme="obsidian"] { --term-bright-magenta: #f070ff; --term-bright-cyan: #56d8f0; --term-bright-white: #ffffff; - --theme-background-image-source: url("../images/theme-backgrounds/obsidian-glass.png?v=4a998eeddd245ec7"); + --theme-background-image-source: url("../images/theme-backgrounds/obsidian-glass.webp?v=7e2bc163829f8f88"); --theme-background-opacity: 0.055; } diff --git a/static/images/theme-backgrounds/README.md b/static/images/theme-backgrounds/README.md new file mode 100644 index 00000000..c4bc7983 --- /dev/null +++ b/static/images/theme-backgrounds/README.md @@ -0,0 +1,13 @@ +# Theme background assets + +The PNG files are the original sources. Pages use the smaller WebP derivatives +at the same 1672 x 941 resolution. Keep the originals for future regeneration. + +Encode with Pillow using `format='WEBP', quality=90, method=6` (quality 89 for +`arctic-frost.png` to stay below the 150,000-byte per-image budget). Pillow is +an authoring tool only; no additional application dependency is required. + +After regeneration, update the matching URL in `static/css/style.css` with +the first 16 hexadecimal characters of the WebP file's SHA-256 digest. +Run the static asset reference tests and browser theme tests to verify the +content versions, size budget, appearance, and saved-theme behavior. diff --git a/static/images/theme-backgrounds/arctic-frost.webp b/static/images/theme-backgrounds/arctic-frost.webp new file mode 100644 index 00000000..f9a083dc Binary files /dev/null and b/static/images/theme-backgrounds/arctic-frost.webp differ diff --git a/static/images/theme-backgrounds/carbon-glass.webp b/static/images/theme-backgrounds/carbon-glass.webp new file mode 100644 index 00000000..cbfe3a1a Binary files /dev/null and b/static/images/theme-backgrounds/carbon-glass.webp differ diff --git a/static/images/theme-backgrounds/matrix-signal.webp b/static/images/theme-backgrounds/matrix-signal.webp new file mode 100644 index 00000000..a672cf63 Binary files /dev/null and b/static/images/theme-backgrounds/matrix-signal.webp differ diff --git a/static/images/theme-backgrounds/navy-topography.webp b/static/images/theme-backgrounds/navy-topography.webp new file mode 100644 index 00000000..09f84768 Binary files /dev/null and b/static/images/theme-backgrounds/navy-topography.webp differ diff --git a/static/images/theme-backgrounds/neon-circuit.webp b/static/images/theme-backgrounds/neon-circuit.webp new file mode 100644 index 00000000..1cb3a9bd Binary files /dev/null and b/static/images/theme-backgrounds/neon-circuit.webp differ diff --git a/static/images/theme-backgrounds/noir-architecture.webp b/static/images/theme-backgrounds/noir-architecture.webp new file mode 100644 index 00000000..0779b441 Binary files /dev/null and b/static/images/theme-backgrounds/noir-architecture.webp differ diff --git a/static/images/theme-backgrounds/obsidian-glass.webp b/static/images/theme-backgrounds/obsidian-glass.webp new file mode 100644 index 00000000..c295eeed Binary files /dev/null and b/static/images/theme-backgrounds/obsidian-glass.webp differ diff --git a/static/images/theme-backgrounds/paper-blueprint.webp b/static/images/theme-backgrounds/paper-blueprint.webp new file mode 100644 index 00000000..3d292c8e Binary files /dev/null and b/static/images/theme-backgrounds/paper-blueprint.webp differ diff --git a/static/images/theme-backgrounds/retro-amber.webp b/static/images/theme-backgrounds/retro-amber.webp new file mode 100644 index 00000000..026e4243 Binary files /dev/null and b/static/images/theme-backgrounds/retro-amber.webp differ diff --git a/static/images/theme-backgrounds/rose-brushed-metal.webp b/static/images/theme-backgrounds/rose-brushed-metal.webp new file mode 100644 index 00000000..1e342a35 Binary files /dev/null and b/static/images/theme-backgrounds/rose-brushed-metal.webp differ diff --git a/static/js/theme-preference.js b/static/js/theme-preference.js index 8888d9ed..2ae17b2b 100644 --- a/static/js/theme-preference.js +++ b/static/js/theme-preference.js @@ -49,28 +49,6 @@ return themeId; } - function revealDeferredBackground(element) { - if (!element?.hasAttribute('data-defer-theme-background')) { - return false; - } - const reveal = () => { - element.setAttribute('data-theme-background-ready', ''); - }; - const schedule = () => { - if (typeof global.requestIdleCallback === 'function') { - global.requestIdleCallback(reveal, { timeout: 1000 }); - } else { - global.setTimeout(reveal, 0); - } - }; - if (document.readyState === 'complete') { - schedule(); - } else { - global.addEventListener('load', schedule, { once: true }); - } - return true; - } - global.ThemePreference = Object.freeze({ applyStored, isValid, @@ -81,5 +59,9 @@ if (document.body?.hasAttribute('data-use-theme-preference')) { applyStored(document.body); } - revealDeferredBackground(document.body); + // Resolve the saved theme before allowing CSS to request its background. + // Do not wait for load or idle: the image is part of the initial appearance. + if (document.body?.hasAttribute('data-defer-theme-background')) { + document.body.setAttribute('data-theme-background-ready', ''); + } })(window); diff --git a/tests/e2e/auth-load-performance.spec.js b/tests/e2e/auth-load-performance.spec.js index 8af99794..4821a733 100644 --- a/tests/e2e/auth-load-performance.spec.js +++ b/tests/e2e/auth-load-performance.spec.js @@ -1,34 +1,47 @@ const { test, expect } = require('playwright/test'); const { assertNoExternalRequests } = require('./helpers'); -test('login load does not wait for the decorative theme background', async ({ page }) => { +for (const theme of ['glass', 'paper']) { + test(`login requests its ${theme} background before unrelated assets finish`, async ({ page }) => { + assertNoExternalRequests(page); + await page.addInitScript(value => localStorage.setItem('websshTheme', value), theme); + let releaseLogo; + const logoGate = new Promise(resolve => { releaseLogo = resolve; }); + const backgrounds = []; + page.on('request', request => { + if (request.url().includes('/theme-backgrounds/')) backgrounds.push(request.url()); + }); + await page.route('**/webssh-logo.svg*', async route => { + await logoGate; + await route.continue(); + }); + try { + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + await expect.poll(() => backgrounds.length, { timeout: 1500 }).toBe(1); + expect(backgrounds[0]).toContain(theme === 'paper' ? 'paper-blueprint.' : 'carbon-glass.'); + await expect(page.locator('body')).toHaveAttribute('data-theme', theme); + } finally { + releaseLogo(); + await page.waitForLoadState('load'); + } + }); +} + +test('login remains usable while its background is still downloading', async ({ page }) => { assertNoExternalRequests(page); let releaseBackground; - let backgroundRequested = false; - const backgroundGate = new Promise(resolve => { - releaseBackground = resolve; - }); + const gate = new Promise(resolve => { releaseBackground = resolve; }); await page.route('**/theme-backgrounds/**', async route => { - backgroundRequested = true; - await backgroundGate; + await gate; await route.continue(); }); - - const navigation = page.goto('/login', { waitUntil: 'load' }); - const loadedWithoutBackground = await Promise.race([ - navigation.then(() => true), - new Promise(resolve => setTimeout(() => resolve(false), 1500)), - ]); - - releaseBackground(); - await navigation; - - expect(loadedWithoutBackground).toBe(true); - await expect(page.getByRole('heading', { name: 'Access your SSH workspace' })) - .toBeVisible(); - await expect.poll(() => backgroundRequested).toBe(true); - await expect(page.locator('body')).toHaveAttribute( - 'data-theme-background-ready', - '', - ); + try { + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Access your SSH workspace' })).toBeVisible(); + await page.locator('input[name="username"]').fill('still-responsive'); + await expect(page.locator('input[name="username"]')).toHaveValue('still-responsive'); + } finally { + releaseBackground(); + await page.waitForLoadState('load'); + } }); diff --git a/tests/e2e/theme-assets.spec.js b/tests/e2e/theme-assets.spec.js new file mode 100644 index 00000000..b4a5c7c0 --- /dev/null +++ b/tests/e2e/theme-assets.spec.js @@ -0,0 +1,36 @@ +const {test, expect} = require('playwright/test'); +const {assertNoExternalRequests} = require('./helpers'); + +const themes = ['glass', 'retro', 'solar', 'paper', 'noir', 'arctic-ice', 'rose-gold', 'cyberpunk-neon', 'emerald-matrix', 'obsidian']; + +test('every theme background decodes at the original resolution', async ({page}) => { + assertNoExternalRequests(page); + await page.goto('/login'); + for (const theme of themes) { + const dimensions = await page.evaluate(async value => { + document.body.dataset.theme = value; + const source = getComputedStyle(document.body, '::before').backgroundImage; + const url = source.match(/url\(["']?([^"')]+)["']?\)/)[1]; + const image = new Image(); + image.src = url; + await image.decode(); + return [image.naturalWidth, image.naturalHeight]; + }, theme); + expect(dimensions, theme).toEqual([1672, 941]); + } +}); + +test('login remains usable when storage and the background are unavailable', async ({page}) => { + assertNoExternalRequests(page); + await page.addInitScript(() => { + Object.defineProperty(window, 'localStorage', {get() { throw new DOMException('Blocked', 'SecurityError'); }}); + }); + await page.route('**/theme-backgrounds/**', route => route.abort()); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/login'); + await expect(page.locator('body')).toHaveAttribute('data-theme', 'glass'); + await page.locator('input[name="username"]').fill('fallback-works'); + await expect(page.locator('input[name="username"]')).toHaveValue('fallback-works'); + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/theme-system.spec.js b/tests/e2e/theme-system.spec.js index e5868d38..6d1aa282 100644 --- a/tests/e2e/theme-system.spec.js +++ b/tests/e2e/theme-system.spec.js @@ -15,19 +15,19 @@ const THEME_IDS = [ ]; const PROFESSIONAL_THEME_BACKGROUNDS = { - glass: 'carbon-glass.png', - solar: 'navy-topography.png', - paper: 'paper-blueprint.png', - noir: 'noir-architecture.png', - 'arctic-ice': 'arctic-frost.png', - 'rose-gold': 'rose-brushed-metal.png', - obsidian: 'obsidian-glass.png', + glass: 'carbon-glass.webp', + solar: 'navy-topography.webp', + paper: 'paper-blueprint.webp', + noir: 'noir-architecture.webp', + 'arctic-ice': 'arctic-frost.webp', + 'rose-gold': 'rose-brushed-metal.webp', + obsidian: 'obsidian-glass.webp', }; const FUN_THEME_BACKGROUNDS = { - retro: 'retro-amber.png', - 'cyberpunk-neon': 'neon-circuit.png', - 'emerald-matrix': 'matrix-signal.png', + retro: 'retro-amber.webp', + 'cyberpunk-neon': 'neon-circuit.webp', + 'emerald-matrix': 'matrix-signal.webp', }; async function openThemeSettings(page) { @@ -283,14 +283,13 @@ test('the last selected theme styles the next login screen', async ({ page }) => await page.goto('/login'); await expect(page).toHaveURL(/\/login/); await expect(page.locator('body')).toHaveAttribute('data-theme', 'paper'); - await expect(page.locator('body')).toHaveAttribute('data-theme-background-ready', ''); const authTheme = await page.evaluate(() => ({ backdropImage: getComputedStyle(document.body, '::before').backgroundImage, cardImage: getComputedStyle(document.querySelector('.auth-card')).backgroundImage, })); - expect(authTheme.backdropImage).toContain('paper-blueprint.png'); - expect(authTheme.cardImage).toContain('paper-blueprint.png'); + expect(authTheme.backdropImage).toContain('paper-blueprint.webp'); + expect(authTheme.cardImage).toContain('paper-blueprint.webp'); for (const path of ['/login', '/register']) { await page.goto(path); diff --git a/tests/js/theme-preference.test.js b/tests/js/theme-preference.test.js index 092d8e73..1c806ce8 100644 --- a/tests/js/theme-preference.test.js +++ b/tests/js/theme-preference.test.js @@ -14,40 +14,19 @@ function createBody(attributes) { }; } -test('a deferred theme background starts only after load and an idle turn', () => { +test('the preferred background is available while the document is still loading', () => { const body = createBody({ 'data-theme': 'glass', 'data-use-theme-preference': '', 'data-defer-theme-background': '', }); - const listeners = new Map(); - let idleCallback; const window = { - addEventListener(name, callback, options) { - listeners.set(name, { callback, options }); - }, - localStorage: { - getItem() { return 'paper'; }, - setItem() {}, - }, - requestIdleCallback(callback, options) { - idleCallback = { callback, options }; - }, - setTimeout() { throw new Error('idle callback should be preferred'); }, + addEventListener() { throw new Error('background must not wait for load'); }, + requestIdleCallback() { throw new Error('background must not wait for idle'); }, + localStorage: { getItem() { return 'paper'; }, setItem() {} }, }; - const document = { body, readyState: 'loading' }; - - vm.runInContext(source, vm.createContext({ document, window })); - + vm.runInContext(source, vm.createContext({document: {body, readyState: 'loading'}, window})); assert.equal(body.getAttribute('data-theme'), 'paper'); - assert.equal(body.hasAttribute('data-theme-background-ready'), false); - assert.equal(listeners.get('load').options.once, true); - - listeners.get('load').callback(); - assert.equal(body.hasAttribute('data-theme-background-ready'), false); - assert.equal(idleCallback.options.timeout, 1000); - - idleCallback.callback(); assert.equal(body.hasAttribute('data-theme-background-ready'), true); }); @@ -74,3 +53,38 @@ test('ordinary pages keep their theme background behavior unchanged', () => { assert.equal(loadListenerAdded, false); assert.equal(body.hasAttribute('data-theme-background-ready'), false); }); + +for (const stored of [null, 'not-a-theme', '']) { + test(`invalid or missing preference keeps the server theme (${stored})`, () => { + const body = createBody({'data-theme': 'glass', 'data-use-theme-preference': '', 'data-defer-theme-background': ''}); + const window = {localStorage: {getItem() { return stored; }, setItem() {}}}; + vm.runInContext(source, vm.createContext({document: {body}, window})); + assert.equal(body.getAttribute('data-theme'), 'glass'); + assert.equal(body.hasAttribute('data-theme-background-ready'), true); + }); +} + +test('blocked browser storage does not prevent the background or preference API', () => { + const body = createBody({'data-theme': 'glass', 'data-use-theme-preference': '', 'data-defer-theme-background': ''}); + const window = {get localStorage() { throw new Error('Storage blocked'); }}; + vm.runInContext(source, vm.createContext({document: {body}, window})); + assert.equal(body.getAttribute('data-theme'), 'glass'); + assert.equal(body.hasAttribute('data-theme-background-ready'), true); + assert.equal(window.ThemePreference.store('paper'), false); + assert.equal(window.ThemePreference.read(), null); +}); + +test('server-selected password-change theme wins over a stored preference', () => { + const body = createBody({'data-theme': 'retro', 'data-defer-theme-background': ''}); + const window = {localStorage: {getItem() { return 'paper'; }}}; + vm.runInContext(source, vm.createContext({document: {body}, window})); + assert.equal(body.getAttribute('data-theme'), 'retro'); + assert.equal(body.hasAttribute('data-theme-background-ready'), true); +}); + +test('loading the helper without a body still exposes its validation API', () => { + const window = {}; + vm.runInContext(source, vm.createContext({document: {body: null}, window})); + assert.equal(window.ThemePreference.isValid('paper'), true); + assert.equal(window.ThemePreference.isValid('invalid'), false); +}); diff --git a/tests/test_static_asset_references.py b/tests/test_static_asset_references.py index 6ed3118c..4ecb2f0f 100644 --- a/tests/test_static_asset_references.py +++ b/tests/test_static_asset_references.py @@ -65,3 +65,12 @@ def test_authentication_translation_bundle_stays_within_its_page_load_budget(): assert auth_size < 100_000 assert auth_size < full_size * 0.2 + + +def test_theme_backgrounds_have_a_small_first_load_budget(): + source = Path('static/css/style.css').read_text(encoding='utf-8') + names = re.findall(r'images/theme-backgrounds/([^?"\)]+)', source) + assert len(set(names)) == 10 + for name in names: + asset = Path('static/images/theme-backgrounds') / name + assert asset.stat().st_size < 150_000, name diff --git a/tests/test_static_delivery.py b/tests/test_static_delivery.py index 260e4147..0708070f 100644 --- a/tests/test_static_delivery.py +++ b/tests/test_static_delivery.py @@ -208,3 +208,43 @@ def test_static_response_with_an_auth_cookie_mutation_is_never_public(client): assert response.status_code == 200 assert response.headers['Cache-Control'] == 'private, no-store' assert response.headers.getlist('Set-Cookie') + + +def test_webp_backgrounds_keep_image_headers_and_cache_validators(client): + from pathlib import Path + + assets = sorted(Path('static/images/theme-backgrounds').glob('*.webp')) + assert len(assets) == 10 + for asset in assets: + target = _asset_url(client, f'images/theme-backgrounds/{asset.name}') + response = client.get(target, headers={'Accept-Encoding': 'gzip'}) + assert response.status_code == 200 + assert response.mimetype == 'image/webp' + assert response.data == asset.read_bytes() + assert 'Content-Encoding' not in response.headers + assert response.headers['Cache-Control'] == 'public, max-age=31536000, immutable' + assert response.headers['X-Content-Type-Options'] == 'nosniff' + assert 'Set-Cookie' not in response.headers + assert 'Cookie' not in response.headers.get('Vary', '') + cached = client.get(target, headers={'If-None-Match': response.headers['ETag']}) + assert cached.status_code == 304 + assert cached.data == b'' + head = client.head(target) + assert head.status_code == 200 + assert head.content_length == len(response.data) + assert head.data == b'' + + +def test_webp_mime_registration_overrides_an_incorrect_host_mapping(monkeypatch, tmp_path): + import mimetypes + from flask import Flask + from app.static_delivery import init_static_delivery + + mimetypes.init() + monkeypatch.setitem(mimetypes.types_map, '.webp', 'application/octet-stream') + (tmp_path / 'background.webp').write_bytes(b'RIFF\x00\x00\x00\x00WEBP') + app = Flask('webp_mime_test', static_folder=str(tmp_path), static_url_path='/static') + init_static_delivery(app) + response = app.test_client().get('/static/background.webp') + assert response.status_code == 200 + assert response.mimetype == 'image/webp'