diff --git a/CHANGELOG.md b/CHANGELOG.md
index ead1143007c..f4729a5061e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` 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
diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts
index 4dd34a616ba..1878e744fdb 100644
--- a/src/node/utils/Settings.ts
+++ b/src/node/utils/Settings.ts
@@ -369,7 +369,7 @@ export type SettingsType = {
from: string | null;
auth: {user: string; pass: string} | null;
},
- getPublicSettings: () => Pick,
+ getPublicSettings: () => Pick,
}
const settings: SettingsType = {
@@ -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(),
diff --git a/src/node/utils/SkinColors.ts b/src/node/utils/SkinColors.ts
index 74b9bedbcfe..5698ea9a3c0 100644
--- a/src/node/utils/SkinColors.ts
+++ b/src/node/utils/SkinColors.ts
@@ -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']);
+};
diff --git a/src/static/js/skin_variants.ts b/src/static/js/skin_variants.ts
index 22c055bf817..cf193cd7de8 100644
--- a/src/static/js/skin_variants.ts
+++ b/src/static/js/skin_variants.ts
@@ -7,19 +7,21 @@ const containers = ['editor', 'background', 'toolbar'];
const colors = ['super-light', 'light', 'dark', 'super-dark'];
// Keep 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
diff --git a/src/templates/pad.html b/src/templates/pad.html
index 151334a4ec3..6bbc359c6ef 100644
--- a/src/templates/pad.html
+++ b/src/templates/pad.html
@@ -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;
%>
@@ -49,9 +54,38 @@
- <% if (configuredColor) { %><% } %>
+ <% if (configuredColor) { %> media="(prefers-color-scheme: light)"<% } %>><% } %>
+ <% if (darkColor) { %><% } %>
+ <% if (darkColor) { %>
+
+ <% } %>
+
<% e.begin_block("styles"); %>
diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html
index 0e9eebb8f27..f070fd73f54 100644
--- a/src/templates/timeslider.html
+++ b/src/templates/timeslider.html
@@ -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;
%>
@@ -39,8 +42,30 @@
- <% if (themeColor) { %><% } %>
+ <% if (themeColor) { %> media="(prefers-color-scheme: light)"<% } %>><% } %>
+ <% if (darkThemeColor) { %><% } %>
+ <% if (darkThemeColor) { %>
+
+ <% } %>
<% e.begin_block("timesliderStyles"); %>
diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.ts
index 17b7c49a86a..343b29f23eb 100644
--- a/src/tests/backend/specs/specialpages.ts
+++ b/src/tests/backend/specs/specialpages.ts
@@ -60,19 +60,36 @@ 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,
+ //);
+ assert.match(res.text,
+ //);
+ });
+
+ 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, //);
- // 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/);
});
@@ -80,7 +97,7 @@ describe(__filename, 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, //);
+ assert.match(res.text, //);
- assert.doesNotMatch(res.text, /prefers-color-scheme/);
+ assert.match(res.text,
+ //);
+ assert.match(res.text,
+ //);
});
it('timeslider page omits theme-color for non-colibris skins', async function () {
@@ -107,4 +127,59 @@ describe(__filename, function () {
assert.doesNotMatch(res.text, /theme-color/);
});
});
+
+ describe('dark-mode flash prevention', function () {
+ const backups:MapArrayType = {};
+ 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 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));
+ });
+ });
});
diff --git a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts
index 0393b913bde..056d232e986 100644
--- a/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts
+++ b/src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts
@@ -1,16 +1,26 @@
import {expect, test, Page} from '@playwright/test';
import {goToNewPad} from '../helper/padHelper';
-const themeColor = (page: Page) =>
- page.locator('meta[name="theme-color"]').getAttribute('content');
+// Issue #7606: the server emits a media-scoped pair of theme-color metas so
+// iOS Safari can pick the right address-bar color at first paint without JS.
+// Read each by its media attribute; the browser applies whichever matches the
+// active color scheme.
+const lightThemeColor = (page: Page) =>
+ page.locator('meta[name="theme-color"][media="(prefers-color-scheme: light)"]')
+ .getAttribute('content');
+const darkThemeColor = (page: Page) =>
+ page.locator('meta[name="theme-color"][media="(prefers-color-scheme: dark)"]')
+ .getAttribute('content');
test.describe('light color scheme', () => {
test.use({colorScheme: 'light'});
test('theme-color meta tracks the dark-mode toggle', async ({page}) => {
await goToNewPad(page);
- // Server emits the light baseline derived from settings.skinVariants.
- expect(await themeColor(page)).toBe('#ffffff');
+ // First paint: light baseline is active, and the dark variant is present
+ // so a dark-OS client would have rendered #485365 without any JS.
+ expect(await lightThemeColor(page)).toBe('#ffffff');
+ expect(await darkThemeColor(page)).toBe('#485365');
await page.locator('button[data-l10n-id="pad.toolbar.settings.title"]').click();
await expect(page.locator('#theme-toggle-row')).toBeVisible();
@@ -18,25 +28,35 @@ test.describe('light color scheme', () => {
// Colibris styles the native checkbox via a sibling label; click the label
// so the toggle fires the real change event the production code listens on.
await page.locator('label[for="options-darkmode"]').click();
- // pad.ts forces super-dark-toolbar (#485365) regardless of the configured
- // light skinVariants, so the meta must follow the client-applied class.
- await expect.poll(() => themeColor(page)).toBe('#485365');
+ // The explicit toggle points every theme-color meta at the dark toolbar
+ // color, so the address bar goes dark even though the OS is in light mode.
+ await expect.poll(() => lightThemeColor(page)).toBe('#485365');
await page.locator('label[for="options-darkmode"]').click();
- await expect.poll(() => themeColor(page)).toBe('#ffffff');
+ await expect.poll(() => lightThemeColor(page)).toBe('#ffffff');
});
});
test.describe('dark color scheme', () => {
test.use({colorScheme: 'dark'});
- test('theme-color meta follows the auto dark-mode switch on dark-OS clients',
+ test('theme-color meta is dark at first paint on dark-OS clients',
async ({page}) => {
await goToNewPad(page);
- // pad.ts auto-switches to super-dark-toolbar when enableDarkMode is on,
- // matchMedia(prefers-color-scheme:dark) matches, and no localStorage
- // white-mode override is set. The meta must follow the applied class —
- // this is the case stffen reported on issue #7606.
- await expect.poll(() => themeColor(page)).toBe('#485365');
+ // The media-scoped dark variant is what fixes stffen's iPhone: it is
+ // present and dark before any JS runs, so iOS Safari colors the address
+ // bar correctly at parse time (issue #7606).
+ await expect.poll(() => darkThemeColor(page)).toBe('#485365');
+ });
+
+ test('page paints dark without a light flash on dark-OS clients',
+ async ({page}) => {
+ await goToNewPad(page);
+ // The inline pre-paint script in adds the dark skin classes to
+ // before the stylesheet paints, so a dark-OS user never sees the
+ // light page (issue #7606). Asserting the class is present confirms the
+ // dark skin is applied; the backend test verifies the script is ordered
+ // before pad.css so it takes effect at first paint.
+ await expect(page.locator('html')).toHaveClass(/super-dark-editor/);
});
});
diff --git a/var/.gitignore b/var/.gitignore
index d75cb9e42b2..32742afc343 100644
--- a/var/.gitignore
+++ b/var/.gitignore
@@ -3,3 +3,4 @@ minified*
installed_plugins.json
dirty.db
rusty.db
+update-state.json