Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ca09f8e
hub-client: Phase 1 — consolidate UI icons into shared icons.tsx module
shikokuchuo Aug 26, 2026
c7bdd7d
hub-client: Phase 1 — unify monospace stacks onto --font-mono
shikokuchuo Aug 26, 2026
805f9e7
hub-client: Phase 1 — unify the button system
shikokuchuo Aug 26, 2026
3743047
hub-client: Phase 1 — shared accessible Menu component + component ga…
shikokuchuo Aug 26, 2026
ec1de16
hub-client: Phase 1 — Tooltip component replaces native title attributes
shikokuchuo Aug 26, 2026
e7bea39
hub-client: Phase 1 — route all ProjectsHome dialogs through ModalDialog
shikokuchuo Aug 26, 2026
f12536d
hub-client: Phase 1 — unify the notification model
shikokuchuo Aug 26, 2026
2d1335b
hub-client: Phase 1 — form-control contract, design-system doc
shikokuchuo Aug 26, 2026
dbe53a3
hub-client: changelog entries for Phase 1 user-visible changes
shikokuchuo Aug 26, 2026
ccc79aa
hub-client: render tooltips in a portal so overflow containers can't …
shikokuchuo Aug 26, 2026
5b0b51b
hub-client: left-align the sidebar-header icon buttons
shikokuchuo Aug 26, 2026
79c1e76
hub-client: place tooltips below the anchor, like native title tooltips
shikokuchuo Aug 26, 2026
34a9a3e
hub-client: Phase 1 review fixes — menu edge cases, focus rings
shikokuchuo Aug 26, 2026
ad3b8ca
hub-client: drop the dead .qh-menu-item.two-line rule
shikokuchuo Aug 26, 2026
426013d
hub-client: fix stale interactive-e2e expectations from Phase 1
shikokuchuo Aug 26, 2026
1a70446
hub-client: fix menu→dialog focus race, harden visual bootHarness
shikokuchuo Aug 26, 2026
f310c87
Add missing Playwright visual regression baselines
github-actions[bot] Aug 26, 2026
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
3 changes: 3 additions & 0 deletions hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ 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.
- [`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.

### 2026-08-25

Expand Down
56 changes: 56 additions & 0 deletions hub-client/design-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Hub-client design system

The hub-client UI is built from a small set of tokens and primitives.
This page is the contract: follow it when adding or touching UI, and the
app stays coherent. The enforcement counterparts are `npm run lint:css`
(off-token values) and the `#/dev/` gallery (visual + keyboard coverage).

## Tokens (`src/theme.css`)

Three layers — new values land on the right one:

1. **Primitive ramps** — raw palette/scale values (`--posit-teal`,
`--posit-blue-dark-2`). Never referenced directly by components.
2. **Semantic aliases** — meaning-named tokens (`--text-primary`,
`--border-color`, `--editor-accent-bg`), defined per theme.
3. **Scale tokens** — the shared scales: spacing (`--space-1`…`--space-8`,
4px base), radii (`--radius-sm/md/lg`), elevation (`--shadow-1/2/3`),
z-layers (`--z-sticky`…`--z-max`), type (`--text-xs`…`--text-xl`,
`--font-weight-*`, `--leading-*`, `--font-mono`), motion
(`--duration-fast/base`, `--ease-out/standard`), and `--focus-ring`.

Rules: no hex/rgb colors outside `theme.css`; no bare z-index integers;
no `outline: none` without a `:focus-visible` counterpart; logical
properties (`margin-inline-start`, `inset-inline-end`) over physical ones.
All enforced by `lint:css`.

## Primitives

| Primitive | Where | Notes |
|---|---|---|
| Buttons | `ui.css` `.qh-btn` (+ `.primary` `.outline` `.danger` `.ghost-accent`, `.small`), `.qh-icon-btn` (+ `.boxed`), `.qh-link` | Two sizes; disabled + focus-visible live on the base classes. Not buttons: `.view-toggle-btn` (segmented control), `.qh-pager` (nav strip), `.preview-btn` (header primary pill). |
| Menu | `components/Menu.tsx` | The only action menu. APG menu-button pattern: arrows/Home/End, type-ahead, submenus, Escape + focus return. Destructive items use `danger` and must be confirm-guarded or undoable. Popovers containing forms (avatar menu) are not menus — they use `.qh-menu` styling only. |
| Tooltip | `components/Tooltip.tsx` | The only tooltip. Never use `title=`. Non-interactive content only. |
| Notifications | `components/notifications.css` | Three tiers — transient (auto-dismiss), dismissible persistent, session banner. Pick by how long the information must live; see the file header. |
| Dialogs | `components/ModalDialog.tsx` | Every dialog routes through it (focus trap, restoration, Escape). Structure content with `.dialog-content` / `.dialog-actions`; form dialogs add `.qh-form-dialog`. |
| Form controls | `ui.css` `.qh-input`, `.qh-field-label`, `.qh-tabs` | Validation: `aria-invalid` + error text via `aria-describedby` (field-level) or `.qh-error.inline` (form-level). |
| Icons | `components/icons.tsx` | The only icon source. Decorative (`aria-hidden`), 24×24 stroke style, `currentColor`. |
| Utilities | `ui.css` `.qh-truncate`, `.qh-row-hover`, `.qh-active-accent-row` | Single-purpose shared classes — adopt, don't fork. |

## The gallery (`#/dev/gallery`)

Every primitive renders on the gallery page in its meaningful states, in
both themes, covered by Playwright visual baselines and axe-core scans
(`npm run test:visual`). The tokens gallery is `#/dev/tokens`.

## Adding a component

1. Style with tokens only — if a value has no token, add one at the right
layer in `theme.css` (or use the nearest scale step).
2. Reuse the primitives above. If none fits, that's a design decision —
build it accessibly (keyboard path, ARIA pattern from the APG) and add
it here.
3. Add the component to the gallery in its default/hover/focus/disabled/
error states; the visual and axe baselines pick it up from there.
4. Every pointer affordance needs a keyboard path; every icon-only control
needs an `aria-label`; status is never conveyed by color alone.
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 @@ -48,6 +48,7 @@ const SCAN_PAGES: { page: string; label: string; selector: string }[] = [
{ 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' },
{ page: 'gallery', label: 'gallery', selector: 'text=Component gallery' },
];

/** key → { ruleId: nodeCount } */
Expand Down
1 change: 1 addition & 0 deletions hub-client/e2e/baseline-screens.visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const BASELINE_PAGES: { page: string; label: string; selector: string }[] = [
{ 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' },
];

for (const { page, label, selector } of BASELINE_PAGES) {
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.
25 changes: 14 additions & 11 deletions hub-client/e2e/files-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* E2E: Files header action row (bd-qhn2raky).
*
* The New/Upload buttons — and the conditional Print button — are
* icon-only buttons that share the header row at equal width and fill it,
* icon-only buttons that share one compact, left-aligned header row,
* whether two or three are present. Guards the regression where three
* text buttons wrapped Upload onto a second row at the default 220px
* sidebar width.
* sidebar width. (The buttons grew to fill the row until the Phase 1
* sidebar cleanup made them compact; the row-wrap guard is the
* behavior that still matters.)
*/

import { test, expect, type Page } from '@playwright/test';
Expand All @@ -29,37 +31,38 @@ async function openEditor(page: Page, title: string, content: string) {
await expect(page.locator('.new-file-btn')).toBeVisible({ timeout: 30000 });
}

async function expectSingleFullRow(page: Page, selectors: string[]) {
async function expectSingleCompactRow(page: Page, selectors: string[]) {
const boxes = [];
for (const sel of selectors) {
const btn = page.locator(sel);
await expect(btn).toBeVisible();
boxes.push((await btn.boundingBox())!);
}
// All on one row, equal width and height, in the given left-to-right order.
// All on one row (no wrapping), equal width and height, in the given
// left-to-right order.
for (let i = 1; i < boxes.length; i++) {
expect(Math.abs(boxes[i].y - boxes[0].y)).toBeLessThan(1);
expect(Math.abs(boxes[i].width - boxes[0].width)).toBeLessThan(1);
expect(Math.abs(boxes[i].height - boxes[0].height)).toBeLessThan(1);
expect(boxes[i].x).toBeGreaterThan(boxes[i - 1].x);
}
// Together they fill the row: 24px horizontal padding + 6px gaps.
// Compact and left-aligned: the first button starts at the header's
// 12px padding edge.
const headerBox = (await page.locator('.sidebar-header').boundingBox())!;
const totalWidth = boxes.reduce((sum, b) => sum + b.width, 0);
expect(totalWidth).toBeCloseTo(headerBox.width - 24 - 6 * (boxes.length - 1), 0);
expect(Math.abs(boxes[0].x - headerBox.x - 12)).toBeLessThan(1);
}

test.describe('Files header action row', () => {
test.setTimeout(90_000);

test('New and Upload fill the row at equal width', async ({ page }) => {
test('New and Upload share one compact row', async ({ page }) => {
await openEditor(page, 'Two Button Row', '---\ntitle: Two Button Row\n---\n\nHello.\n');

await expect(page.locator('.print-file-btn')).toHaveCount(0);
await expectSingleFullRow(page, ['.new-file-btn', '.upload-asset-btn']);
await expectSingleCompactRow(page, ['.new-file-btn', '.upload-asset-btn']);
});

test('Print, New and Upload share one row at equal width', async ({ page }) => {
test('Print, New and Upload share one compact row', async ({ page }) => {
await openEditor(
page,
'Three Button Row',
Expand All @@ -69,7 +72,7 @@ test.describe('Files header action row', () => {
// Print appears once the format is detected as printable, and is
// last in the row so the stable New/Upload pair doesn't shift.
await expect(page.locator('.print-file-btn')).toBeVisible({ timeout: 30000 });
await expectSingleFullRow(page, [
await expectSingleCompactRow(page, [
'.new-file-btn',
'.upload-asset-btn',
'.print-file-btn',
Expand Down
6 changes: 6 additions & 0 deletions hub-client/e2e/helpers/axe-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
"dialog-share|light": {
"color-contrast": 2
},
"gallery|dark": {
"color-contrast": 7
},
"gallery|light": {
"color-contrast": 3
},
"minimal-header|dark": {
"color-contrast": 1
},
Expand Down
104 changes: 59 additions & 45 deletions hub-client/e2e/helpers/visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,52 +91,66 @@ export async function bootHarness(
await page.goto(`/#/dev/${route}`);

// Wait for the app's boot to create the identity record, then pin it.
await page.waitForFunction(
() =>
new Promise<boolean>((resolve) => {
const req = indexedDB.open('quarto-hub');
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains('userSettings')) {
db.close();
resolve(false);
return;
}
const tx = db.transaction('userSettings', 'readonly');
const get = tx.objectStore('userSettings').get('identity');
get.onsuccess = () => {
db.close();
resolve(!!get.result);
// The dev server can trigger a full-page reload mid-boot (vite re-
// optimizes dependencies on a cold cache — the CI environment), which
// destroys the execution context under either of these probes. Both
// are idempotent, so retry past the reload.
for (let attempt = 0; ; attempt++) {
try {
await page.waitForFunction(
() =>
new Promise<boolean>((resolve) => {
const req = indexedDB.open('quarto-hub');
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains('userSettings')) {
db.close();
resolve(false);
return;
}
const tx = db.transaction('userSettings', 'readonly');
const get = tx.objectStore('userSettings').get('identity');
get.onsuccess = () => {
db.close();
resolve(!!get.result);
};
get.onerror = () => {
db.close();
resolve(false);
};
};
req.onerror = () => resolve(false);
}),
undefined,
{ timeout: 15000 },
);
await page.evaluate((identity) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.open('quarto-hub');
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('userSettings', 'readwrite');
tx.objectStore('userSettings').put(identity);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
};
get.onerror = () => {
db.close();
resolve(false);
};
};
req.onerror = () => resolve(false);
}),
undefined,
{ timeout: 15000 },
);
await page.evaluate((identity) => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.open('quarto-hub');
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('userSettings', 'readwrite');
tx.objectStore('userSettings').put(identity);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
};
req.onerror = () => reject(req.error);
});
}, FIXED_IDENTITY);
req.onerror = () => reject(req.error);
});
}, FIXED_IDENTITY);
break;
} catch (err) {
if (attempt >= 2 || !/Execution context was destroyed/.test(String(err))) {
throw err;
}
await page.waitForLoadState('load');
}
}

await page.reload();
await page.waitForSelector(selector, { timeout: 15000 });
Expand Down
Loading
Loading