From ff9896c796ce82dea37dbe2130c9540e24ca2671 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:02:58 +0100 Subject: [PATCH 1/2] hub-client: theme the changelog/more-info iframe to match app dark mode (GH #624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The About tab renders changelog.md and more-info.md through the WASM pipeline into a sandboxed iframe. That document is a separate browsing context: it sees none of the app's theme classes or CSS variables, its canvas lets the modal's --bg-modal show through, and the injected stylesheet hardcoded light colors (#333 text). On the dark modal that is 1.3:1 contrast — the changelog was near-invisible. Extract the injected stylesheet into utils/changelogDoc.ts with light and dark variants built from the theme.css palette (every text/ background pair meets WCAG AA 4.5:1, pinned by contrast tests), declare color-scheme so UA painting follows, and set the body background to the modal color so the iframe blends seamlessly. AboutTab now keeps the raw WASM renders and re-injects theme-matched styles from useTheme().effectiveTheme, so a theme flip restyles an open document without re-running the pipeline. Also switches the light-theme link color from off-palette #646cff (4.09:1, AA fail) to the app's own --accent-secondary #447099 (5.22:1). Verified end-to-end in Chromium (Playwright): real WASM-rendered changelog + real theme.css screenshotted in both themes; dark probe reports #fff text on #213D4F with color-scheme: dark. --- .../src/components/tabs/AboutTab.test.tsx | 89 ++++++++++++- hub-client/src/components/tabs/AboutTab.tsx | 66 +++------- hub-client/src/utils/changelogDoc.test.ts | 104 +++++++++++++++ hub-client/src/utils/changelogDoc.ts | 122 ++++++++++++++++++ 4 files changed, 330 insertions(+), 51 deletions(-) create mode 100644 hub-client/src/utils/changelogDoc.test.ts create mode 100644 hub-client/src/utils/changelogDoc.ts diff --git a/hub-client/src/components/tabs/AboutTab.test.tsx b/hub-client/src/components/tabs/AboutTab.test.tsx index d11998547..c452a828e 100644 --- a/hub-client/src/components/tabs/AboutTab.test.tsx +++ b/hub-client/src/components/tabs/AboutTab.test.tsx @@ -3,19 +3,70 @@ * every group and entry from the shortcut map (utils/keyboardShortcuts) * so the reference can never silently drift from the registry. * + * Also covers the changelog/more-info modal theming (GH #624): the iframe + * document sees none of the app's theme classes, so AboutTab must inject + * theme-matched styles into the rendered HTML — including when the app is + * in dark mode, where hardcoded light colors were near-invisible. + * * @vitest-environment jsdom */ -import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import type { ComponentProps } from 'react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; import AboutTab from './AboutTab'; +import { ThemeProvider } from '../ThemeContext'; import { SHORTCUT_GROUPS } from '../../utils/keyboardShortcuts'; +vi.mock('@quarto/preview-runtime', () => ({ + renderContentToHtml: vi.fn(async () => ({ + success: true, + html: '\n\n\n\n\n

entry

\n', + })), + isWasmReady: () => true, +})); + +// jsdom lacks matchMedia, which ThemeProvider reads for 'auto' mode. +// (The shared stub in test-utils/setup.ts is only wired into the +// integration config, not the unit config.) +vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), +})); + +function seedColorScheme(colorScheme: 'light' | 'dark') { + localStorage.setItem( + 'quarto-hub:preferences', + JSON.stringify({ + version: 1, + scrollSyncEnabled: true, + errorOverlayCollapsed: true, + colorScheme, + unlockNestingCursor: true, + richText: true, + }), + ); +} + +function renderAboutTab(props: ComponentProps) { + return render( + + + , + ); +} + describe('AboutTab keyboard shortcuts reference', () => { afterEach(cleanup); it('renders every group and entry from the shortcut map', () => { - render(); + renderAboutTab({ wasmStatus: 'loading' }); expect(screen.getByText('Keyboard Shortcuts')).toBeTruthy(); for (const group of SHORTCUT_GROUPS) { @@ -28,3 +79,35 @@ describe('AboutTab keyboard shortcuts reference', () => { } }); }); + +describe('AboutTab changelog modal theming (GH #624)', () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(cleanup); + + async function openChangelog() { + const button = await screen.findByRole('button', { name: 'View Changelog' }); + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)); + button.click(); + const iframe = (await screen.findByTitle('Changelog')) as HTMLIFrameElement; + return iframe.srcdoc || iframe.getAttribute('srcdoc') || ''; + } + + it('injects dark-theme styles into the iframe when the app is dark', async () => { + seedColorScheme('dark'); + renderAboutTab({ wasmStatus: 'ready' }); + + const srcdoc = await openChangelog(); + expect(srcdoc).toContain('color-scheme: dark'); + expect(srcdoc).not.toContain('color: #333'); + }); + + it('injects light-theme styles into the iframe when the app is light', async () => { + seedColorScheme('light'); + renderAboutTab({ wasmStatus: 'ready' }); + + const srcdoc = await openChangelog(); + expect(srcdoc).toContain('color-scheme: light'); + }); +}); diff --git a/hub-client/src/components/tabs/AboutTab.tsx b/hub-client/src/components/tabs/AboutTab.tsx index 37a8bb338..6c04ec336 100644 --- a/hub-client/src/components/tabs/AboutTab.tsx +++ b/hub-client/src/components/tabs/AboutTab.tsx @@ -7,11 +7,13 @@ * - Buttons to view markdown documents (changelog, more info) in modal */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import Tooltip from '../Tooltip'; import { SHORTCUT_GROUPS } from '../../utils/keyboardShortcuts'; import { common, tabs } from '../../strings'; import { renderContentToHtml, isWasmReady } from '@quarto/preview-runtime'; +import { useTheme } from '../ThemeContext'; +import { injectChangelogStyles } from '../../utils/changelogDoc'; import changelogMd from '../../../changelog.md?raw'; import moreInfoMd from '../../../resources/more-info.md?raw'; import './AboutTab.css'; @@ -22,46 +24,6 @@ interface AboutTabProps { wasmStatus: WasmStatus; } -// Minimal CSS for changelog rendering -const changelogStyles = ` - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - font-size: 14px; - line-height: 1.6; - color: #333; - padding: 24px; - margin: 0; - max-width: 800px; - } - h2 { - font-size: 20px; - font-weight: 600; - margin: 0 0 16px 0; - color: #111; - } - ul { - margin: 0; - padding: 0 0 0 20px; - } - li { - margin: 8px 0; - } - a { - color: #646cff; - text-decoration: none; - } - a:hover { - text-decoration: underline; - } - code { - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; - font-size: 13px; - background: #f4f4f4; - padding: 2px 6px; - border-radius: 3px; - } -`; - // Document configuration for the modal viewer interface MarkdownDocument { title: string; @@ -74,9 +36,10 @@ const documents: Record = { }; export default function AboutTab({ wasmStatus }: AboutTabProps) { - const [renderedDocs, setRenderedDocs] = useState>({}); + const [rawDocs, setRawDocs] = useState>({}); const [renderError, setRenderError] = useState(null); const [activeModal, setActiveModal] = useState(null); + const { effectiveTheme } = useTheme(); // Render all markdown documents when WASM becomes ready useEffect(() => { @@ -90,17 +53,13 @@ export default function AboutTab({ wasmStatus }: AboutTabProps) { for (const [key, doc] of Object.entries(documents)) { const result = await renderContentToHtml(doc.markdown); if (result.success) { - // Inject minimal styles into the rendered HTML - rendered[key] = result.html.replace( - '', - `` - ); + rendered[key] = result.html; } else { setRenderError(result.error || `Failed to render ${doc.title}`); return; } } - setRenderedDocs(rendered); + setRawDocs(rendered); setRenderError(null); } catch (err) { setRenderError(err instanceof Error ? err.message : 'Unknown error'); @@ -110,6 +69,17 @@ export default function AboutTab({ wasmStatus }: AboutTabProps) { renderDocuments(); }, [wasmStatus]); + // Inject theme-matched styles into the iframe documents. Kept as a pure + // re-injection over the raw renders so a theme flip restyles an already + // rendered document without re-running the WASM pipeline (GH #624). + const renderedDocs = useMemo(() => { + const themed: Record = {}; + for (const [key, html] of Object.entries(rawDocs)) { + themed[key] = injectChangelogStyles(html, effectiveTheme); + } + return themed; + }, [rawDocs, effectiveTheme]); + const handleOpenModal = (docKey: string) => { setActiveModal(docKey); }; diff --git a/hub-client/src/utils/changelogDoc.test.ts b/hub-client/src/utils/changelogDoc.test.ts new file mode 100644 index 000000000..d3e8a49ed --- /dev/null +++ b/hub-client/src/utils/changelogDoc.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for the changelog/more-info iframe theming (GH #624). + * + * The About tab renders markdown into an iframe whose document sees none + * of the app's theme classes or CSS variables, and whose canvas lets the + * modal background show through. The injected styles must therefore set + * theme-appropriate colors: hardcoded light colors rendered the changelog + * near-invisible (1.3:1) on the dark modal. + * + * These tests pin the actual requirement — WCAG AA contrast (4.5:1) for + * text and links against the modal background of each theme — rather than + * specific hex values. + */ + +import { describe, it, expect } from 'vitest'; +import { changelogStylesForTheme, injectChangelogStyles } from './changelogDoc'; + +// -- WCAG contrast helpers --------------------------------------------------- + +function luminance(hex: string): number { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const f = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} + +function contrastRatio(a: string, b: string): number { + let [la, lb] = [luminance(a), luminance(b)]; + if (la < lb) [la, lb] = [lb, la]; + return (la + 0.05) / (lb + 0.05); +} + +/** Pull a `color`/`background` declaration out of a `selector { ... }` block. */ +function declared(css: string, selector: string, prop: string): string { + const block = css.match(new RegExp(`${selector.replace(/[.*]/g, '\\$&')}\\s*{([^}]*)}`)); + expect(block, `no rule for ${selector}`).toBeTruthy(); + const decl = block![1].match(new RegExp(`${prop}:\\s*([^;]+)`)); + expect(decl, `no ${prop} in ${selector} rule`).toBeTruthy(); + return decl![1].trim(); +} + +// Modal backgrounds from theme.css: light --bg-modal, dark --bg-modal +// (--posit-blue-dark-2). The iframe canvas shows the modal through, and the +// injected styles also set the body background to the same value. +const LIGHT_MODAL_BG = '#ffffff'; +const DARK_MODAL_BG = '#213d4f'; + +describe('changelogStylesForTheme', () => { + it('declares color-scheme so UA painting (scrollbars, canvas) matches', () => { + expect(changelogStylesForTheme('dark')).toContain('color-scheme: dark'); + expect(changelogStylesForTheme('light')).toContain('color-scheme: light'); + }); + + it('dark theme: body text meets AA contrast on the dark modal background', () => { + const css = changelogStylesForTheme('dark'); + const text = declared(css, 'body', 'color'); + expect(contrastRatio(text, DARK_MODAL_BG)).toBeGreaterThanOrEqual(4.5); + }); + + it('dark theme: links meet AA contrast on the dark modal background', () => { + const css = changelogStylesForTheme('dark'); + const link = declared(css, 'a', 'color'); + expect(contrastRatio(link, DARK_MODAL_BG)).toBeGreaterThanOrEqual(4.5); + }); + + it('dark theme: code chips keep AA contrast on their own background', () => { + const css = changelogStylesForTheme('dark'); + const codeBg = declared(css, 'code', 'background'); + const text = declared(css, 'body', 'color'); // code inherits body text color + expect(contrastRatio(text, codeBg)).toBeGreaterThanOrEqual(4.5); + }); + + it('dark theme: body background matches the dark modal (seamless iframe)', () => { + const css = changelogStylesForTheme('dark'); + expect(declared(css, 'body', 'background').toLowerCase()).toBe(DARK_MODAL_BG); + }); + + it('light theme: body text and links meet AA contrast on the light modal', () => { + const css = changelogStylesForTheme('light'); + expect(contrastRatio(declared(css, 'body', 'color'), LIGHT_MODAL_BG)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(declared(css, 'a', 'color'), LIGHT_MODAL_BG)).toBeGreaterThanOrEqual(4.5); + expect(declared(css, 'body', 'background').toLowerCase()).toBe(LIGHT_MODAL_BG); + }); + + it('themes differ (a theme flip must restyle the iframe)', () => { + expect(changelogStylesForTheme('dark')).not.toBe(changelogStylesForTheme('light')); + }); +}); + +describe('injectChangelogStyles', () => { + const html = '\n\n\n\n\n

x

\n'; + + it('injects the themed styles before ', () => { + const out = injectChangelogStyles(html, 'dark'); + const styleIdx = out.indexOf('`); +} From 8e3afb5c3013c27f22ff4599b170ad4788cbe609 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:03:33 +0100 Subject: [PATCH 2/2] hub-client changelog: dark-mode changelog viewer fix (ff9896c79) --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 19b423a43..5a3e21959 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -25,6 +25,7 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-08-27 +- [`ff9896c7`](https://github.com/quarto-dev/q2/commits/ff9896c7): The changelog and more-info viewer now follows the app's colour scheme — in dark mode the text is readable light-on-dark (and links use the app's blue) instead of near-invisible dark text on the dark dialog. - [`e3a86d05`](https://github.com/quarto-dev/q2/commits/e3a86d05): Small-window header tidied up: at the smallest widths the view-mode switcher is hidden (the Preview button covers switching) and Share + Preview stay as plain buttons instead of a "..." menu. The sidebar toggle is now a grey chip in the sidebar's own colour, clearly separate from the title-bar buttons, and the switch-project icon is teal to mark it as the way out to your projects. - [`38922590`](https://github.com/quarto-dev/q2/commits/38922590): A sidebar toggle button now sits at the left of the editor header at every window size — click it to hide or show the sidebar (in narrow windows it opens the sidebar as an overlay drawer). The button is muted grey so it reads as sidebar chrome, not a title-bar action. - [`c5c5f23d`](https://github.com/quarto-dev/q2/commits/c5c5f23d): Corners are now consistent across the app — buttons, menus, cards, and dialogs share one radius scale (buttons and cards are slightly rounder).