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
53 changes: 52 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"shiki": "^4.3.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand Down
8 changes: 4 additions & 4 deletions src/components/home/AgentSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export function AgentSurface() {
const origin = typeof window === 'undefined' ? '' : window.location.origin;

return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-12">
<div>
<p className="m-0 max-w-[46ch] text-[14px] leading-relaxed text-muted">
<div className="grid min-w-0 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:gap-12">
<div className="min-w-0">
<p className="m-0 max-w-full sm:max-w-[46ch] text-[14px] leading-relaxed text-muted">
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
Expand All @@ -53,7 +53,7 @@ export function AgentSurface() {
</div>
</div>

<ul className="m-0 list-none p-0">
<ul className="m-0 min-w-0 list-none p-0">
{SURFACES.map(surface => (
<li
key={surface.path}
Expand Down
121 changes: 121 additions & 0 deletions src/components/layout/MobileNavDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Drawer } from 'vaul';
import { Link } from 'react-router-dom';
import { ArrowUpRight, Check } from 'lucide-react';
import type { NodeInfo, NodeStats } from '../../lib/api';
import { cn } from '../../lib/utils';

/**
* The navigation drawer on phones.
*
* This replaced a panel that animated its height open beneath the header. That
* worked, but it read as part of the page rather than as a layer over it: it
* pushed the content down, it could not be dismissed by dragging or by tapping
* away, and it left the page behind it fully lit.
*
* Vaul is a drawer built on Radix Dialog, so the behaviour a menu needs comes
* with it — focus is trapped while open and restored on close, Escape closes,
* the page behind is inert and does not scroll, and the sheet can be thrown
* shut with a flick. It opens from the bottom, which is where a thumb already
* is on a tall phone; the top of a 812px screen is the hardest place to reach.
*/
interface MobileNavDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => 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 <body>: 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 (
<Drawer.Root open={open} onOpenChange={onOpenChange} autoFocus>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-[60] bg-background/70 backdrop-blur-[2px]" />
<Drawer.Content
aria-label="Navigation"
className={cn(
'fixed inset-x-0 bottom-0 z-[70] mt-24 flex max-h-[88vh] flex-col',
'rounded-t-[var(--radius)] border-t border-border bg-background',
'outline-none',
)}
>
{/* The grabber is the affordance for the drag Vaul provides; without
it the sheet gives no sign it can be thrown shut. */}
<div aria-hidden="true" className="mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-border" />

<div className="overflow-y-auto overscroll-contain px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4">
<Drawer.Title className="m-0 px-1 text-[11.5px] text-muted">
{node?.name ?? 'gitlawb'}
{node?.network && <span className="ml-1.5">· {node.network}</span>}
</Drawer.Title>

<nav aria-label="Sections" className="mt-2">
<ul className="m-0 list-none p-0">
{links.map(({ to, label, end }) => {
const active = isActive(to, end);
return (
<li key={to}>
<Link
to={to}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-12 items-center justify-between rounded-[var(--radius-sm)] px-3',
'text-[15px] capitalize transition-colors',
active
? 'bg-surface-secondary font-medium text-foreground'
: 'text-muted active:bg-surface-secondary',
)}
>
{label}
{active && <Check size={15} aria-hidden="true" className="text-foreground" />}
</Link>
</li>
);
})}
</ul>
</nav>

{stats && (
<p className="m-0 mt-4 border-t border-separator px-3 pt-3 text-[12.5px] tabular text-muted">
{stats.repos.toLocaleString()} repos
<span className="px-1.5 text-subtle">·</span>
{stats.agents.toLocaleString()} agents
</p>
)}

<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 px-3">
{TEXT_SURFACES.map(surface => (
<a
key={surface.href}
href={surface.href}
className="inline-flex h-9 items-center gap-1 font-mono text-[12.5px] text-muted"
>
{surface.label}
<ArrowUpRight size={12} aria-hidden="true" />
</a>
))}
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
}
55 changes: 13 additions & 42 deletions src/components/layout/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import { motion, useReducedMotion } from 'motion/react';
import { Menu, X, Search } from 'lucide-react';
import { ThemeToggle } from '../ui/ThemeToggle';
import { Logo } from '../ui/Logo';
import { useNodeStatus } from '../../hooks/useNodeStatus';
import { useShortcuts } from '../../hooks/useShortcuts';
import { cn } from '../../lib/utils';
import { MobileNavDrawer } from './MobileNavDrawer';
import { modifierKeyLabel } from '../../lib/platform';

const NAV_LINKS = [
Expand Down Expand Up @@ -57,7 +58,7 @@ export default function TopBar() {
<div className="mx-auto flex h-14 max-w-[1280px] items-center gap-2 sm:gap-6 px-4 sm:px-6 lg:px-8">
<Link to="/" className="flex items-center gap-2.5 shrink-0">
<Logo className="h-[18px] w-auto shrink-0 text-foreground" />
<span className="hidden sm:inline text-[15px] font-semibold tracking-tight text-foreground">
<span className="text-[15px] font-semibold tracking-tight text-foreground">
gitlawb
</span>
</Link>
Expand Down Expand Up @@ -123,7 +124,7 @@ export default function TopBar() {
title={`Search — ${paletteHint}`}
aria-label={`Open command palette (${paletteHint})`}
aria-keyshortcuts="Meta+K Control+K"
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"
>
<Search size={17} />
Expand All @@ -136,51 +137,21 @@ export default function TopBar() {
aria-label="Navigation menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen(o => !o)}
className="md:hidden inline-flex h-8 w-8 items-center justify-center text-muted hover:text-foreground"
className="md:hidden inline-flex h-10 w-10 items-center justify-center text-muted hover:text-foreground"
>
{menuOpen ? <X size={17} /> : <Menu size={17} />}
</button>
</div>
</div>

<AnimatePresence>
{menuOpen && (
<motion.nav
key="mobile-nav"
initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, height: 'auto' }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: reduce ? 0 : 0.26, ease: [0.16, 1, 0.3, 1] }}
className="md:hidden overflow-hidden border-b border-border bg-background"
>
<ul className="m-0 list-none px-4 sm:px-6 py-1">
{NAV_LINKS.map(({ to, label, end }) => (
<li key={to}>
<Link
to={to}
aria-current={isActive(to, end) ? 'page' : undefined}
className={cn(
'flex items-center justify-between py-2.5 border-b border-separator',
'text-[14px] transition-colors',
isActive(to, end) ? 'text-foreground' : 'text-muted',
)}
>
{label}
{isActive(to, end) && (
<span className="h-px w-5 bg-foreground" aria-hidden="true" />
)}
</Link>
</li>
))}
</ul>
{stats && (
<p className="m-0 px-4 sm:px-6 py-3 text-[12.5px] tabular text-muted">
{stats.repos.toLocaleString()} repos · {stats.agents.toLocaleString()} agents
</p>
)}
</motion.nav>
)}
</AnimatePresence>
<MobileNavDrawer
open={menuOpen}
onOpenChange={setMenuOpen}
links={NAV_LINKS}
isActive={isActive}
node={node}
stats={stats}
/>
</header>
);
}
7 changes: 4 additions & 3 deletions src/components/network/FederationMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ function NodeChip({
{lead ? <Server size={13} /> : <Radio size={13} />}
</span>
<span className="text-[12.5px] font-medium text-foreground whitespace-nowrap">
{snap.node.label}
<span className="sm:hidden">{snap.node.label.split('.')[0]}</span>
<span className="hidden sm:inline">{snap.node.label}</span>
</span>
{snap.stats && (
<span className="text-[11px] text-muted tabular whitespace-nowrap">
Expand All @@ -74,11 +75,11 @@ export function FederationMap({ snapshots }: { snapshots: NodeSnapshot[] }) {
return (
<div
ref={containerRef}
className="relative flex min-h-[240px] w-full items-center justify-between gap-8 px-2 py-8 sm:px-8"
className="relative flex min-h-[200px] sm:min-h-[240px] w-full items-center justify-between gap-3 sm:gap-8 px-0 sm:px-8 py-6 sm:py-8"
>
<NodeChip snap={origin} innerRef={originRef} lead />

<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2.5 sm:gap-4">
{shown.map((s, i) => (
<NodeChip key={s.node.id} snap={s} innerRef={peerRefs[i]} />
))}
Expand Down
5 changes: 4 additions & 1 deletion src/components/network/NetworkGlobe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="relative mx-auto w-full max-w-[460px] aspect-square">
<div className="relative mx-auto w-full max-w-[250px] sm:max-w-[460px] aspect-square">
<Globe config={config} className="top-0" />
</div>
);
Expand Down
Loading
Loading