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
13 changes: 13 additions & 0 deletions claude-notes/plans/2026-08-25-hub-client-uiux-modernization.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,19 @@ commit, and can be individually approved, held, or reverted.
tooltip capture needed a page-region clip — Playwright element
screenshots of the portaled position:fixed bubble capture it
unstyled (tooling artifact, computed styles verified correct).
**Review adjustments landed:** (1) fullscreen preview went blank at
≤700px — the split-collapse rule hid the fullscreen pane; fixed by
excluding `.fullscreen` (60b156d43, regression spec watched red
first). (2) The drawer toggle was drawer-only and undiscoverable —
now permanent header chrome that hides/shows the static sidebar
above 900px too, in muted grey with a sidebar-tinted active state
(38922590d). (3) Round 2 (e3a86d053): view-mode switcher hidden at
≤700px (the Preview pill covers switching), Share + Preview back
inline (kebab menu retired), the toggle is a grey chip in the
sidebar's own tint, and switch-project is teal (the one
exit-to-another-view action). **Note:** PR #622 merged before these
adjustments; they ride the follow-up branch
`hub-client-uiux-phase5-review`.
- [x] Off-palette token values (00e577087): outline icons + replay me-chip
re-mapped onto Posit ramps — one hue family per meaning (header blue,
code teal, function orange), each theme picking the ramp step that
Expand Down
2 changes: 2 additions & 0 deletions hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ WASM rebuild is needed for a changelog-only edit.

### 2026-08-27

