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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ A defence-in-depth pass across the API, token, export, and deployment surfaces:
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `<head>` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.

### Internal / contributor-facing

Expand Down
5 changes: 4 additions & 1 deletion src/node/utils/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ export type SettingsType = {
from: string | null;
auth: {user: string; pass: string} | null;
},
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings" | "enablePluginPadOptions" | "privacyBanner">,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enableDarkMode" | "enablePadWideSettings" | "enablePluginPadOptions" | "privacyBanner">,
}

const settings: SettingsType = {
Expand Down Expand Up @@ -871,6 +871,9 @@ const settings: SettingsType = {
title: settings.title,
skinName: settings.skinName,
skinVariants: settings.skinVariants,
// Needed so pad.html / timeslider.html only emit the dark theme-color
// variant when dark mode can actually be reached client-side (#7606).
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
enablePluginPadOptions: settings.enablePluginPadOptions,
privacyBanner: getPublicPrivacyBanner(),
Expand Down
15 changes: 15 additions & 0 deletions src/node/utils/SkinColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,18 @@ export const configuredToolbarColor = (
if (skinName !== 'colibris') return null;
return toolbarColorForTokens((skinVariants || '').split(/\s+/).filter(Boolean));
};

// The toolbar color colibris auto-switches to on a dark-OS client (pad.ts
// forces 'super-dark-toolbar' when enableDarkMode is on and the system is in
// dark mode). Templates emit this in a `media="(prefers-color-scheme: dark)"`
// theme-color meta so iOS Safari — which colors the address bar at parse time
// and does not reliably repaint when JS mutates the tag later — picks the
// right color at first paint instead of staying on the light baseline
// (issue #7606). Returns null for non-colibris skins, matching
// configuredToolbarColor.
export const darkToolbarColor = (
skinName: string | undefined | null,
): string | null => {
if (skinName !== 'colibris') return null;
return toolbarColorForTokens(['super-dark-toolbar']);
};
26 changes: 14 additions & 12 deletions src/static/js/skin_variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,21 @@ const containers = ['editor', 'background', 'toolbar'];
const colors = ['super-light', 'light', 'dark', 'super-dark'];

// Keep <meta name="theme-color"> in sync with the toolbar the user actually
// sees. The server emits a baseline derived from settings.skinVariants, but
// pad.ts may flip the toolbar to super-dark on first paint (enableDarkMode
// + prefers-color-scheme:dark + no localStorage white-mode override) and
// the user can toggle via #options-darkmode. Without this, dark-mode users
// keep the light meta and see a white address bar above a dark toolbar
// (issue #7606 follow-up). Color resolution lives in skin_toolbar_colors so
// the server-rendered baseline and the client updates share one source of
// truth — Qodo flagged the prior duplicated table as a drift hazard.
// sees. The server emits a media-scoped light + dark pair so iOS Safari picks
// the right color at first paint (issue #7606); on desktop/Android the user
// can still override the system scheme via #options-darkmode. When that
// happens we point EVERY theme-color meta at the chosen color so the explicit
// choice wins regardless of which media query the browser is currently
// matching — otherwise toggling light while the OS is dark (or vice versa)
// would update a tag the browser is ignoring. Color resolution lives in
// skin_toolbar_colors so the server-rendered baseline and the client updates
// share one source of truth — Qodo flagged the prior duplicated table as a
// drift hazard.
const updateThemeColorMeta = (newClasses: string[]) => {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) return;
meta.setAttribute('content',
toolbarColorForTokens(newClasses.join(' ').split(/\s+/).filter(Boolean)));
const metas = document.querySelectorAll('meta[name="theme-color"]');
if (!metas.length) return;
const color = toolbarColorForTokens(newClasses.join(' ').split(/\s+/).filter(Boolean));
metas.forEach((meta) => { meta.setAttribute('content', color); });
};

// add corresponding classes when config change
Expand Down
44 changes: 39 additions & 5 deletions src/templates/pad.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
// theme-color matches the configured toolbar so mobile address bars don't
// paint a mismatched system color above the toolbar on first paint. We do
// not emit a prefers-color-scheme: dark variant: the client-side dark-mode
// auto-switch is gated on enableDarkMode, matchMedia, and a localStorage
// white-mode override, none of which a media query can express.
// paint a mismatched system color above the toolbar on first paint. We emit
// a prefers-color-scheme: dark variant scoped with the `media` attribute so
// iOS Safari — which colors the address bar at parse time and does not
// reliably repaint when JS mutates the tag later — picks the dark toolbar
// color at first paint on a dark-OS client (issue #7606). The dark variant
// is only emitted when enableDarkMode is on, since that is what gates the
// client-side auto-switch; the manual #options-darkmode toggle and the
// localStorage white-mode override are still handled by skin_variants.ts.
var configuredColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants);
var darkColor = settings.enableDarkMode ? skinColors.darkToolbarColor(settings.skinName) : null;
%>
<!doctype html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
Expand Down Expand Up @@ -49,9 +54,38 @@
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<% if (configuredColor) { %><meta name="theme-color" content="<%=configuredColor%>"><% } %>
<% if (configuredColor) { %><meta name="theme-color" content="<%=configuredColor%>"<% if (darkColor) { %> media="(prefers-color-scheme: light)"<% } %>><% } %>
<% if (darkColor) { %><meta name="theme-color" content="<%=darkColor%>" media="(prefers-color-scheme: dark)"><% } %>
<link rel="shortcut icon" href="../favicon.ico">

