diff --git a/src/AGENTS.md b/src/AGENTS.md
index 0a476125..7f9a0855 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -38,6 +38,14 @@ The code of the docs site and the status page. The root `AGENTS.md` still applie
- `src/status/` reads the store that a separate checker writes, and never writes to it. It is not prerendered, because it answers from the store at request time.
- The status build has no `@/` alias. Code under `src/status/`, and any shared module it imports, such as `src/components/analytics.tsx` and `src/lib/analytics.ts`, uses relative imports.
+## Focus
+
+- **The ring shows only after Tab, never after a click.** A browser matches `:focus-visible` on an element the reader clicked as soon as any key is pressed, such as an arrow to scroll, so `:focus-visible` alone draws a stray box. `PointerFocus` in `src/components/pointer-focus.tsx`, mounted in both root routes, sets `data-input` on the `html` element: `pointer` on `pointerdown`, `keyboard` on Tab or Shift+Tab. Arrow keys, space and typing change nothing. Before either, there is no value and the ring may show.
+- **One rule draws it.** The "Pointer and focus" section of `src/styles/app.css` draws a 2px ring in half-strength foreground at offset 2px, and switches it off under `html[data-input='pointer']` for everything but a text field, which keeps its focus after a click. It also turns off the shadow ring that Fumadocs components add with `focus-visible:ring-2`, so only one ring is drawn. The same section gives enabled buttons, tabs, summaries and selects the pointer cursor.
+- **Never add per-component `:focus` or `:focus-visible` styles, `outline: none` or `focus-visible:` ring utilities.** They fight the rule and bring back a ring after a click. A new control, scrollable region or custom widget inherits the global rule. If its box clips an outside ring (`overflow: hidden`, a scroll container), add its selector to the inset list in that section, which draws the ring at offset -2px, rather than styling it on its own.
+- A script-focused target that is not in the tab order needs `tabindex="-1"`, which the global rule leaves without a ring.
+- Do not hide a control with `opacity: 0` unless it shows again on focus, because its ring is hidden with it.
+
## Tests
- Tests sit beside the code as `*.test.ts` or `*.test.tsx`. Each content lint rule has a test that proves it fires on a bad sample; give a new rule one.
@@ -52,3 +60,4 @@ The code of the docs site and the status page. The root `AGENTS.md` still applie
- **Analytics grown.** Flag a value import of `posthog-js` outside `src/components/analytics.tsx`, an option that turns on capture, replay, flags or storage, or an agent-read event that gains an IP address, a cookie, a header, a query, a page's text or a value that came from a client and matched no closed list, or that is sent without `$process_person_profile: false`, awaited by the response, or from a build or test without a key.
- **Limit weakened.** Flag a change that makes the `429` on `/mcp`, `/api/docs/search` or `/api/search` differ from the shapes above, keys a limit on anything but the client's address, stores or sends that address, moves the stage behind work the limit should save, leaves a counted address with no bound on its events, or sends an event for a `429`.
- **Alias in the status build.** Flag an `@/` import that code under `src/status/` can reach.
+- **Focus ring bypassed.** Flag a `:focus` or `:focus-visible` rule, `outline: none`, or a `focus-visible:` ring or outline utility on a component, a ring that shows after a pointer click, or a change to `PointerFocus` that sets `keyboard` on anything but Tab.
diff --git a/src/components/page-table-of-contents.tsx b/src/components/page-table-of-contents.tsx
index 28f4f796..bb90e328 100644
--- a/src/components/page-table-of-contents.tsx
+++ b/src/components/page-table-of-contents.tsx
@@ -116,7 +116,7 @@ export function PageTableOfContentsPopover() {
type="button"
aria-controls={contentId}
aria-expanded={open}
- className="flex h-10 w-full items-center gap-2.5 px-4 py-2.5 text-start text-sm text-fd-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-fd-ring"
+ className="flex h-10 w-full items-center gap-2.5 px-4 py-2.5 text-start text-sm text-fd-muted-foreground"
onClick={() => setOpen((value) => !value)}
>
diff --git a/src/components/pointer-focus.tsx b/src/components/pointer-focus.tsx
index 0360b353..6d8e204d 100644
--- a/src/components/pointer-focus.tsx
+++ b/src/components/pointer-focus.tsx
@@ -1,19 +1,30 @@
import { useEffect } from 'react';
-// A link clicked with a pointer navigates within the app, so the page changes
-// but focus stays on the link. The next key the reader presses, such as an
-// arrow to scroll, then makes the browser treat that focus as keyboard focus
-// and draw the ring on a sidebar row. Releasing focus after a pointer click
-// avoids that; a link followed from the keyboard (detail 0) keeps its focus.
+// Records how the reader last moved focus, on the root element, so the focus
+// ring in src/styles/app.css shows only after Tab. A browser matches
+// :focus-visible on an element the reader clicked as soon as any key is
+// pressed, so the ring cannot be left to :focus-visible alone.
+//
+// `data-input="pointer"` on any press of the mouse, pen or finger;
+// `data-input="keyboard"` on Tab or Shift+Tab. Arrow keys, space and typing
+// change nothing. Before either, there is no attribute and the ring may show.
export function PointerFocus() {
useEffect(() => {
- const onClick = (event: MouseEvent) => {
- if (event.detail === 0) return;
- const link = (event.target as Element | null)?.closest?.('a');
- if (link) setTimeout(() => link.blur(), 0);
+ const root = document.documentElement;
+ const onPointerDown = () => {
+ root.dataset.input = 'pointer';
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) return;
+ root.dataset.input = 'keyboard';
+ };
+ document.addEventListener('pointerdown', onPointerDown, true);
+ document.addEventListener('keydown', onKeyDown, true);
+ return () => {
+ document.removeEventListener('pointerdown', onPointerDown, true);
+ document.removeEventListener('keydown', onKeyDown, true);
+ delete root.dataset.input;
};
- document.addEventListener('click', onClick, true);
- return () => document.removeEventListener('click', onClick, true);
}, []);
return null;
}
diff --git a/src/status/routes/__root.tsx b/src/status/routes/__root.tsx
index 2487c62d..2e04adf7 100644
--- a/src/status/routes/__root.tsx
+++ b/src/status/routes/__root.tsx
@@ -5,6 +5,7 @@ import { RootProvider } from 'fumadocs-ui/provider/tanstack';
// without either one owning a copy of the other's theme.
import appCss from '../../styles/app.css?url';
import { Analytics } from '../../components/analytics';
+import { PointerFocus } from '../../components/pointer-focus';
export const Route = createRootRoute({
head: () => ({
@@ -46,6 +47,7 @@ function RootComponent() {
+