Agents are not a second audience here. Every page answers in plain
markdown to any client that does not ask for HTML, identity is an
Ed25519 keypair rather than an account, and each push produces a
@@ -53,7 +53,7 @@ export function AgentSurface() {
-
+
{SURFACES.map(surface => (
void;
+ links: readonly { to: string; label: string; end?: boolean }[];
+ isActive: (to: string, end?: boolean) => boolean;
+ node: NodeInfo | null;
+ stats: NodeStats | null;
+}
+
+/** Machine-readable copies, the thing that makes this explorer unusual. */
+const TEXT_SURFACES = [
+ { href: '/llms.txt', label: 'llms.txt' },
+ { href: '/skill.md', label: 'skill.md' },
+];
+
+export function MobileNavDrawer({
+ open,
+ onOpenChange,
+ links,
+ isActive,
+ node,
+ stats,
+}: MobileNavDrawerProps) {
+ // autoFocus moves focus into the sheet on open. Vaul leaves it off by
+ // default, which left focus on : a keyboard or screen-reader user
+ // opened the menu and was still outside it, with the next Tab starting from
+ // the top of the document behind the overlay.
+ return (
+
+
+
+
+ {/* The grabber is the affordance for the drag Vaul provides; without
+ it the sheet gives no sign it can be thrown shut. */}
+
+
+
{shown.map((s, i) => (
))}
diff --git a/src/components/network/NetworkGlobe.tsx b/src/components/network/NetworkGlobe.tsx
index 10c1903..630484b 100644
--- a/src/components/network/NetworkGlobe.tsx
+++ b/src/components/network/NetworkGlobe.tsx
@@ -58,8 +58,11 @@ export function NetworkGlobe() {
[isDark],
);
+ // The globe is smaller on a phone. The sphere is decorative — the markers
+ // are a stock world spread, not peer positions — and at 343px square it took
+ // most of a screen, nearly all of it dark sphere against a dark ground.
return (
-
+
);
diff --git a/src/components/register/Tabs.tsx b/src/components/register/Tabs.tsx
index 0a9ab8c..33ce650 100644
--- a/src/components/register/Tabs.tsx
+++ b/src/components/register/Tabs.tsx
@@ -2,6 +2,7 @@ import {
createContext,
useContext,
useId,
+ useLayoutEffect,
useRef,
type ReactNode,
type KeyboardEvent,
@@ -50,6 +51,47 @@ function ListContainer({ children, className }: { children: ReactNode; className
function List({ children, ...rest }: { children: ReactNode; 'aria-label'?: string }) {
const listRef = useRef(null);
+ const { selected } = useTabs();
+
+ // Keep the selected tab inside the strip.
+ //
+ // The strip scrolls horizontally once the tabs outrun their box, which on a
+ // 375px screen happens at six tabs — 440px of tabs in 343px. Arriving on
+ // ?tab=events then left the selected tab off the right edge with the strip
+ // still at scrollLeft 0, so the reader saw the first tabs and no indication
+ // of which one they were on.
+ //
+ // Only the container is scrolled, never the page: scrollIntoView would also
+ // move the document vertically and jump the reader past the repository
+ // header they had just landed on.
+ useLayoutEffect(() => {
+ const list = listRef.current;
+ if (!list) return;
+
+ const reveal = () => {
+ const tab = list.querySelector('[role="tab"][aria-selected="true"]');
+ if (!tab) return;
+ const listBox = list.getBoundingClientRect();
+ const tabBox = tab.getBoundingClientRect();
+ const margin = 12;
+ if (tabBox.left < listBox.left) {
+ list.scrollLeft -= listBox.left - tabBox.left + margin;
+ } else if (tabBox.right > listBox.right) {
+ list.scrollLeft += tabBox.right - listBox.right + margin;
+ }
+ };
+
+ reveal();
+
+ // Re-run when the strip resizes. Each tab carries a count that arrives
+ // after its own fetch, so a tab grows from "certs" to "certs 1" a moment
+ // after selection — enough to push a tab revealed on the first pass back
+ // over the edge.
+ const observer = new ResizeObserver(reveal);
+ observer.observe(list);
+ for (const child of Array.from(list.children)) observer.observe(child);
+ return () => observer.disconnect();
+ }, [selected]);
// Roving arrow keys across the tab strip, wrapping at both ends.
const onKeyDown = (e: KeyboardEvent) => {
diff --git a/src/components/repos/RepoRow.tsx b/src/components/repos/RepoRow.tsx
index 98b5ace..46be329 100644
--- a/src/components/repos/RepoRow.tsx
+++ b/src/components/repos/RepoRow.tsx
@@ -33,7 +33,7 @@ export function RepoRow({ repo }: RepoRowProps) {
ref={ref}
{...prefetch}
className="group relative grid items-center gap-x-4 sm:gap-x-6
- grid-cols-[minmax(0,1fr)_auto]
+ grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto]
md:grid-cols-[minmax(0,1fr)_64px_auto]
border-b border-separator last:border-b-0 px-4 py-3
transition-colors hover:bg-surface-secondary"
@@ -80,8 +80,12 @@ export function RepoRow({ repo }: RepoRowProps) {
)}
+ {/* Two lines on a phone, one on desktop. Stacked, the row has the width
+ for a second line, and a description cut mid-word tells the reader
+ less than the sentence does. Desktop keeps one line so the list
+ stays scannable at density. */}
{repo.description && (
-
diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx
index 8fd922f..a4ff4e3 100644
--- a/src/components/ui/Dropdown.tsx
+++ b/src/components/ui/Dropdown.tsx
@@ -56,7 +56,7 @@ export function Dropdown({
id={controlId}
aria-labelledby={`${labelId} ${controlId}`}
className={cn(
- 'group inline-flex items-center gap-1.5 h-8 pl-3.5 pr-3 text-[13px] whitespace-nowrap',
+ 'group inline-flex items-center gap-1.5 h-9 sm:h-8 pl-3.5 pr-3 text-[13px] whitespace-nowrap',
'rounded-[var(--radius-control)] border border-border bg-transparent text-foreground',
'cursor-pointer transition-colors hover:border-foreground/45 hover:bg-surface-secondary',
'data-[state=open]:border-foreground/60 data-[state=open]:bg-surface-secondary',
diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx
index 74c9638..565674e 100644
--- a/src/components/ui/SegmentedControl.tsx
+++ b/src/components/ui/SegmentedControl.tsx
@@ -110,7 +110,10 @@ export function SegmentedControl({
onClick={() => onChange(option.value)}
aria-pressed={active}
className={cn(
- 'relative inline-flex items-center gap-1.5 px-3 h-7 text-[12.5px] whitespace-nowrap transition-colors',
+ // 36px on a phone, 28 from sm up. These are the primary filter controls on
+ // the listing pages and a 28px target is uncomfortable to hit with a
+ // thumb; desktop keeps the tighter density.
+ 'relative inline-flex items-center gap-1.5 px-3.5 sm:px-3 h-9 sm:h-7 text-[13px] sm:text-[12.5px] whitespace-nowrap transition-colors',
'rounded-[var(--radius-control)]',
'focus:outline-none focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2',
active ? 'text-background' : 'text-muted hover:text-foreground',
diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx
index 8fdcf4f..d6f06df 100644
--- a/src/components/ui/ThemeToggle.tsx
+++ b/src/components/ui/ThemeToggle.tsx
@@ -28,7 +28,7 @@ export function ThemeToggle() {
// the global `*` reset sets border-color, so a transparent border still
// paints as a 1px frame. The icon is sized down from the component's
// 24px default, which was cramped inside a 32px hit area.
- className="inline-flex h-8 w-8 items-center justify-center
+ className="inline-flex h-10 w-10 sm:h-8 sm:w-8 items-center justify-center
text-muted transition-colors hover:text-foreground
[&_svg]:size-[17px]"
/>
diff --git a/src/components/ui/icon-cloud.tsx b/src/components/ui/icon-cloud.tsx
index 46c8a6b..e39348d 100644
--- a/src/components/ui/icon-cloud.tsx
+++ b/src/components/ui/icon-cloud.tsx
@@ -355,7 +355,11 @@ export function IconCloud({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
- className="rounded-lg"
+ // The width/height attributes size the drawing buffer, not the box.
+ // Left unconstrained the canvas lays out at a fixed 400px and runs off
+ // the side of a phone; these let CSS scale it down while the buffer,
+ // and so the rendering, stays 400x400.
+ className="h-auto max-w-full rounded-lg"
aria-label="Interactive 3D Icon Cloud"
role="img"
/>
diff --git a/src/index.css b/src/index.css
index 108658b..8119823 100644
--- a/src/index.css
+++ b/src/index.css
@@ -379,9 +379,22 @@ body {
}
::-webkit-scrollbar-thumb:hover { background: var(--color-muted); }
-:focus-visible {
- outline: 2px solid var(--color-accent);
- outline-offset: -1px;
+/* The default focus ring MUST live in @layer base.
+
+ Unlayered, it beat every Tailwind focus utility, because unlayered CSS wins
+ over layered CSS regardless of specificity — the same rule that broke the
+ border-color reset above. A control that had been given its own focus
+ treatment got this one instead: the command palette drew a hard 2px box,
+ inset by a pixel, around a search field that sits flush inside a rounded
+ panel and already shows focus with its caret.
+
+ Layered, this stays the default for anything that does not style its own
+ focus, and a component that does style it now wins. */
+@layer base {
+ :focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: -1px;
+ }
}
a { text-underline-offset: 0.2em; }
diff --git a/src/pages/DocsPage.tsx b/src/pages/DocsPage.tsx
index 454832c..ae695cd 100644
--- a/src/pages/DocsPage.tsx
+++ b/src/pages/DocsPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { NavLink, Navigate, useParams, useLocation } from 'react-router-dom';
import { DocArticle } from '../components/docs/DocArticle';
@@ -32,6 +32,7 @@ interface DocState {
export default function DocsPage() {
const { slug = 'quickstart' } = useParams();
const { hash } = useLocation();
+ const docNavRef = useRef(null);
const [loaded, setLoaded] = useState({ slug: null, html: null, error: null });
useEffect(() => {
@@ -93,6 +94,23 @@ export default function DocsPage() {
document.getElementById(id)?.scrollIntoView({ block: 'start' });
}, [loaded.slug, loaded.html, slug, hash]);
+ // Keep the current document visible in the section nav.
+ //
+ // Below lg the nav is a horizontal strip, and four labels come to 419px in
+ // 351px of room. Opening /docs/node therefore left "Run a node" past the
+ // right edge with the strip unscrolled, so the reader could not see which
+ // document they were in. Only the strip scrolls, never the page.
+ useLayoutEffect(() => {
+ const nav = docNavRef.current;
+ const active = nav?.querySelector('[aria-current="page"]') ??
+ nav?.querySelector('a[class*="bg-foreground"]');
+ if (!nav || !active) return;
+ const navBox = nav.getBoundingClientRect();
+ const box = active.getBoundingClientRect();
+ if (box.left < navBox.left) nav.scrollLeft -= navBox.left - box.left + 12;
+ else if (box.right > navBox.right) nav.scrollLeft += box.right - navBox.right + 12;
+ }, [slug]);
+
const headings = useMemo(
() => (state.html ? extractTocHeadings(state.html) : []),
[state.html],
@@ -114,6 +132,7 @@ export default function DocsPage() {