From 64f14a3e59b38cc98f805ee13ff7911c50276d75 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 22:00:22 -0700 Subject: [PATCH] fix(workspace): focus the page title on a route change A sidebar or history navigation now focuses #page-title, as the concept does on every hashchange (app.js 1168), so a screen reader announces the page heading instead of the main region. The first render leaves focus alone and the skip link still focuses main. The heading module's comment now describes what the shell does. --- tests/browser/workspace.spec.mjs | 19 +++++++++++++++++++ web/js/workspace/heading.mjs | 3 ++- web/js/workspace/index.mjs | 5 +++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 2c0e2be..45166e2 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -112,6 +112,25 @@ test('skip to content focuses the current view without changing its route', asyn await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); }); +// The concept focuses its page title on every hashchange navigation (docs/concepts/workspace/app.js 1168), so a screen +// reader announces ", heading level 1"; the first render leaves focus alone. +test('a sidebar route change focuses the page title, as the concept does', async ({ page }) => { + await page.goto(`${workspace}#/library`); + const title = page.locator('#page-title'); + await expect(title).toHaveText('Prompt library'); + await expect(title).not.toBeFocused(); + await sidebarLink(page, 'Images').click(); + await expect(title).toHaveText('Images'); + await expect(title).toBeFocused(); + await expect(page.locator('#workspace-main')).not.toBeFocused(); + await sidebarLink(page, 'Tags & categories').click(); + await expect(title).toHaveText('Tags & categories'); + await expect(title).toBeFocused(); + await page.goBack(); + await expect(title).toHaveText('Images'); + await expect(title).toBeFocused(); +}); + test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => { await page.goto(`${workspace}#/overview`); const prompts = statistic(page, 'Prompts'); diff --git a/web/js/workspace/heading.mjs b/web/js/workspace/heading.mjs index dacba4a..ba111f1 100644 --- a/web/js/workspace/heading.mjs +++ b/web/js/workspace/heading.mjs @@ -18,7 +18,8 @@ function partsOf(heading) { /** * Replace `main`'s content with `.heading` > div (`h1#page-title[tabindex=-1]`, `p.muted`) + `.heading-actions`. - * The h1 is the focus target after a save or a route change, as the concept's page title is. + * The h1 is the focus target after a save, and the shell focuses it on every route change after the first, as the + * concept focuses its page title on hashchange (app.js 1168). The skip link focuses `main` instead. */ export function renderHeading(main, { title = '', description = '', actions = [] } = {}) { const heading = element('div', 'heading'); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index b57de5f..44208cb 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -89,8 +89,9 @@ function mountWorkspace() { } document.title = `${routeTitle(route)} ยท Prompt Manager`; // The concept's heading(); each view fills its description and actions. - const { description } = renderHeading(main, { title: route.label }); - if (!firstRender) main.focus(); + // The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`. + const { description, h1 } = renderHeading(main, { title: route.label }); + if (!firstRender) h1.focus(); firstRender = false; document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); if (route.id === 'images') {