- [`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).
- [`b95d5e18`](https://github.com/quarto-dev/q2/commits/b95d5e18): Narrow windows now get a designed layout: the sidebar becomes an overlay drawer (open it from the new header button, dismiss with Escape or by clicking outside), split view collapses to the editor when the window is too narrow for two panes, and the header's Share and Preview actions move into a "More actions" menu.
- [`0c751c75`](https://github.com/quarto-dev/q2/commits/0c751c75): The projects home now shows a skeleton of the page while projects load (instead of a spinner), and empty states ("No projects yet", "No files yet") gained a guiding icon.
Expand Down
128 changes: 98 additions & 30 deletions hub-client/e2e/viewport-matrix.visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,22 +222,12 @@ for (const { route, mode } of SHELL_ROUTES) {
// paint on, ending up *under* .header-right — an internal overlap
// no viewport assertion catches. Its own scrollWidth reports it.
await expectNoHorizontalScroll(page, '.header-left');
// Header controls stay reachable. At ≤700px the preview button
// collapses into the overflow menu (Phase 5) — the kebab is the
// reachable control.
if (width > 700) {
await expectInsideViewport(
page,
page.getByRole('button', { name: 'Fullscreen preview' }),
'preview button',
);
} else {
await expectInsideViewport(
page,
page.getByRole('button', { name: 'More actions' }),
'overflow menu button',
);
}
// Header controls stay reachable.
await expectInsideViewport(
page,
page.getByRole('button', { name: 'Fullscreen preview' }),
'preview button',
);
await expectInsideViewport(
page,
page.locator('.connection-indicator'),
Expand Down Expand Up @@ -328,8 +318,66 @@ test('sidebar is an off-canvas drawer at 800px, static at 1280px', async ({ page
await expectNoHorizontalScroll(page, '.editor-main');

await bootAt(page, 1280, 'editor-shell', '.editor-main');
await expect(page.getByRole('button', { name: 'Toggle sidebar' })).toHaveCount(0);
// The toggle is permanent chrome (Phase 5 review feedback): visible at
// every width, and the sidebar starts visible at 1280px.
const toggle = page.getByRole('button', { name: 'Toggle sidebar' });
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('.sidebar-sections')).toBeVisible();
});

test('sidebar toggle is a distinct chip in both states', async ({ page }) => {
await bootAt(page, 1280, 'editor-shell', '.editor-main');
const toggle = page.getByRole('button', { name: 'Toggle sidebar' });
const bare = page.getByRole('button', { name: 'Switch project' });
const styleOf = (el: HTMLElement) => {
const cs = getComputedStyle(el);
return { bg: cs.backgroundColor, border: cs.borderTopColor };
};
// The toggle wears the sidebar's own grey tint in both states — never
// bare/transparent like the title-bar buttons. Open deepens the tint.
const on = await toggle.evaluate(styleOf);
const bareStyle = await bare.evaluate(styleOf);
expect(on.bg).not.toBe(bareStyle.bg);
expect(on.bg).not.toBe('rgba(0, 0, 0, 0)');
// The switch-project button is the header's teal one: it exits the
// editor for the projects view. The toggle stays grey.
const switchColor = await bare.evaluate((el) => getComputedStyle(el).color);
expect(switchColor).toBe('rgb(65, 149, 153)'); // --posit-teal
// Sidebar off: still a visible chip (background + border), just greyer.
await toggle.click();
await expect(page.locator('.sidebar-sections')).toBeHidden();
const off = await toggle.evaluate(styleOf);
expect(off.bg).not.toBe('rgba(0, 0, 0, 0)');
expect(off.border).not.toBe('rgba(0, 0, 0, 0)');
expect(off.bg).not.toBe(on.bg);
});

test('sidebar toggle hides and restores the sidebar at 1280px', async ({ page }) => {
await bootAt(page, 1280, 'editor-shell', '.editor-main');
const toggle = page.getByRole('button', { name: 'Toggle sidebar' });
await toggle.click();
await expect(page.locator('.sidebar-sections')).toBeHidden();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expectNoHorizontalScroll(page, '.editor-main');
await toggle.click();
await expect(page.locator('.sidebar-sections')).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
});

test('sidebar hidden at 1280px stays closed as a drawer at 800px', async ({ page }) => {
await bootAt(page, 1280, 'editor-shell', '.editor-main');
const toggle = page.getByRole('button', { name: 'Toggle sidebar' });
await toggle.click();
await expect(page.locator('.sidebar-sections')).toBeHidden();
// Narrowing across the breakpoint must not pop the sidebar back: the
// drawer opens only when the user asks.
await page.setViewportSize({ width: 800, height: 720 });
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page.locator('.sidebar-drawer')).not.toBeInViewport();
await toggle.click();
await expect(page.locator('.sidebar-drawer')).toBeInViewport();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
});

test('sidebar drawer opens with scrim and moves focus in', async ({ page }) => {
Expand Down Expand Up @@ -396,7 +444,8 @@ test('split view collapses to the editor pane at 700px', async ({ page }) => {
await expect(page.locator('.preview-pane')).toBeHidden();
await expect(page.locator('.pane-divider')).toBeHidden();
await expect(page.locator('.editor-pane')).toBeVisible();
await expect(page.getByRole('button', { name: 'Split view' })).toBeDisabled();
// The view toggle itself is hidden this narrow (the header composition
// specs own that assertion); the Preview pill covers editor⇄preview.
await expectNoHorizontalScroll(page, '.editor-main');
});

Expand All @@ -406,26 +455,45 @@ test('split view intact at 900px (counter-check)', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Split view' })).toBeEnabled();
});

/* ---- header overflow menu (≤700px) ---- */
/* ---- smallest-header composition (≤700px, Phase 5 review) ----
Review feedback: the view-mode buttons are hidden this narrow (split
is collapsed anyway; the Preview pill covers editor⇄preview), and
Share + Preview stay inline — the kebab overflow menu is retired. */

test('header secondary actions collapse into an overflow menu at 700px', async ({ page }) => {
test('header at 700px: inline share + preview, no view toggle, no kebab', async ({ page }) => {
await bootAt(page, 700, 'editor-shell', '.editor-main');
await expect(page.locator('.minimal-header .preview-btn')).toBeHidden();
await expect(page.locator('.minimal-header .header-share-btn')).toBeHidden();
const overflow = page.getByRole('button', { name: 'More actions' });
await expect(overflow).toBeVisible();
await overflow.click();
const menu = page.locator('[role="menu"]');
await expect(menu).toBeVisible();
await expect(menu.getByRole('menuitem', { name: 'Share this project' })).toBeVisible();
await expect(menu.getByRole('menuitem', { name: 'Fullscreen preview' })).toBeVisible();
await menu.getByRole('menuitem', { name: 'Share this project' }).click();
await expect(page.locator('.minimal-header .preview-btn')).toBeVisible();
await expect(page.locator('.minimal-header .header-share-btn')).toBeVisible();
await expect(page.locator('.minimal-header .view-toggle-control')).toBeHidden();
await expect(page.getByRole('button', { name: 'More actions' })).toHaveCount(0);
// The inline actions still fire.
await page.locator('.minimal-header .header-share-btn').click();
await expect(page.getByTestId('header-last-action')).toHaveText('share');
});

test('header at 320px: same composition, nothing clipped', async ({ page }) => {
await bootAt(page, 320, 'editor-shell', '.editor-main');
await expect(page.locator('.minimal-header .preview-btn')).toBeVisible();
await expect(page.locator('.minimal-header .header-share-btn')).toBeVisible();
await expect(page.locator('.minimal-header .view-toggle-control')).toBeHidden();
await expectNoHorizontalScroll(page, '.minimal-header');
});

test('header actions inline at 1280px (counter-check)', async ({ page }) => {
await bootAt(page, 1280, 'editor-shell', '.editor-main');
await expect(page.locator('.minimal-header .preview-btn')).toBeVisible();
await expect(page.locator('.minimal-header .header-share-btn')).toBeVisible();
await expect(page.locator('.minimal-header .view-toggle-control')).toBeVisible();
await expect(page.getByRole('button', { name: 'More actions' })).toHaveCount(0);
});

/* ---- fullscreen preview at narrow widths (regression: the ≤700px
split-collapse rule must not hide the fullscreen preview pane) ---- */

for (const width of [700, 320] as const) {
test(`fullscreen preview shows the preview pane at ${width}px`, async ({ page }) => {
await bootAt(page, width, 'editor-shell-fullscreen', '.editor-main');
await expect(page.locator('.preview-pane.fullscreen')).toBeVisible();
await expectNoHorizontalScroll(page, '.editor-main');
});
}
40 changes: 38 additions & 2 deletions hub-client/src/components/DevHarness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,8 @@ function EditorShellHarness({ viewMode }: { viewMode: 'markup' | 'both' | 'previ
onToggleFullscreenPreview={() => setLastAction('fullscreen-preview')}
isFullscreenPreview={false}
isOnline={true}
sidebarOpen={drawer.drawerOpen}
onToggleSidebar={drawer.isDrawer ? drawer.toggle : undefined}
sidebarOpen={drawer.sidebarVisible}
onToggleSidebar={drawer.toggle}
sidebarToggleRef={drawer.toggleRef}
/>
<main className={`editor-main view-mode-${viewMode}`}>
Expand Down Expand Up @@ -591,6 +591,33 @@ const DEV_PAGES: Record<string, () => React.ReactNode> = {
'editor-shell': () => <EditorShellHarness viewMode="both" />,
'editor-shell-markup': () => <EditorShellHarness viewMode="markup" />,
'editor-shell-preview': () => <EditorShellHarness viewMode="preview" />,
// Fullscreen preview (Editor.tsx: header/editor-pane/divider unmount,
// the preview pane gains .fullscreen) — regression cover for the
// ≤700px split-collapse rule hiding the fullscreen pane.
'editor-shell-fullscreen': () => (
<EditorChrome>
<main className="editor-main view-mode-both">
<div className="pane preview-pane fullscreen">
<button className="fullscreen-close-btn" aria-label="Exit fullscreen preview">
</button>
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--editor-text-muted)',
fontFamily: 'var(--font-mono)',
fontSize: 12,
}}
>
Preview pane (fullscreen)
</div>
</div>
</main>
</EditorChrome>
),
// The About tab standalone (sidebar width) — covers the shortcuts
// reference, which the sidebar route's collapsed ABOUT section hides.
'about-tab': () => (
Expand All @@ -610,7 +637,16 @@ const DEV_PAGES: Record<string, () => React.ReactNode> = {
onToggleFullscreenPreview={() => {}}
isFullscreenPreview={false}
isOnline={true}
// The sidebar toggle is permanent chrome (Phase 5 review); the
// header route has no sidebar, so a static open state + dummy ref.
sidebarOpen={true}
onToggleSidebar={() => {}}
sidebarToggleRef={{ current: null }}
/>
{/* The toggle's aria-controls points at the drawer's id; in the
real app the drawer always exists, so the route stubs it
(axe's aria-valid-attr-value flags a dangling reference). */}
<div id="sidebar-drawer" hidden />
</EditorChrome>
),
notifications: () => (
Expand Down
13 changes: 11 additions & 2 deletions hub-client/src/components/Editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@
display: contents;
}

/* Static layout, sidebar hidden via the header toggle: the wrapper
leaves the layout entirely (the panes take the width). */
.sidebar-drawer.hidden {
display: none;
}

@media (max-width: 900px) {
.sidebar-drawer {
display: block;
Expand Down Expand Up @@ -306,8 +312,11 @@

/* Split-view collapse (Phase 5): two ~350px panes are useless this
narrow — the editor takes the width and the preview is one tap away
on the view toggle (whose split button is disabled at ≤700px). */
.editor-main.view-mode-both .preview-pane,
on the header's Preview button (the view toggle is hidden ≤700px —
MinimalHeader.css). The fullscreen preview pane keeps its place:
collapsing must not hide the one surface fullscreen mode exists to
show. */
.editor-main.view-mode-both .preview-pane:not(.fullscreen),
.editor-main.view-mode-both .pane-divider {
display: none;
}
Expand Down
4 changes: 2 additions & 2 deletions hub-client/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1078,8 +1078,8 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
onToggleFullscreenPreview={handleToggleFullscreenPreview}
isFullscreenPreview={isFullscreenPreview}
isOnline={isOnline}
sidebarOpen={sidebarDrawer.drawerOpen}
onToggleSidebar={sidebarDrawer.isDrawer ? sidebarDrawer.toggle : undefined}
sidebarOpen={sidebarDrawer.sidebarVisible}
onToggleSidebar={sidebarDrawer.toggle}
sidebarToggleRef={sidebarDrawer.toggleRef}
/>
{replayState.isActive && (
Expand Down
40 changes: 30 additions & 10 deletions hub-client/src/components/MinimalHeader.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,31 @@
Scoped to the media query because overflow != visible changes the
span's baseline alignment, and the 1280px baselines must stay
pixel-identical. */
/* Header overflow menu (Phase 5): hidden by default; at ≤700px the
secondary actions (share, fullscreen preview) collapse into it. */
.header-overflow {
display: none;
/* Sidebar toggle (Phase 5, design review): permanent chrome that reads
as the sidebar's own — the sidebar's background tint and border.
Grey in both states; opening the sidebar deepens the tint to the
sidebar's content layer. Never teal — teal marks the switch-project
action, which exits to another view. */
.minimal-header .sidebar-toggle-btn {
color: var(--editor-text-muted);
background: var(--sidebar-bg);
border-color: var(--sidebar-border);
}

.minimal-header .sidebar-toggle-btn[aria-expanded='true'] {
color: var(--text-secondary);
background: var(--input-bg-alpha);
}

.minimal-header .sidebar-toggle-btn:hover {
color: var(--text-primary);
background: var(--sidebar-section-hover);
}

/* The switch-project button is the header's one teal icon: it exits the
editor for the projects view. */
.minimal-header .header-switch-btn {
color: var(--posit-teal);
}

@media (max-width: 700px) {
Expand All @@ -102,14 +123,13 @@
text-overflow: ellipsis;
}

.minimal-header .header-share-btn,
.minimal-header .preview-btn {
/* Smallest-header composition (Phase 5 review): the view-mode buttons
are hidden this narrow — split view is collapsed (Editor.css) and
the Preview pill covers the editor⇄preview switch — leaving room for
the inline Share and Preview buttons. */
.minimal-header .view-toggle-control {
display: none;
}

.minimal-header .header-overflow {
display: block;
}
}

/* Very narrow windows (Phase 4): wrap to two rows — project controls and
Expand Down
Loading
Loading