Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/static_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import gzip
import hashlib
import mimetypes
import re
from functools import lru_cache, wraps
from pathlib import Path
Expand Down Expand Up @@ -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"
Expand Down
20 changes: 10 additions & 10 deletions static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
13 changes: 13 additions & 0 deletions static/images/theme-backgrounds/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/images/theme-backgrounds/carbon-glass.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/images/theme-backgrounds/neon-circuit.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/images/theme-backgrounds/retro-amber.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 5 additions & 23 deletions static/js/theme-preference.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
63 changes: 38 additions & 25 deletions tests/e2e/auth-load-performance.spec.js
Original file line number Diff line number Diff line change
@@ -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');
}
});
36 changes: 36 additions & 0 deletions tests/e2e/theme-assets.spec.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
25 changes: 12 additions & 13 deletions tests/e2e/theme-system.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading