diff --git a/frontend/e2e/specs/cross/responsive.spec.ts b/frontend/e2e/specs/cross/responsive.spec.ts
index 6aed8333..6693e5fb 100644
--- a/frontend/e2e/specs/cross/responsive.spec.ts
+++ b/frontend/e2e/specs/cross/responsive.spec.ts
@@ -24,8 +24,17 @@ test.describe('responsive', { tag: '@cross' }, () => {
expect(await page.evaluate(hasHorizontalOverflow)).toBe(false);
await page.getByRole('row', { name: /E2E Alpha/ }).click();
+ // The title, not the actions trigger: below md the star and the pager live inside
+ // the menu, and the trigger itself renders before the flow arrives.
+ await expect(page.locator('header').getByText('E2E Alpha')).toBeVisible();
await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible();
expect(await page.evaluate(hasHorizontalOverflow)).toBe(false);
+
+ await page.getByRole('button', { name: 'Flow actions' }).click();
+ await expect(page.getByRole('menuitem', { name: /Flows/ })).toBeVisible();
+ await expect(page.getByRole('menuitem', { name: /favorites/ })).toBeVisible();
+ await page.keyboard.press('Escape');
+
expectCleanPage(pageErrorLog);
});
});
diff --git a/frontend/e2e/specs/flows/create.spec.ts b/frontend/e2e/specs/flows/create.spec.ts
index 2bf08cb3..770483b1 100644
--- a/frontend/e2e/specs/flows/create.spec.ts
+++ b/frontend/e2e/specs/flows/create.spec.ts
@@ -43,7 +43,7 @@ test.describe('flow create', { tag: ['@flows', '@smoke'] }, () => {
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page).toHaveURL(/\/flows\/7/);
- await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible();
+ await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled();
await expect(page.locator('header').getByText('Say Hello Flow')).toBeVisible();
expectCleanPage(pageErrorLog);
});
diff --git a/frontend/e2e/specs/flows/lifecycle.spec.ts b/frontend/e2e/specs/flows/lifecycle.spec.ts
index 59ea3284..01ed2b98 100644
--- a/frontend/e2e/specs/flows/lifecycle.spec.ts
+++ b/frontend/e2e/specs/flows/lifecycle.spec.ts
@@ -17,7 +17,7 @@ import { FLOW_A, flowsCassette, makeFlow } from '../../mocks/cassettes/flows.ts'
const openFlowA = async (page: import('@playwright/test').Page) => {
await page.goto('/flows');
await page.getByRole('row', { name: /E2E Alpha/ }).click();
- await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible();
+ await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled();
};
test.describe('flow lifecycle', { tag: '@flows' }, () => {
diff --git a/frontend/e2e/specs/flows/tabs.spec.ts b/frontend/e2e/specs/flows/tabs.spec.ts
index 7145229f..216d75b3 100644
--- a/frontend/e2e/specs/flows/tabs.spec.ts
+++ b/frontend/e2e/specs/flows/tabs.spec.ts
@@ -21,7 +21,7 @@ test.describe('flow detail tabs', { tag: '@flows' }, () => {
test('each tab renders its populated content', async ({ page, pageErrorLog }) => {
await page.goto('/flows/5');
- await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible();
+ await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled();
for (const { marker, name } of TABS) {
await page.getByRole('tab', { name }).click();
@@ -33,7 +33,7 @@ test.describe('flow detail tabs', { tag: '@flows' }, () => {
test('the screenshot image decodes from its REST endpoint', async ({ page, pageErrorLog }) => {
await page.goto('/flows/5');
- await expect(page.getByRole('button', { name: 'Flow actions' })).toBeVisible();
+ await expect(page.locator('header').getByRole('button', { name: 'Toggle favorite' })).toBeEnabled();
await page.getByRole('tab', { name: 'Screenshots' }).click();
const image = page.getByRole('img', { name: TABS_SCREENSHOT_NAME });
diff --git a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png
index 2570ff10..c357674f 100644
Binary files a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png and b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-dark-visual-linux.png differ
diff --git a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png
index dedda0f9..724793c1 100644
Binary files a/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png and b/frontend/e2e/specs/visual/routes.spec.ts-snapshots/flows-5-light-visual-linux.png differ
diff --git a/frontend/src/components/layouts/app/app-header.tsx b/frontend/src/components/layouts/app/app-header.tsx
index 3a4c896a..0a5f135d 100644
--- a/frontend/src/components/layouts/app/app-header.tsx
+++ b/frontend/src/components/layouts/app/app-header.tsx
@@ -55,23 +55,10 @@ export function AppHeaderAction({
);
}
-export function AppHeaderActions({
- children,
- className,
- pager,
-}: {
- children?: ReactNode;
- className?: string;
- pager?: ReactNode;
-}) {
- // Pass a detail prev/next control as `pager`, not as a child: as the trailing child it stays edge-pinned,
- // so a conditional neighbour (e.g. a Report button that loads late) can't shift it under the cursor mid-click.
- return (
-
- {children}
- {pager}
-
- );
+// The cluster is right-pinned, so it grows leftward: controls that come and go belong at the
+// start of the children, and everything after them keeps its position when they appear.
+export function AppHeaderActions({ children, className }: { children?: ReactNode; className?: string }) {
+ return
{children}
;
}
export function AppHeaderContent({ children, className }: { children: ReactNode; className?: string }) {
diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx
index d60451f4..692811e0 100644
--- a/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx
+++ b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx
@@ -36,6 +36,9 @@ export function DetailNavigationButtons({
const isSm = size === 'sm';
const sideButtonSize = isSm ? 'size-7' : 'size-8';
const middleHeight = isSm ? 'h-7' : 'h-8';
+ // Reserve the widest label the set can produce ("2409/2409"), so stepping across a digit
+ // boundary cannot widen the counter and slide Previous out from under the cursor.
+ const reservedLabelWidth = `${String(controller.total).length * 2 + 1}ch`;
return (