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