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
62 changes: 62 additions & 0 deletions claude-notes/plans/2026-08-26-hub-client-uiux-modernization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Hub-Client UI/UX Modernization (pointer)

**Date:** 2026-08-26
**Authoritative plan:** [`.posit/assistant/plans/2026-08-25-hub-client-uiux-modernization-plan.md`](../../../.posit/assistant/plans/2026-08-25-hub-client-uiux-modernization-plan.md)
**Braid epic:** bd-2q55e6rc

This file is the repo-side pointer required by the work-tracking workflow.
The full plan (phases, test specs, decisions) lives in the authoritative
file above; progress is tracked in braid.

## Strands

| Phase | Strand | Scope |
|-------|--------|-------|
| 0 — Token foundation & CSS hygiene | bd-5nm6v8bl | scale tokens, `lint:css`, DevHarness tokens page, visual + axe-core baselines, `.ph-*`→`.qh-*` rename |
| 1 — Component consistency | bd-iguk0hpd | button system, menu, tooltip, notifications, dialogs, forms, icons, mono unification |
| 2 — Keyboard & assistive-tech | bd-lavl5jv8 | focus rings, treeview, aria audits, forced-colors, shortcut map, copy audit |
| 3 — Functional states & motion safety | bd-6oxpa77k | reduced-motion global, loading/error/empty states |
| 4 — Graceful narrow viewports | bd-nubnj8ue | viewport matrix 1280–320px, reflow fixes |
| 5 — Visual refinement (feedback gate) | bd-tfsdmytf | single review gate for opinionated visual changes |
| 6 — Enthusiast details (stretch) | bd-j8s3ahjo | command palette, DnD affordances, quick-switcher |
| 7 — CI enforcement | bd-i1wlah5w | axe-core + lint:css as blocking CI checks |

## Phase 0 progress log

**Complete (2026-08-26)** on branch `hub-client-uiux-phase0`, four commits:

- `16cb20b1` test infrastructure: `lint:css` (scripts/lint-css.mjs +
grandfathered exceptions), DevHarness baseline routes (projects-home,
3 dialogs, sidebar, header, notifications), visual baseline spec,
axe-core baseline spec + manifest.
- `305fec7c` scale tokens in theme.css + layering doc + `#/dev/tokens`
gallery page; deterministic baselines (fixed IDB identity, frozen
clock, transitions off, fonts awaited); visual config retries: 1.
- `df1cd10b` `.ph-*` → `.qh-*` rename (611 replacements, 22 files).
- `7ea60da9` color/z-index burn-down to zero exceptions (187 → 85; the
rest are owned by Phases 2/7) + shared utilities (`.qh-truncate`,
`.qh-row-hover`, `.qh-active-accent-row`).

Verification: `npm run build:all`, `npm run test:ci` (1005 + 112 + 133),
visual suite 42/42 pixel-clean vs pre-change baselines, axe baselines
hold, eslint identical to main (192 pre-existing problems, none added),
rename-touched e2e specs (projects-home, accessibility, files-header)
7/7 green on a real e2e build. No changelog entry — Phase 0 is internal
(changelog policy: user-facing changes only).

Deviations from the plan, recorded:

- Editor-shell baselines are covered surface-by-surface (header, sidebar
sections, dialogs, notifications) — the full Editor needs live sync +
Monaco + WASM, which the no-server visual config avoids by design.
- axe/lint:css run locally via `npm run test:visual` / `npm run lint:css`,
not `test:ci` (vitest-only); blocking CI wiring is Phase 7 either way.
- lint:css color rule allows literals in token *definitions*
(custom properties) in any file, so the standalone src/debug/ page
keeps its local token block.
- ProjectsHome menu z-index 70 → --z-dropdown (60): order-preserving
renumber, pixel-invisible.
- Known flake: bootHarness's identity-pinning occasionally hits an
execution-context teardown under full parallelism; absorbed by
`retries: 1` in the visual config (assertions themselves are
deterministic).
6 changes: 3 additions & 3 deletions hub-client/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ test.describe('accessibility (WCAG 2.2)', () => {
},
]);
await seedProjectInBrowser(page, indexDocId, syncServer, 'A11y Project');
const row = page.locator('.ph-row', { hasText: 'A11y Project' });
const row = page.locator('.qh-row', { hasText: 'A11y Project' });
await expect(row).toBeVisible({ timeout: 15000 });
await row.locator('.ph-row-name').click();
await row.locator('.qh-row-name').click();

