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 hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
89 changes: 86 additions & 3 deletions hub-client/src/components/tabs/AboutTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n</head>\n<body><p>entry</p></body>\n</html>',
})),
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<typeof AboutTab>) {
return render(
<ThemeProvider>
<AboutTab {...props} />
</ThemeProvider>,
);
}

describe('AboutTab keyboard shortcuts reference', () => {
afterEach(cleanup);

it('renders every group and entry from the shortcut map', () => {
render(<AboutTab wasmStatus="loading" />);
renderAboutTab({ wasmStatus: 'loading' });

expect(screen.getByText('Keyboard Shortcuts')).toBeTruthy();
for (const group of SHORTCUT_GROUPS) {
Expand All @@ -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');
});
});
66 changes: 18 additions & 48 deletions hub-client/src/components/tabs/AboutTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -74,9 +36,10 @@ const documents: Record<string, MarkdownDocument> = {
};

export default function AboutTab({ wasmStatus }: AboutTabProps) {
const [renderedDocs, setRenderedDocs] = useState<Record<string, string>>({});
const [rawDocs, setRawDocs] = useState<Record<string, string>>({});
const [renderError, setRenderError] = useState<string | null>(null);
const [activeModal, setActiveModal] = useState<string | null>(null);
const { effectiveTheme } = useTheme();

// Render all markdown documents when WASM becomes ready
useEffect(() => {
Expand All @@ -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(
'</head>',
`<style>${changelogStyles}</style></head>`
);
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');
Expand All @@ -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<string, string> = {};
for (const [key, html] of Object.entries(rawDocs)) {
themed[key] = injectChangelogStyles(html, effectiveTheme);
}
return themed;
}, [rawDocs, effectiveTheme]);

const handleOpenModal = (docKey: string) => {
setActiveModal(docKey);
};
Expand Down
104 changes: 104 additions & 0 deletions hub-client/src/utils/changelogDoc.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n</head>\n<body><p>x</p></body>\n</html>';

it('injects the themed styles before </head>', () => {
const out = injectChangelogStyles(html, 'dark');
const styleIdx = out.indexOf('<style>');
expect(styleIdx).toBeGreaterThan(-1);
expect(out.indexOf('</head>')).toBeGreaterThan(styleIdx);
expect(out).toContain('color-scheme: dark');
expect(out).toContain('<p>x</p>');
});

it('produces theme-specific documents from the same source HTML', () => {
expect(injectChangelogStyles(html, 'dark')).not.toBe(injectChangelogStyles(html, 'light'));
});
});
Loading
Loading