diff --git a/claude-notes/plans/2026-08-25-hub-client-uiux-modernization.md b/claude-notes/plans/2026-08-25-hub-client-uiux-modernization.md index d2761182f..650b37269 100644 --- a/claude-notes/plans/2026-08-25-hub-client-uiux-modernization.md +++ b/claude-notes/plans/2026-08-25-hub-client-uiux-modernization.md @@ -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 diff --git a/hub-client/changelog.md b/hub-client/changelog.md index a318d598d..19b423a43 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -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. diff --git a/hub-client/e2e/viewport-matrix.visual.spec.ts b/hub-client/e2e/viewport-matrix.visual.spec.ts index 683239ee2..d8db55b64 100644 --- a/hub-client/e2e/viewport-matrix.visual.spec.ts +++ b/hub-client/e2e/viewport-matrix.visual.spec.ts @@ -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'), @@ -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 }) => { @@ -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'); }); @@ -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'); + }); +} diff --git a/hub-client/src/components/DevHarness.tsx b/hub-client/src/components/DevHarness.tsx index 13af56036..08b0242e8 100644 --- a/hub-client/src/components/DevHarness.tsx +++ b/hub-client/src/components/DevHarness.tsx @@ -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} />
@@ -591,6 +591,33 @@ const DEV_PAGES: Record React.ReactNode> = { 'editor-shell': () => , 'editor-shell-markup': () => , 'editor-shell-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': () => ( + +
+
+ +
+ Preview pane (fullscreen) +
+
+
+
+ ), // The About tab standalone (sidebar width) — covers the shortcuts // reference, which the sidebar route's collapsed ABOUT section hides. 'about-tab': () => ( @@ -610,7 +637,16 @@ const DEV_PAGES: Record 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). */} +