PR: #620: feat(shortcuts): overlay v2 — canonical frame, focus-visible-only close ring [Story-2401] by @ankitmaheshwari-com
View PR →

Story-2401 · pramaan-app · 15 Jul 2026

The ring belongs to the keyboard

Two things were wrong with the ? cheat sheet. It opened with a box drawn around its own ✕, which counsel reads as a stuck hover state — and it wore the default dialog chrome rather than the frame the founder’s designer drew. This Story fixes the ring at its actual cause (not the one the ticket guessed), and rebuilds the panel against the canonical frame. The rows themselves stay a read of the shortcuts registry, which is why the zoom keys will appear here on the day Story-2398 registers them, with no edit to this component.

The one idea

A focus ring is an answer to a question only the keyboard asks. Counsel who reaches for a mouse never asked it. The ring must therefore be tied not to being focused but to having arrived by keyboard — and that means controlling what gets focused when the sheet opens, not just how a focused thing is painted.

The bug was not where the ticket said it was

The ticket read: “Bug-2387’s fix kept a plain :focus ring; it must be :focus-visible ONLY.” That diagnosis did not survive contact with the code. closeIconButtonClassName — the one ✕ treatment shared by dialog, sheet and peek — was already focus-visible:-only, and had been since Bug-2387. Painting it again would have changed nothing, and the box would have survived the fix.

The real cause is one step upstream, in who receives focus:

counsel presses ? ← a KEYBOARD interaction Radix opens the dialog Radix autofocuses the first tabbable → the ✕ (it is the only tabbable in a cheat sheet) the browser sees programmatic focus, last input = keyboard → :focus-visible LEGITIMATELY matches → a ring is painted around the ✕ the instant the sheet appears

So the ring was never a wrong selector. It was a correct selector answering a question nobody asked, because the ✕ was handed a focus it never earned. The fix is to give the opening focus to the panel instead:

onOpenAutoFocus on the overlay’s DialogContent: prevent Radix’s default, and focus the content element (Radix already gives it tabIndex=-1). The dialog is still announced to a screen reader, Escape still closes, Tab still traps — but the ring now waits for a Tab that actually walks onto the ✕. Mouse: no ring. Tab: ring. Which is the acceptance criterion.

This is scoped to the shortcuts overlay, deliberately. Radix’s default (autofocus the first tabbable) is correct for a form dialog, where the first tabbable is the first field counsel wants to type into. It is only wrong where the first tabbable is the ✕ — a read-only sheet. Changing DialogContent globally would have traded this bug for a regression in every form in the app.

What shipped

Piece What it does
The panel, rebuilt to the frame 620px wide, --surface-soft ground, 20px radius, the app-card shadow, and 36/40/40 padding. The header becomes a row: a Playfair 30px title over a 16px muted subtitle on the left, the ✕ on the right, 28px of air below.
The keycaps Each <kbd> is now a physical cap — 40×36 minimum, white fill, a 1.5px border thickened to 2.5px along the bottom edge (the frame’s one bit of dimension), 8px radius, and the --shadow-subtle hairline beneath. Set in Inter, per the frame — not the mono face the v1 overlay used.
The rows Group labels at 12px/600, 0.14em tracking, uppercase; actions at 17px in warm near-black; 9px of vertical padding per row; 22px between groups. The hierarchy the founder asked for is carried by type and air, not by rules or boxes.
The ✕ keeps its converged treatment Borderless, hover tint, focus-visible ring — the shared CloseIconButton from Bug-2387, sized to the frame’s 40px. See the conflict flagged below.
The rows still come from the registry Untouched. The overlay takes translated groups and draws whatever it is handed. No list of keys exists in this component to fall out of date.

The conflict, flagged rather than silently resolved

The canonical frame draws a bordered ✕. Repo doctrine says the ✕ is borderless. We followed doctrine.

The frame gives the close button a white fill, a 1px #DAD1C6 border and a 10px radius. That is a direct contradiction of Bug-2387, which converged every overlay’s ✕ — dialog, sheet, peek — onto one borderless treatment whose entire hover affordance is a token tint. AGENTS.md fixes the tie-break: repo-local primitives in src/components/ui/ (authority 2) outrank the design frame (authority 3). So the overlay uses the shared borderless CloseIconButton at the frame’s 40px size.

