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
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:
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 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 |
|---|---|---|---|
#FAF7F4 | panel ground | --surface-soft | exact |
#F5F0EB | ✕ hover tint | --paper-7 (via the shared ✕) | treatment-owned |
#1A1A1A | title | --ink-strong | exact |
#7A7A7A | subtitle | --ink-muted | exact |
#FFFFFF | keycap fill | --white | exact |
#5F5953 | ✕ glyph | --ink-app-body (via the shared ✕) | exact |
| 0 24px 60px /.10 | panel shadow | --shadow-app-card | same geometry, alpha .06 vs .10 |
| 0 1px 0 /.04 | keycap shadow | --shadow-subtle | exact |
#E1D9CF | panel border | --surface-hairline-app | ≈ 3/unit lighter |
#CFC4B6 | keycap border | --surface-hairline | ≈ 5–15/unit |
#A39A8E | group label | --ink-meta-muted | darker; chosen for semantic fit (metadata ink) over raw proximity |
#2A2621 / #3A352E | action + 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 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
| Story | Proves |
|---|---|
| Reading Room (registry map) | The default. Opens with no ring on the ✕. |
| Close button — hover | Hover is a tint. A hovered ✕ draws no box. |
| Close button — keyboard focus | Asserts 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 rows | Story-2398’s future map renders unchanged through this component. |
| No shortcuts on this page | A 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.tsx | Component | +86 | -39 |
| src/components/shortcuts/shortcuts-overlay.test.tsx | Component | +68 | -0 |
| src/components/shortcuts/shortcuts-overlay.tsx | Component | +50 | -19 |