// The editor's sidebar New-file button is the dialog trigger.
const newFileBtn = page.locator('.new-file-btn');
Expand Down Expand Up @@ -109,7 +109,7 @@ test.describe('accessibility (WCAG 2.2)', () => {
]);
await seedProjectInBrowser(page, indexDocId, syncServer, 'Dialog Project');
await expect(
page.locator('.ph-row', { hasText: 'Dialog Project' }),
page.locator('.qh-row', { hasText: 'Dialog Project' }),
).toBeVisible({ timeout: 15000 });

await page.getByRole('button', { name: '+ New collection' }).click();
Expand Down
130 changes: 130 additions & 0 deletions hub-client/e2e/baseline-a11y.visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* axe-core accessibility baselines for the hub-client UI/UX modernization
* (Phase 0). Scans the dev-harness key surfaces in both themes BEFORE any
* token/consistency work lands, so a token migration that breaks contrast
* (or any later a11y regression) fails immediately rather than phases later.
*
* Characterization model: the CURRENT serious/critical violations are
* recorded in helpers/axe-baseline.json (per page+theme, rule → node
* count). A scan fails when:
* - a serious/critical violation appears that the baseline does not know
* (a NEW regression), or
* - a baselined rule's node count GROWS (a worsened regression), or
* - a baselined rule disappears or shrinks (a FIX — the baseline must be
* regenerated to burn it down; keeps the file self-pruning).
*
* Regenerate after intentional fixes/changes (single worker — the write
* happens in afterAll and must see every scan's result):
* AXE_BASELINE_WRITE=1 npx playwright test --config playwright.visual.config.ts baseline-a11y --workers=1
*
* Checker choice matches the quarto-cli ecosystem standard (bundled
* axe-core, per the `axe` document-accessibility feature). Real-app key
* screens (editor shell) are covered by the e2e accessibility suite;
* extending scans to every gallery page is Phase 2; CI wiring is Phase 7.
*/

import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { THEMES, bootHarness } from './helpers/visual';

// bootHarness does two page loads (identity pinning) against a shared dev
// server; under full parallelism the default 30s budget is too tight.
test.setTimeout(60_000);

const BASELINE_PATH = fileURLToPath(
new URL('./helpers/axe-baseline.json', import.meta.url),
);

const SCAN_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: 'setup-migration', label: 'setup-migration', selector: '.setup-modal' },
{ page: 'setup-fresh', label: 'setup-fresh', selector: '.setup-modal' },
{ page: 'tokens', label: 'tokens', selector: 'text=Design tokens' },
];

/** key → { ruleId: nodeCount } */
type AxeBaseline = Record<string, Record<string, number>>;

function loadBaseline(): AxeBaseline {
try {
return JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
} catch {
return {};
}
}

const WRITE_MODE = process.env.AXE_BASELINE_WRITE === '1';
const written: AxeBaseline = {};

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

const results = await new AxeBuilder({ page: browserPage }).analyze();
const blocking = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical',
);
const counts: Record<string, number> = {};
for (const v of blocking) counts[v.id] = v.nodes.length;

const key = `${label}|${theme}`;
if (WRITE_MODE) {
if (Object.keys(counts).length > 0) written[key] = counts;
return;
}