This is raised, not buried: if the founder wants the frame’s bordered cap, the change belongs in close-icon-button.tsx and lands on all overlays at once — which is exactly the convergence Bug-2387 bought, and not something this Story may spend unilaterally.

Token reconciliation

Every value below is a token from design/v1/colors_and_type.css. No hex, no rgb(), no hsl() entered src/. Nine of the frame’s colours land on a token exactly; four are near-misses inside the same warm neutral ramp, listed here so the delta is a decision on the record rather than a drift.

Frame Role Token used Delta
#FAF7F4panel ground--surface-softexact
#F5F0EB✕ hover tint--paper-7 (via the shared ✕)treatment-owned
#1A1A1Atitle--ink-strongexact
#7A7A7Asubtitle--ink-mutedexact
#FFFFFFkeycap fill--whiteexact
#5F5953✕ glyph--ink-app-body (via the shared ✕)exact
0 24px 60px /.10panel shadow--shadow-app-cardsame geometry, alpha .06 vs .10
0 1px 0 /.04keycap shadow--shadow-subtleexact
#E1D9CFpanel border--surface-hairline-app≈ 3/unit lighter
#CFC4B6keycap border--surface-hairline≈ 5–15/unit
#A39A8Egroup label--ink-meta-muteddarker; chosen for semantic fit (metadata ink) over raw proximity
#2A2621 / #3A352Eaction + keycap text--ink-3≈ 4/unit; one ink now serves both

No new token is proposed. Every miss sits within a few units of an existing warm neutral, which is what the ramp is for; inventing four near-duplicates would dilute it.

The zoom rows, without a hand-edit

The acceptance criterion is that zoom rows “appear automatically when the keyboard-zoom story merges (registry, no hand-edit)”. That is already the shape of this component and it stays that way: the overlay renders ShortcutGroupView[] and knows nothing about which keys exist.

Story-2398 adds a binding to READING_ROOM.view in shortcut-registry.ts → toShortcutGroupViews() translates it → the overlay draws one more row → zero lines changed in shortcuts-overlay.tsx

Story-2398 owns the key map (− / = / 0); this Story owns the presentation. So the registry is deliberately not touched here — two agents editing one map is how a merge conflict becomes a lost binding. The WithZoomRows story stands in as the proof: it hands the overlay tomorrow’s map as data and shows the rows render, today.

Storybook

StoryProves
Reading Room (registry map)The default. Opens with no ring on the ✕.
Close button — hoverHover is a tint. A hovered ✕ draws no box.
Close button — keyboard focusAsserts the ✕ is unfocused on open, then one Tab lands on it — the ring’s only door.
Hindi copy (registry map)The same registry map through the real messages/hi.json — not hand-typed Hindi.
With zoom rowsStory-2398’s future map renders unchanged through this component.
No shortcuts on this pageA surface that binds nothing says so.

Verification

pnpm lint — 0 errors.
pnpm test — 1297 passed, incl. the new shortcuts-overlay.test.tsx: the shared ✕ class carries no bare focus: ring, the ✕ is not focused on open (the panel is), and every registry binding renders a row.
pnpm build — green.
pnpm storybook:check — drift + build green; the frame’s arbitrary values (tracking-[0.14em], text-[17px], py-[9px], rounded-[20px], border-b-[2.5px]) were confirmed present in the emitted CSS, not silently dropped (L1).

Observability — instrumentation-exempt per pramaan-docs ADR-124: a client-only presentation change to an existing overlay. No route, handler, job, queue or workflow boundary was introduced.

Files changed

3 file(s), +204 / -58 lines

Path Role + -
src/components/shortcuts/shortcuts-overlay.stories.tsxComponent+86-39
src/components/shortcuts/shortcuts-overlay.test.tsxComponent+68-0
src/components/shortcuts/shortcuts-overlay.tsxComponent+50-19