Keep the Patrol decision header pinned while a long decision scrolls (#2445)

On phones the Patrol attention detail header holds Back, Previous and
Next issue. It was authored `sticky top-0`, but the inbox `<section>`
used `overflow-hidden`, which made that section the sticky scroll
container. Below `lg` the header moved exactly with the section, so on
long decisions the queue controls scrolled away.

- Inbox section: `overflow-hidden` → `overflow-clip`. The rounded
corners are still clipped, but the section no longer creates a scroll
container, so the header pins against `.app-scroll-shell`.
- Header: adds the opaque `bg-surface/95` it was first authored with. PR
#2443 removed it because Tailwind 3 never rendered it. Now that the
header sticks, content would otherwise show through.

Verified on a mock stack in light and dark:
- **390px:** the header sits at 484/284/84/4/4px while the shell scrolls
0/200/400/600/max. Before the fix it followed the section off screen. It
stops 4px down because of the shell's `py-1` padding.
- **1440px:** with Evidence and history expanded, the header stays
opaque at the top of the detail panel's own scroller.
- **1024px:** checked as well.

The receipt is
`frontend-modern/browser-verification/56c38699b5a0cd36.json`. A
regression test in `PatrolAttentionWorkbench.test.tsx` pins the class
contract.
This commit is contained in:
rcourtman
2026-10-03 21:15:10 +01:00
committed by GitHub
6 changed files with 94 additions and 2 deletions
@@ -11084,3 +11084,11 @@ the renamed Settings > Pulse Mobile section instead of Remote Access. Codes,
severities and the response shape are unchanged. The API reference heading
for the relay protocol records that Pulse Mobile retires on 31 March 2027, and
the published copy stays identical to `docs/API.md`.
### Patrol attention detail header pinning
Pinning the Patrol attention detail header on phones
(`frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx`) is a
layout-only change. It reads no new attention fields, sends no new request,
and leaves the attention projection, lifecycle, and queue ordering contracts
above unchanged.
@@ -3433,6 +3433,15 @@ Patrol is off or its model needs setup, while retaining the existing setup task
above it. Desktop and narrow browser checks must show the task and attention
list together, then allow a selected decision to open and return without
changing the disabled Patrol controls or overflowing the viewport.
The selected-detail header (title, queue position, Back, and the phone
Previous and Next issue controls) is `sticky top-0` with an opaque
`bg-surface/95` background, and the `Patrol decision inbox` section clips its
rounded corners with `overflow-clip`, never `overflow-hidden`. An
`overflow-hidden` ancestor becomes the sticky scroll container, so on phones
the header scrolled away with the page and took the queue controls with it.
Below `lg` the header pins against `.app-scroll-shell`; at `lg` it pins inside
the detail panel's own scroller. The class contract is pinned in
`frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx`.
The objective brief and optional-context fields in `PatrolObjectivesPanel`
share the same `FormTextarea` ownership contract.
@@ -1260,6 +1260,12 @@ proof, including failed verification and reduced motion, remains
The durable decision and proof record is
`internal/records/operational-trust-governed-docker-restart-2026-07-19.md`.
Reviewing a long decision on a phone must not lose the queue controls: the
selected-detail header with Back, Previous, and Next issue stays pinned while
the detail scrolls. The shell-level rule (opaque sticky header, `overflow-clip`
inbox section) is owned by the frontend-primitives attention workbench shell
contract.
## Current State
The Patrol 2.0 frontend now uses a one-task-at-a-time, decision-first
@@ -0,0 +1,42 @@
{
"version": 1,
"base_sha": "beb6c3f6a6f3fad74a1b9f76745bacdadcab7834",
"verified_at": "2026-10-03T18:34:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx"
],
"content_sha256": {
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "5d7c824c96e90ee8c4b8a8a92ac34e42ecd3f053925512de6fa0408f6bd0370e"
},
"routes": [
"/patrol"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 1024,
"height": 768
},
{
"width": 390,
"height": 844
}
],
"states": [
"mock stack, decision inbox with 50 decisions, light and dark color schemes",
"390px touch: selected decision detail with Back to list, Previous and Next issue in the header; header top 484/284/84/4/4 while .app-scroll-shell scrolls 0/200/400/600/max, staying topmost at the shell padding edge",
"390px: header background resolves to surface at 95% opacity in light and dark, scrolled content no longer shows through",
"1440px: Evidence and history expanded so the detail panel overflows (1165/832); header stays at the panel top while the panel scrolls 0/300/333, opaque in both themes",
"inbox section computed overflow clip with 12px corner radius, rounded corners still clipped at 1440"
],
"interactions": [
"clicked Start review to open the highest-priority decision",
"scrolled .app-scroll-shell at 390px and checked the header stays the topmost element at its position",
"expanded Evidence and history and scrolled the lg detail panel at 1440px and 1024px",
"stepped through all 50 decisions with Next decision at 1440px"
]
}
@@ -384,7 +384,7 @@ export function PatrolAttentionWorkbench(
return (
<section
aria-label="Patrol decision inbox"
class="overflow-hidden rounded-xl border border-border bg-surface shadow-xs"
class="overflow-clip rounded-xl border border-border bg-surface shadow-xs"
>
<div class="border-b border-border px-4 py-4 sm:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -877,7 +877,7 @@ function AttentionDetail(props: {
aria-labelledby="attention-detail-title"
aria-busy={props.loading}
>
<div class="sticky top-0 z-20 border-b border-border px-4 py-3 backdrop-blur-sm sm:px-5">
<div class="sticky top-0 z-20 border-b border-border bg-surface/95 px-4 py-3 backdrop-blur-sm sm:px-5">
<div class="flex items-center justify-between gap-3">
<button
type="button"
@@ -479,6 +479,33 @@ describe('PatrolAttentionWorkbench', () => {
expect(window.location.search).toBe('?attention=record-1');
});
it('keeps the detail header and queue controls pinned while a long decision scrolls', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
renderWorkbench();
fireEvent.click(
await screen.findByRole('button', { name: 'Open Database VM · Disk pressure' }),
);
const detailRegion = await screen.findByRole('complementary', {
name: 'Database VM · Disk pressure',
});
const header = within(detailRegion)
.getByRole('button', { name: 'Back to attention list' })
.closest('div.sticky');
expect(header).toHaveClass('top-0', 'bg-surface/95');
// overflow-hidden would make the inbox section the sticky scroll container,
// so on phones the header scrolled away with the page; overflow-clip keeps the
// rounded-corner clipping without creating a scroll container.
const inbox = screen.getByRole('region', { name: 'Patrol decision inbox' });
expect(inbox).toHaveClass('overflow-clip', 'rounded-xl');
expect(inbox).not.toHaveClass('overflow-hidden');
});
it('uses plain scan-row language while keeping unknown trust metadata in detail', async () => {
const unknownTrust = item({
state: 'unknown',