<% if (darkColor) { %>
<script>
// Apply the dark skin classes before the stylesheet paints so dark-OS
// users don't see a white flash while the JS bundle loads (issue #7606).
// enableDarkMode being on is implied by this script being emitted; the
// matchMedia + localStorage white-mode checks mirror the auto-switch in
// pad.ts, which still runs on init to wire up the #options-darkmode toggle
// and theme the editor iframes (those don't exist yet at parse time).
(function () {
try {
// The skin-variants builder lets the user pick variants by hand; pad.ts
// skips the auto-dark switch there, so we must too.
if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') return;
if (localStorage.getItem('ep_darkMode') === 'false') return;
if (!(window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) return;
Comment on lines +69 to +76

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6f7b07b. Added the #skinvariantsbuilder guard to the pre-paint script in both pad.html and timeslider.html so it matches pad.ts's auto-switch condition exactly, plus a backend assertion to prevent regression.

var el = document.documentElement;
['super-light', 'light', 'dark', 'super-dark'].forEach(function (c) {
['editor', 'background', 'toolbar'].forEach(function (k) {
el.classList.remove(c + '-' + k);
});
});
el.classList.add('super-dark-editor', 'dark-background', 'super-dark-toolbar');
} catch (e) { /* localStorage/matchMedia unavailable — fall back to light */ }
})();
</script>
<% } %>

<% e.begin_block("styles"); %>
<link href="../static/css/pad.css?v=<%=settings.randomVersionString%>" rel="stylesheet">

Expand Down
27 changes: 26 additions & 1 deletion src/templates/timeslider.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
var themeColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants);
// See pad.html: emit a media-scoped dark variant so iOS Safari picks the
// dark toolbar color at first paint on a dark-OS client (issue #7606).
var darkThemeColor = settings.enableDarkMode ? skinColors.darkToolbarColor(settings.skinName) : null;
%>
<!doctype html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=settings.skinVariants%>">
Expand Down Expand Up @@ -39,8 +42,30 @@
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<% if (themeColor) { %><meta name="theme-color" content="<%=themeColor%>"><% } %>
<% if (themeColor) { %><meta name="theme-color" content="<%=themeColor%>"<% if (darkThemeColor) { %> media="(prefers-color-scheme: light)"<% } %>><% } %>
<% if (darkThemeColor) { %><meta name="theme-color" content="<%=darkThemeColor%>" media="(prefers-color-scheme: dark)"><% } %>
<link rel="shortcut icon" href="../../favicon.ico">
<% if (darkThemeColor) { %>
<script>
// See pad.html: apply the dark skin classes before the stylesheet paints
// so dark-OS users don't see a white flash on load (issue #7606).
(function () {
try {
if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') return;
if (localStorage.getItem('ep_darkMode') === 'false') return;
if (!(window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) return;
var el = document.documentElement;
Comment on lines +53 to +58

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6f7b07b alongside pad.html — same #skinvariantsbuilder guard added here.

['super-light', 'light', 'dark', 'super-dark'].forEach(function (c) {
['editor', 'background', 'toolbar'].forEach(function (k) {
el.classList.remove(c + '-' + k);
});
});
el.classList.add('super-dark-editor', 'dark-background', 'super-dark-toolbar');
} catch (e) { /* localStorage/matchMedia unavailable — fall back to light */ }
})();
</script>
<% } %>
<% e.begin_block("timesliderStyles"); %>
<link rel="stylesheet" href="../../static/css/pad.css?v=<%=settings.randomVersionString%>">
<link rel="stylesheet" href="../../static/css/iframe_editor.css?v=<%=settings.randomVersionString%>">
Expand Down
91 changes: 83 additions & 8 deletions src/tests/backend/specs/specialpages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,27 +60,44 @@ describe(__filename, function () {
beforeEach(function () {
backups.skinName = settings.skinName;
backups.skinVariants = settings.skinVariants;
backups.enableDarkMode = settings.enableDarkMode;
});
afterEach(function () {
settings.skinName = backups.skinName;
settings.skinVariants = backups.skinVariants;
settings.enableDarkMode = backups.enableDarkMode;
});

it('pad page emits theme-color matching the configured colibris toolbar', async function () {
it('pad page emits a light baseline and a media-scoped dark variant', async function () {
// Issue #7606: iOS Safari colors the address bar at parse time and does
// not reliably repaint when JS mutates the meta later, so the dark
// toolbar color must be selectable at first paint via a media query.
settings.skinName = 'colibris';
settings.skinVariants = 'super-light-toolbar super-light-editor light-background';
settings.enableDarkMode = true;
const res = await agent.get('/p/testpad').expect(200);
assert.match(res.text,
/<meta name="theme-color" content="#ffffff" media="\(prefers-color-scheme: light\)">/);
assert.match(res.text,
/<meta name="theme-color" content="#485365" media="\(prefers-color-scheme: dark\)">/);
});

it('pad page omits the dark variant when enableDarkMode is off', async function () {
// With dark mode disabled the client never auto-switches, so the address
// bar should stay on the unscoped light baseline.
settings.skinName = 'colibris';
settings.skinVariants = 'super-light-toolbar super-light-editor light-background';
settings.enableDarkMode = false;
const res = await agent.get('/p/testpad').expect(200);
assert.match(res.text, /<meta name="theme-color" content="#ffffff">/);
// No media-query variants — runtime dark-mode also depends on localStorage,
// which a server-rendered media query cannot account for.
assert.doesNotMatch(res.text, /prefers-color-scheme/);
});

it('pad page tracks an explicit dark toolbar variant', async function () {
settings.skinName = 'colibris';
settings.skinVariants = 'dark-toolbar dark-editor dark-background';
const res = await agent.get('/p/testpad').expect(200);
assert.match(res.text, /<meta name="theme-color" content="#576273">/);
assert.match(res.text, /<meta name="theme-color" content="#576273"/);
});

it('pad page omits theme-color for non-colibris skins', async function () {
Expand All @@ -90,14 +107,17 @@ describe(__filename, function () {
assert.doesNotMatch(res.text, /theme-color/);
});

it('timeslider page emits theme-color matching the configured toolbar', async function () {
it('timeslider page emits a light baseline and a media-scoped dark variant', async function () {
settings.skinName = 'colibris';
settings.skinVariants = 'super-dark-toolbar super-dark-editor dark-background';
settings.skinVariants = 'super-light-toolbar super-light-editor light-background';
settings.enableDarkMode = true;
// Issue #7659: /p/:pad/timeslider redirects unless ?embed=1 — that
// query is the iframe path that still serves the timeslider HTML.
const res = await agent.get('/p/testpad/timeslider?embed=1').expect(200);
assert.match(res.text, /<meta name="theme-color" content="#485365">/);
assert.doesNotMatch(res.text, /prefers-color-scheme/);
assert.match(res.text,
/<meta name="theme-color" content="#ffffff" media="\(prefers-color-scheme: light\)">/);
assert.match(res.text,
/<meta name="theme-color" content="#485365" media="\(prefers-color-scheme: dark\)">/);
});

it('timeslider page omits theme-color for non-colibris skins', async function () {
Expand All @@ -107,4 +127,59 @@ describe(__filename, function () {
assert.doesNotMatch(res.text, /theme-color/);
});
});

describe('dark-mode flash prevention', function () {
const backups:MapArrayType<any> = {};
beforeEach(function () {
backups.skinName = settings.skinName;
backups.enableDarkMode = settings.enableDarkMode;
});
afterEach(function () {
settings.skinName = backups.skinName;
settings.enableDarkMode = backups.enableDarkMode;
});

const PREPAINT = "classList.add('super-dark-editor', 'dark-background', 'super-dark-toolbar')";

it('pad page inlines the pre-paint dark-mode script before the stylesheet', async function () {
// Issue #7606: without this, dark-OS users see the page painted light
// until the JS bundle runs and flips the skin classes. The inline script
// must come before pad.css so the dark classes are on <html> at first
// paint.
settings.skinName = 'colibris';
settings.enableDarkMode = true;
const res = await agent.get('/p/testpad').expect(200);
const scriptIdx = res.text.indexOf(PREPAINT);
const cssIdx = res.text.indexOf('css/pad.css');
assert(scriptIdx !== -1, 'expected the pre-paint dark-mode script in the pad page');
assert(cssIdx !== -1 && scriptIdx < cssIdx,
'pre-paint dark-mode script must come before pad.css so it applies before first paint');
// Must skip the auto-dark switch on the skin-variants builder, matching
// pad.ts (so it can't fight the builder UI on a dark-OS client).
assert(res.text.includes('#skinvariantsbuilder'),
'pre-paint script must guard against the #skinvariantsbuilder hash like pad.ts');
});

it('timeslider page inlines the pre-paint dark-mode script', async function () {
settings.skinName = 'colibris';
settings.enableDarkMode = true;
const res = await agent.get('/p/testpad/timeslider?embed=1').expect(200);
assert(res.text.includes(PREPAINT),
'expected the pre-paint dark-mode script in the timeslider page');
});

it('omits the pre-paint script when dark mode is disabled', async function () {
settings.skinName = 'colibris';
settings.enableDarkMode = false;
const res = await agent.get('/p/testpad').expect(200);
assert(!res.text.includes(PREPAINT));
});

it('omits the pre-paint script for non-colibris skins', async function () {
settings.skinName = 'no-skin';
settings.enableDarkMode = true;
const res = await agent.get('/p/testpad').expect(200);
assert(!res.text.includes(PREPAINT));
});
});
});
Loading
Loading