Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
2 changes: 1 addition & 1 deletion src/components/page-table-of-contents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
>
<Text className="size-4 shrink-0" aria-hidden="true" />
Expand Down
33 changes: 22 additions & 11 deletions src/components/pointer-focus.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/status/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({
Expand Down Expand Up @@ -46,6 +47,7 @@ function RootComponent() {
</RootProvider>
<Scripts />
<Analytics site="status" />
<PointerFocus />
</body>
</html>
);
Expand Down
79 changes: 56 additions & 23 deletions src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,8 @@
}

/* Sidebar groups and selected-row fills already communicate hierarchy and
location. Remove the generated rails, retain a small nested offset, and keep
keyboard focus visible inside the scroll viewport rather than clipping an
external browser outline. */
location. Remove the generated rails, retain a small nested offset, and draw
keyboard focus inside the row, so the scroll viewport never clips it. */
#nd-sidebar [class*='before:bg-fd-border']::before,
#nd-sidebar-mobile [class*='before:bg-fd-border']::before,
#nd-sidebar a[data-active='true']::before,
Expand All @@ -307,7 +306,6 @@
}
#nd-sidebar a:focus-visible,
#nd-sidebar-mobile a:focus-visible {
outline: 2px solid var(--color-fd-ring);
outline-offset: -2px;
}

Expand Down Expand Up @@ -565,9 +563,6 @@ div[data-orientation='horizontal']:has(> [role='tabpanel'] > figure.shiki:only-c
.marfa-fold-button:hover {
color: var(--color-fd-foreground);
}
.marfa-fold-button:focus-visible {
outline-offset: -2px;
}

.marfa-terminal {
margin-block: 1rem;
Expand Down Expand Up @@ -604,7 +599,6 @@ div[data-orientation='horizontal']:has(> [role='tabpanel'] > figure.shiki:only-c
color: var(--color-fd-muted-foreground);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
list-style: none;
transition: color 0.15s;
}
Expand All @@ -614,9 +608,6 @@ div[data-orientation='horizontal']:has(> [role='tabpanel'] > figure.shiki:only-c
.marfa-terminal-fold > summary:hover {
color: var(--color-fd-foreground);
}
.marfa-terminal-fold > summary:focus-visible {
outline-offset: -2px;
}
.marfa-terminal-fold > summary > svg {
width: 0.875rem;
height: 0.875rem;
Expand Down Expand Up @@ -705,15 +696,11 @@ div[data-orientation='horizontal']:has(> [role='tabpanel'] > figure.shiki:only-c
.marfa-paged-arrow[aria-disabled='true'] {
background-color: color-mix(in oklab, var(--color-fd-foreground) 3%, transparent);
color: color-mix(in oklab, var(--color-fd-foreground) 28%, transparent);
cursor: default;
}
.marfa-paged-arrow > svg {
width: 1rem;
height: 1rem;
}
.marfa-paged-step:focus {
outline: none;
}
/* A link to a step's heading lands with the card's top row in view: the
card's own offset plus its border, padding, top row and gap. */
.marfa-paged-step :is(h2, h3, h4)[id] {
Expand Down Expand Up @@ -772,21 +759,67 @@ div[data-orientation='horizontal']:has(> [role='tabpanel'] > figure.shiki:only-c
}
}

/* ── Focus ────────────────────────────────────────────────────────────────
One neutral ring for keyboard focus everywhere, in place of the browser's
blue one, and none after a click. */
/* ── Pointer and focus ────────────────────────────────────────────────────
Tailwind's reset gives every button the default cursor. Anything a reader
can press shows a pointer, unless it is disabled. */
:is(
button,
input[type='button'],
input[type='submit'],
input[type='reset'],
summary,
select,
[role='button'],
[role='tab'],
[role='switch']
):not(:disabled, [aria-disabled='true'], [data-disabled]) {
cursor: pointer;
}

/* One neutral ring for keyboard focus everywhere, and none after a click.
src/components/pointer-focus.tsx sets data-input on the root element:
"keyboard" once the reader presses Tab, "pointer" on any press of the
mouse, pen or finger. Browsers match :focus-visible after a click once a
key is pressed, such as an arrow to scroll, so :focus-visible alone would
draw a ring on a button the reader only clicked. A text field keeps its
focus after a click, because the reader is typing there.
Fumadocs also draws a ring of its own with a shadow, which the first
declaration turns off. */
:focus-visible {
outline: 2px solid var(--color-fd-ring);
--tw-ring-shadow: 0 0 #0000;
outline: 2px solid color-mix(in oklab, var(--color-fd-foreground) 50%, transparent);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
html[data-input='pointer']
:focus-visible:not(
:is(
input:not([type='button'], [type='submit'], [type='reset'], [type='checkbox'], [type='radio']),
textarea,
select,
[contenteditable]:not([contenteditable='false'])
)
) {
outline: none;
}
/* A tab list scrolls sideways and would clip a ring drawn outside it, and the
search field sits in a box that is already its own focus indicator. */
[role='tab']:focus-visible {
/* A target that script focuses, such as a step or a dialog, is never reached
with Tab, and a ring around the whole of it says nothing. */
[tabindex='-1']:focus-visible {
outline: none;
}
/* A link follows the shape of its text unless a class already rounds it. */
a:where(:not([class*='rounded'])):focus-visible {
border-radius: 4px;
}
/* A scrollable region, a tab list or a summary sits in a box that clips
what is drawn outside it, so its ring is drawn inside. */
[tabindex='0']:focus-visible,
[role='tab']:focus-visible,
summary:focus-visible,
[data-toc-popover] button:focus-visible,
.marfa-fold-button:focus-visible {
outline-offset: -2px;
}
/* The search field sits in a box that is already its own focus indicator. */
[role='dialog'] input:focus-visible {
outline: none;
}
Expand Down
5 changes: 0 additions & 5 deletions src/styles/visuals.css
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,6 @@
font-weight: 500;
font-size: 0.8125rem;
line-height: 1.25rem;
cursor: pointer;
transition:
background-color 0.15s,
border-color 0.15s;
Expand All @@ -876,10 +875,6 @@
background: var(--color-fd-accent);
color: var(--color-fd-foreground);
}
.marfa-ix-button:focus-visible {
outline: 2px solid var(--color-fd-ring);
outline-offset: 2px;
}
.marfa-ix-reset {
margin-left: auto;
border-color: transparent;
Expand Down
Loading