const baseline = loadBaseline();
const expected = baseline[key] ?? {};
const problems: string[] = [];
for (const [ruleId, count] of Object.entries(counts)) {
if (!(ruleId in expected)) {
problems.push(`NEW violation: ${ruleId} (${count} node(s))`);
} else if (count > expected[ruleId]) {
problems.push(
`WORSENED: ${ruleId} grew from ${expected[ruleId]} to ${count} node(s)`,
);
} else if (count < expected[ruleId]) {
problems.push(
`IMPROVED: ${ruleId} shrank from ${expected[ruleId]} to ${count} node(s) — regenerate the baseline (AXE_BASELINE_WRITE=1)`,
);
}
}
for (const ruleId of Object.keys(expected)) {
if (!(ruleId in counts)) {
problems.push(
`FIXED: ${ruleId} no longer fires — regenerate the baseline (AXE_BASELINE_WRITE=1)`,
);
}
}
if (problems.length > 0) {
console.log(
`axe baseline drift (${key}):\n` +
problems.map((p) => ` ${p}`).join('\n') +
'\n current: ' +
JSON.stringify(counts),
);
}
expect(problems).toEqual([]);
});
}
}

// After all scans in write mode, persist the baseline.
test.afterAll(() => {
if (!WRITE_MODE) return;
const sorted: AxeBaseline = {};
for (const key of Object.keys(written).sort()) sorted[key] = written[key];
writeFileSync(BASELINE_PATH, JSON.stringify(sorted, null, 2) + '\n');
console.log(
`axe baseline: wrote ${Object.keys(sorted).length} entries to helpers/axe-baseline.json`,
);
});
46 changes: 46 additions & 0 deletions hub-client/e2e/baseline-screens.visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Characterization baselines for the hub-client UI/UX modernization
* (Phase 0). These capture the CURRENT appearance of key surfaces via
* dev-harness routes, in both themes, BEFORE any token/consistency work
* lands. Every later visual change must show up here as a deliberate,
* reviewed diff in the Playwright report.
*
* The full editor shell is intentionally not screenshotted here: it needs
* a live sync connection + Monaco + WASM, which this no-server config
* avoids by design. Its chrome (header, sidebar sections, dialogs,
* notifications) is covered surface-by-surface via the routes below.
*
* Run with: npm run test:visual
* Update after an intentional change: npm run test:visual:update
*/

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

// bootHarness does two page loads (identity pinning) against a shared dev
// 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' },
];

for (const { page, label, selector } 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`, {
// Allow small pixel differences for anti-aliasing variance
maxDiffPixelRatio: 0.01,
});
});
}
}
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.
4 changes: 2 additions & 2 deletions hub-client/e2e/files-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ async function openEditor(page: Page, title: string, content: string) {
{ path: 'index.qmd', content, contentType: 'text' },
]);
await seedProjectInBrowser(page, indexDocId, syncServer, title);
const row = page.locator('.ph-row', { hasText: title });
const row = page.locator('.qh-row', { hasText: title });
await expect(row).toBeVisible({ timeout: 15000 });
await row.locator('.ph-row-name').click();
await row.locator('.qh-row-name').click();
await expect(page.locator('.new-file-btn')).toBeVisible({ timeout: 30000 });
}

Expand Down
44 changes: 44 additions & 0 deletions hub-client/e2e/helpers/axe-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"dialog-new-asset|dark": {
"color-contrast": 1
},
"dialog-new-asset|light": {
"color-contrast": 4
},
"dialog-share|dark": {
"color-contrast": 1
},
"dialog-share|light": {
"color-contrast": 2
},
"minimal-header|dark": {
"color-contrast": 1
},
"minimal-header|light": {
"color-contrast": 1
},
"notifications|dark": {
"color-contrast": 1
},
"notifications|light": {
"color-contrast": 1
},
"projects-home|dark": {
"color-contrast": 3
},
"projects-home|light": {
"color-contrast": 3
},
"setup-fresh|dark": {
"color-contrast": 1
},
"setup-migration|dark": {
"color-contrast": 2
},
"sidebar-sections|dark": {
"color-contrast": 10
},
"sidebar-sections|light": {
"color-contrast": 10
}
}
Loading
Loading