Skip to content
Merged
4 changes: 4 additions & 0 deletions hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ WASM rebuild is needed for a changelog-only edit.
### 2026-08-26

- [`00e0617a`](https://github.com/quarto-dev/q2/commits/00e0617a): Comment bubbles now show the comment's full text — quoted words, emphasis, and inline code inside a comment were previously dropped from the bubble.
- [`08db6bd3`](https://github.com/quarto-dev/q2/commits/08db6bd3): Copy cleanup across the app chrome: consistent sentence case ("Copy link", "Share project"), proper ellipsis characters, and one verb per concept — the sidebar's asset button is now "Add asset", matching the dialog it opens.
- [`85428ab2`](https://github.com/quarto-dev/q2/commits/85428ab2): The About tab gains a Keyboard Shortcuts reference listing every shortcut in the app.
- [`26b8848c`](https://github.com/quarto-dev/q2/commits/26b8848c): Every interactive control now shows a visible focus ring when navigated by keyboard, and Windows High Contrast mode keeps visible boundaries on buttons, menus, dialogs, and notifications.
- [`94969c3b`](https://github.com/quarto-dev/q2/commits/94969c3b): The file tree is fully keyboard-operable: Tab moves into it once, arrow keys/Home/End navigate, folders expand and collapse with the arrow keys, typing a name jumps to it, Enter opens, and Shift+F10 opens the row's actions menu. Search results work the same way.
- [`c740728a`](https://github.com/quarto-dev/q2/commits/c740728a): All project-list dialogs (new project, rename, duplicate, connect/import, and friends) now share the accessible dialog chrome: Escape closes, Tab stays inside the dialog, and focus returns to the button that opened it.
- [`73d9cbe5`](https://github.com/quarto-dev/q2/commits/73d9cbe5): Tooltips are now styled and keyboard-accessible throughout — hover shows them after a short delay, keyboard focus shows them immediately, and Escape dismisses.
- [`7e5de6fe`](https://github.com/quarto-dev/q2/commits/7e5de6fe): File rows in the sidebar gain a visible ⋯ actions button (on hover or keyboard focus), and all menus are now fully keyboard-operable: arrow keys, Home/End, type-ahead, submenus, and Escape.
Expand Down
1 change: 1 addition & 0 deletions hub-client/e2e/baseline-a11y.visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const SCAN_PAGES: { page: string; label: string; selector: string }[] = [
{ page: 'dialog-share', label: 'dialog-share', selector: '.share-dialog' },
{ page: 'dialog-new-asset', label: 'dialog-new-asset', selector: '.new-asset-dialog' },
{ page: 'sidebar', label: 'sidebar-sections', selector: '.sidebar-sections' },
{ page: 'about-tab', label: 'about-tab', selector: '.about-tab' },
{ page: 'header', label: 'minimal-header', selector: '.minimal-header' },
{ page: 'notifications', label: 'notifications', selector: '.ephemeral-session-banner' },
{ page: 'setup-migration', label: 'setup-migration', selector: '.setup-modal' },
Expand Down
38 changes: 26 additions & 12 deletions hub-client/e2e/baseline-screens.visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,38 @@ import { THEMES, bootHarness } from './helpers/visual';
// server; under full parallelism the default 30s budget is too tight.
test.setTimeout(60_000);

const BASELINE_PAGES: { page: string; label: string; selector: string }[] = [
{ page: 'projects-home', label: 'projects-home', selector: '.projects-home' },
{ page: 'dialog-new-file', label: 'dialog-new-file', selector: '.new-file-dialog' },
{ page: 'dialog-share', label: 'dialog-share', selector: '.share-dialog' },
{ page: 'dialog-new-asset', label: 'dialog-new-asset', selector: '.new-asset-dialog' },
{ page: 'sidebar', label: 'sidebar-sections', selector: '.sidebar-sections' },
{ page: 'header', label: 'minimal-header', selector: '.minimal-header' },
{ page: 'notifications', label: 'notifications', selector: '.ephemeral-session-banner' },
{ page: 'tokens', label: 'tokens', selector: 'text=Design tokens' },
{ page: 'gallery', label: 'gallery', selector: 'text=Component gallery' },
// capture: 'element' screenshots the surface's container, so a change
// local to that surface can't hide inside a mostly-empty 1280×720 page
// (a sidebar-wide change once measured 0.86% of full-page pixels — under
// the tolerance). 'page' is kept where the selector isn't a container
// (tokens/gallery headings) or the surface has fixed-position children
// outside it (notifications' toasts).
const BASELINE_PAGES: {
page: string;
label: string;
selector: string;
capture: 'element' | 'page';
}[] = [
{ page: 'projects-home', label: 'projects-home', selector: '.projects-home', capture: 'element' },
{ page: 'dialog-new-file', label: 'dialog-new-file', selector: '.new-file-dialog', capture: 'element' },
{ page: 'dialog-share', label: 'dialog-share', selector: '.share-dialog', capture: 'element' },
{ page: 'dialog-new-asset', label: 'dialog-new-asset', selector: '.new-asset-dialog', capture: 'element' },
{ page: 'sidebar', label: 'sidebar-sections', selector: '.sidebar-sections', capture: 'element' },
{ page: 'about-tab', label: 'about-tab', selector: '.about-tab', capture: 'element' },
{ page: 'header', label: 'minimal-header', selector: '.minimal-header', capture: 'element' },
{ page: 'notifications', label: 'notifications', selector: '.ephemeral-session-banner', capture: 'page' },
{ page: 'tokens', label: 'tokens', selector: 'text=Design tokens', capture: 'page' },
{ page: 'gallery', label: 'gallery', selector: 'text=Component gallery', capture: 'page' },
];

for (const { page, label, selector } of BASELINE_PAGES) {
for (const { page, label, selector, capture } of BASELINE_PAGES) {
for (const theme of THEMES) {
test(`${label} — ${theme} theme`, async ({ page: browserPage }) => {
await bootHarness(browserPage, page, selector, theme);

await expect(browserPage).toHaveScreenshot(`${label}-${theme}.png`, {
const target =
capture === 'element' ? browserPage.locator(selector) : browserPage;
await expect(target).toHaveScreenshot(`${label}-${theme}.png`, {
// Allow small pixel differences for anti-aliasing variance
maxDiffPixelRatio: 0.01,
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 75 additions & 0 deletions hub-client/e2e/forced-colors.visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Forced-colors (Windows High Contrast) spec. With forced colors active
* the platform strips backgrounds, box shadows, and author colors, so any
* control whose boundary or state is conveyed by background/shadow alone
* disappears. These specs assert the key surfaces keep visible boundaries
* (system-color borders) and that the selected row stays distinguishable.
*
* A manual Windows High Contrast pass is documented alongside the
* screen-reader smoke script (hub-client/screen-reader-smoke.md).
*
* Phase 2 deliverable of the UI/UX modernization plan.
*/

import { test, expect, type Page, type Locator } from '@playwright/test';
import { bootHarness } from './helpers/visual';

test.setTimeout(60_000);

test.beforeEach(async ({ page }) => {
await page.emulateMedia({ forcedColors: 'active' });
});

/** Computed border summary, e.g. "solid 1px rgb(0, 0, 0)". */
function borderOf(locator: Locator): Promise<string> {
return locator.evaluate((el) => {
const s = getComputedStyle(el);
return `${s.borderTopStyle} ${s.borderTopWidth} ${s.borderTopColor}`;
});
}

async function expectVisibleBoundary(locator: Locator) {
const border = await borderOf(locator);
expect(border.startsWith('none') || border.includes(' 0px ')).toBe(false);
}

test('gallery: buttons and menu keep visible boundaries', async ({ page }) => {
await bootHarness(page, 'gallery', 'text=Component gallery', 'light');

// Background-only variants (primary) and hairline variants (outline)
// must both keep a boundary when backgrounds are stripped.
await expectVisibleBoundary(page.locator('button.qh-btn.primary').first());
await expectVisibleBoundary(page.locator('button.qh-btn.outline').first());
await expectVisibleBoundary(page.locator('button.qh-icon-btn').first());
await expectVisibleBoundary(page.locator('input.qh-input').first());

await page.click('button:has-text("Gallery menu")');
const menu = page.locator('[role="menu"]');
await expect(menu).toBeVisible();
await expectVisibleBoundary(menu);
});

test('dialog keeps a visible boundary without its shadow', async ({ page }) => {
await bootHarness(page, 'dialog-share', '.share-dialog', 'light');
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
await expectVisibleBoundary(dialog);
});

test('sidebar: the selected file row stays distinguishable', async ({ page }) => {
await bootHarness(page, 'sidebar', '.sidebar-sections', 'light');
const tree = page.locator('[role="tree"][aria-label="Files"]');
const active = tree.locator('[role="treeitem"][aria-selected="true"]');
const other = tree.locator('[role="treeitem"]', { hasText: 'analysis.qmd' });
const bgOf = (l: Locator) => l.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(await bgOf(active)).not.toBe(await bgOf(other));
});

test('tooltip keeps a visible boundary', async ({ page }) => {
await bootHarness(page, 'gallery', 'text=Component gallery', 'light');
// The gallery tooltip demo: focus shows the tooltip immediately.
await page.getByRole('button', { name: 'Hover or focus me' }).focus();
const tip = page.locator('[role="tooltip"]');
await expect(tip).toBeVisible();
await expectVisibleBoundary(tip);
});
42 changes: 42 additions & 0 deletions hub-client/e2e/gallery-states-a11y.visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* axe-core scans of interactive gallery states — the open menu and the
* visible tooltip — which the static per-page baseline scans never
* render (Phase 2's extension of the Phase 0 axe coverage to every
* gallery surface).
*
* Unlike the characterization baseline (which records existing
* violations), these surfaces were built to the APG patterns in Phase 1,
* so the contract is strict: any serious/critical violation inside the
* interactive element fails. Scans are scoped with .include() so the
* gallery page's own baselined contrast issues don't leak in.
*/

import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
import { THEMES, bootHarness, type Theme } from './helpers/visual';

test.setTimeout(60_000);

async function expectNoBlockingViolations(page: Parameters<typeof bootHarness>[0], include: string) {
const results = await new AxeBuilder({ page }).include(include).analyze();
const blocking = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical',
);
expect(blocking.map((v) => `${v.id} (${v.nodes.length} node(s))`)).toEqual([]);
}

for (const theme of THEMES) {
test(`axe: gallery menu open — ${theme} theme`, async ({ page }) => {
await bootHarness(page, 'gallery', 'text=Component gallery', theme as Theme);
await page.click('button:has-text("Gallery menu")');
await expect(page.locator('[role="menu"]')).toBeVisible();
await expectNoBlockingViolations(page, '[role="menu"]');
});

test(`axe: gallery tooltip visible — ${theme} theme`, async ({ page }) => {
await bootHarness(page, 'gallery', 'text=Component gallery', theme as Theme);
await page.getByRole('button', { name: 'Hover or focus me' }).focus();
await expect(page.locator('[role="tooltip"]')).toBeVisible();
await expectNoBlockingViolations(page, '[role="tooltip"]');
});
}
6 changes: 6 additions & 0 deletions hub-client/e2e/helpers/axe-baseline.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"about-tab|dark": {
"color-contrast": 3
},
"about-tab|light": {
"color-contrast": 4
},
"dialog-new-asset|dark": {
"color-contrast": 1
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading