diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/content/docs/index.mdx b/content/docs/index.mdx index 9c6bd91..981daf2 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -5,8 +5,8 @@ icon: House --- Axonpack is a set of free, open-source foundation libraries for React Native and Expo apps. Each one -is small, focused and dependency-light — something you drop into an app you already have, rather than -a framework you adopt. +is small, focused and dependency-light. You drop one into an app you already have, rather than +adopting a framework. Every package is published independently, versioned independently, and documented independently. There is no `axonpack` meta-package to install, and installing one never brings in another. @@ -19,13 +19,13 @@ Everything is MIT licensed and developed in the open at - These docs cover what is published, and nothing else — a page describing something you cannot + These docs cover what is published, and nothing else. A page describing something you cannot install would only make you guess which half of this site is real. ## How these docs are organised -**One folder per package.** The sidebar lists them below this page — picking one puts you in that +**One folder per package.** The sidebar lists them below this page, and picking one puts you in that package's documentation and nothing else. A package's guides, its reference and its screenshots all live under its own name, so nothing here is shared between two libraries by accident. diff --git a/scripts/fetch-packages.mjs b/scripts/fetch-packages.mjs index c1763d1..7394331 100644 --- a/scripts/fetch-packages.mjs +++ b/scripts/fetch-packages.mjs @@ -13,7 +13,7 @@ // // No dependencies. Node 20+ has fetch. -import { access, mkdir, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; const SCOPE = "@axonpack/"; @@ -162,6 +162,39 @@ async function countDownloads(name, publishedAt) { return answered ? total : null; } +// --- repository stars --------------------------------------------------------------------------- +// +// Read at build time like everything else here, so the page ships as static HTML and no visitor +// pays for a request to GitHub. It goes stale between builds, which the nightly deploy keeps short. +// +// Never fatal. GitHub allows 60 unauthenticated calls an hour per IP and Actions runners share +// addresses, so a rate limit here is normal rather than exceptional. A missing count hides the +// number; it must not take the build down, because the build is also the deploy. +const fetchStars = async (repo) => { + try { + const res = await fetch(`https://api.github.com/repos/${repo}`, { + headers: { + Accept: "application/vnd.github+json", + // Actions sets this. It lifts the limit to 1,000 an hour and is not needed locally. + ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), + }, + }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return (await res.json()).stargazers_count ?? null; + } catch (error) { + console.warn(` stars unavailable for ${repo}: ${error.message}`); + return null; + } +}; + +// The repo name lives in content.json already, so the count follows whatever that names rather than +// being pinned again here. +const content = JSON.parse( + await readFile(new URL("../src/content.json", import.meta.url), "utf8"), +); +const repo = { name: content.nav.github.repo, stars: await fetchStars(content.nav.github.repo) }; +console.log(` ${repo.name} -> ${repo.stars ?? "?"} stars`); + const packages = []; for (const name of [...names].sort()) { const manifest = await json(`https://registry.npmjs.org/${encode(name)}/latest`); @@ -207,6 +240,6 @@ for (const name of [...names].sort()) { await mkdir(new URL("../src/generated/", import.meta.url), { recursive: true }); await writeFile( new URL("../src/generated/packages.json", import.meta.url), - JSON.stringify({ builtAt: new Date().toISOString(), packages }, null, 2) + "\n", + JSON.stringify({ builtAt: new Date().toISOString(), repo, packages }, null, 2) + "\n", ); console.log(`wrote src/generated/packages.json (${packages.length} packages)`); diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs index 310f9a8..702d025 100644 --- a/scripts/sync-changelog.mjs +++ b/scripts/sync-changelog.mjs @@ -12,6 +12,7 @@ * Usage: bun run sync:changelog */ import { readFile, writeFile } from 'node:fs/promises'; +import { gunzipSync } from 'node:zlib'; /** * Every package with a changelog page. A package earns an entry here on the day it goes to npm, the @@ -32,22 +33,68 @@ const get = async (url, as = 'text') => { }; /** - * This repository is mounted as a submodule at `docs/` inside the monorepo, so when you are working - * there the package's changelog is on disk one level up. Read that first: it means a version bumped - * locally shows on the site immediately, without waiting for the change to reach `main`. + * Pulls one file out of a gzipped tar. Forty lines against a dependency for a format that is fixed + * 512-byte headers: a name, an octal size, then the data padded to the next block. npm prefixes + * every path in a package tarball with `package/`. */ -async function readChangelog(name) { +function fileFromTarball(archive, wanted) { + const tar = gunzipSync(archive); + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/s, ""); + if (!name) return null; // Two zero blocks end the archive. + const size = parseInt(header.subarray(124, 136).toString("utf8").replace(/\0.*$/s, "").trim(), 8) || 0; + const start = offset + 512; + if (name === wanted) return tar.subarray(start, start + size).toString("utf8"); + offset = start + Math.ceil(size / 512) * 512; + } + return null; +} + +/** + * The changelog, from the published package itself. + * + * It used to come from `main` of the monorepo over raw.githubusercontent, which tied this build to + * another repository's branch state: a package on npm whose source had not reached `main` yet gave + * a 404 and failed the deploy. The tarball cannot have that problem, because it is the thing that + * was published. It is also what the README always claimed this site does. + * + * The local checkout still wins when there is one, so a version bumped in the monorepo shows here + * before it is published. That does mean a local run can succeed where CI would not, since CI only + * ever checks out this repository. + */ +async function readChangelog(name, registry) { try { const local = await readFile( new URL(`../../packages/${name}/CHANGELOG.md`, import.meta.url), - 'utf8', + "utf8", ); console.log(`${name}: ../packages (local checkout)`); return local; } catch { - console.log(`${name}: raw.githubusercontent.com (main)`); - return get(`https://raw.githubusercontent.com/axonpack/axonpack/main/packages/${name}/CHANGELOG.md`); + // No monorepo around this checkout, which is the normal case in CI. + } + + const tarball = registry?.versions?.[registry?.["dist-tags"]?.latest]?.dist?.tarball; + if (!tarball) { + console.warn(`${name}: no tarball listed on npm — leaving the committed changelog alone`); + return null; } + + try { + const response = await fetch(tarball); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + const changelog = fileFromTarball(Buffer.from(await response.arrayBuffer()), "package/CHANGELOG.md"); + if (changelog) { + console.log(`${name}: npm tarball`); + return changelog; + } + console.warn(`${name}: the tarball ships no CHANGELOG.md — leaving the committed page alone`); + } catch (error) { + console.warn(`${name}: could not read the tarball (${error.message}) — leaving the committed page alone`); + } + return null; } /** @@ -95,7 +142,9 @@ function parse(markdown) { } async function sync({ name, slug }) { - const [markdown, registry] = await Promise.all([readChangelog(name), readRegistry(name)]); + // The registry doc has to come first: it is where the tarball URL lives. + const registry = await readRegistry(name); + const markdown = await readChangelog(name, registry); // `null` means we could not reach npm, which is not the same as a version being absent from it — // saying "not yet published" because the network was down would put a false claim on the page. @@ -110,6 +159,14 @@ async function sync({ name, slug }) { }) : null; + const latest = registry?.['dist-tags']?.latest; + + // Nothing to regenerate from. The page on disk is committed, so it stays as it was rather than + // being emptied, and the version still reaches releases.generated.ts if npm answered. + if (markdown === null) { + return { slug, latest, date: latest ? fmtDate(latest) : null }; + } + const releases = parse(markdown); const body = releases @@ -148,7 +205,6 @@ async function sync({ name, slug }) { }) .join('\n\n'); - const latest = registry?.['dist-tags']?.latest; const page = `--- title: Changelog description: Every published release of ${name}, newest first. diff --git a/src/app/global.css b/src/app/global.css index 4881e7e..307f3c1 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -53,3 +53,23 @@ html > body[data-scroll-locked] { } } } + +/* The landing page's header deliberately has no line under it, and the docs pages have no top bar at + all, so fumadocs' own navbar on the blog was the only header on the site drawing one. The colour + goes rather than the border, so nothing shifts by the pixel the border occupies. */ +#nd-nav > nav { + border-bottom-color: transparent; +} + +/* Every screenshot in the docs is a phone capture, about 2.16 times taller than it is wide, and + fumadocs renders MDX images at the full column width. That made each one roughly 1500px tall, so + a page turned into a scroll. Capping the height rather than the width is what keeps a future + landscape diagram at full size: at column width its height is already under this. + `width: auto` is load-bearing: next/image writes the intrinsic size onto the tag as a width + attribute, which counts as a specified width, so clamping only the height squashed the picture + instead of scaling it. */ +.prose img { + width: auto; + max-height: 28rem; + margin-inline: auto; +} diff --git a/src/components/devtools-panel.module.css b/src/components/devtools-panel.module.css index c7bc891..8aba466 100644 --- a/src/components/devtools-panel.module.css +++ b/src/components/devtools-panel.module.css @@ -1,47 +1,44 @@ /* The panel's own motion, kept beside it rather than in the page's stylesheet, so rewriting the hero cannot take the carousel with it. */ -/* Six panes over 24s, dwelling on each then sliding to the next. The track carries a seventh pane +/* Five panes over 20s, dwelling on each then sliding to the next. The track carries a sixth pane that repeats the first, so the wrap at 100% lands on an identical frame instead of snapping back. - Change the pane count and every stop below has to be recut. */ + Change the pane count and every stop below has to be recut: each pane owns 20% of the cycle, of + which it dwells for 16 and slides for 4. */ @keyframes carousel { 0%, - 13% { + 16% { transform: translateX(0); } - 16.67%, - 29.67% { + 20%, + 36% { transform: translateX(-100%); } - 33.33%, - 46.33% { + 40%, + 56% { transform: translateX(-200%); } - 50%, - 63% { + 60%, + 76% { transform: translateX(-300%); } - 66.67%, - 79.67% { + 80%, + 96% { transform: translateX(-400%); } - 83.33%, - 96.33% { - transform: translateX(-500%); - } 100% { - transform: translateX(-600%); + transform: translateX(-500%); } } /* Same clock as the track, one delay per tab. */ @keyframes tabOn { 0%, - 13% { + 16% { opacity: 1; } - 16.67%, - 97% { + 20%, + 96% { opacity: 0.35; } 100% { @@ -66,11 +63,11 @@ @media (prefers-reduced-motion: no-preference) { .track { - animation: carousel 24s cubic-bezier(0.65, 0, 0.35, 1) infinite; + animation: carousel 20s cubic-bezier(0.65, 0, 0.35, 1) infinite; } .tab { - animation: tabOn 24s steps(1, end) infinite; + animation: tabOn 20s steps(1, end) infinite; } .flow { diff --git a/src/components/devtools-panel.tsx b/src/components/devtools-panel.tsx index 833abf1..cb433af 100644 --- a/src/components/devtools-panel.tsx +++ b/src/components/devtools-panel.tsx @@ -1,173 +1,496 @@ -import { Bug, Database, Gauge, Radio, Terminal, TriangleAlert } from 'lucide-react'; +import { + Braces, + ChevronDown, + ChevronRight, + Copy, + Database, + Image as ImageIcon, + MoreVertical, + Play, + Search, + Zap, +} from 'lucide-react'; +import { panes, type Block, type Chart, type JsonLine, type Pane, type Row, type Tone } from './devtools-panes'; import styles from './devtools-panel.module.css'; -type Tone = 'ok' | 'warn' | 'bad' | 'muted'; -type Row = { lead: string; text: string; trail?: string; tone?: Tone }; -type Pane = { - tab: string; - icon: typeof Radio; - filters: string[]; - rows: Row[]; - views: string[]; - detail: { key: string; value: string; tone?: Tone }[]; -}; - -const TONES: Record = { - ok: 'text-emerald-500', - warn: 'text-amber-500', - bad: 'text-rose-500', - muted: 'text-fd-muted-foreground', -}; - -// Every tab the package ships. Five rows and four detail lines each, so the panes are the same -// height and the window never resizes mid-slide. The carousel keyframes are cut for six of these. -const panes: Pane[] = [ - { - tab: 'Network', - icon: Radio, - filters: ['All', 'Fetch', 'XHR', 'WS', 'SSE'], - rows: [ - { lead: 'GET', text: '/v1/session', trail: '200', tone: 'ok' }, - { lead: 'POST', text: '/v1/orders', trail: '201', tone: 'ok' }, - { lead: 'GET', text: '/v1/products?page=2', trail: '200', tone: 'ok' }, - { lead: 'GET', text: '/v1/me', trail: '401', tone: 'bad' }, - { lead: 'WS', text: '/live', trail: 'open', tone: 'ok' }, - ], - views: ['Headers', 'Response', 'Timing', 'Initiator'], - detail: [ - { key: 'status', value: '401 Unauthorized', tone: 'bad' }, - { key: 'body', value: '{ "error": "token_expired" }' }, - { key: 'waiting', value: '61 ms · downloading 27 ms' }, - { key: 'initiator', value: 'api-client.ts:88' }, - ], - }, - { - tab: 'Console', - icon: Terminal, - filters: ['All', 'Log', 'Warn', 'Error', 'REPL'], - rows: [ - { lead: 'log', text: 'session restored' }, - { lead: 'log', text: 'cart hydrated, 3 items' }, - { lead: 'warn', text: 'slow render, 142 ms', tone: 'warn' }, - { lead: 'error', text: 'TypeError: cart is undefined', tone: 'bad' }, - { lead: '>', text: 'store.getState().user' }, - ], - views: ['Message', 'Arguments', 'Source'], - detail: [ - { key: 'level', value: 'error', tone: 'bad' }, - { key: 'origin', value: 'CartScreen.tsx:42' }, - { key: 'argument', value: '{ userId: 8812, retry: false }' }, - { key: 'repeated', value: '3 times' }, - ], - }, - { - tab: 'Crash', - icon: TriangleAlert, - filters: ['All', 'Fatal', 'Rejection', 'Render', 'Native'], - rows: [ - { lead: 'fatal', text: "cannot read 'id' of null", tone: 'bad' }, - { lead: 'render', text: 'CartScreen boundary caught', tone: 'bad' }, - { lead: 'reject', text: 'refresh failed, 401', tone: 'warn' }, - { lead: 'native', text: 'NSInvalidArgumentException', tone: 'bad' }, - { lead: 'note', text: 'reported at next launch' }, - ], - views: ['Stack', 'Component stack', 'Breadcrumbs', 'Device'], - detail: [ - { key: 'message', value: "cannot read 'id' of null", tone: 'bad' }, - { key: 'stack', value: 'at CartScreen (CartScreen.tsx:42:11)' }, - { key: 'component', value: 'CartScreen › CartList › Row' }, - { key: 'device', value: 'iPhone 15 · iOS 18.2 · build 412' }, - ], - }, - { - tab: 'Storage', - icon: Database, - filters: ['All', 'Async', 'MMKV', 'Secure', 'Custom'], - rows: [ - { lead: 'async', text: 'auth.token', trail: 'string' }, - { lead: 'async', text: 'onboarding.seen', trail: 'boolean' }, - { lead: 'mmkv', text: 'cart.items', trail: 'json' }, - { lead: 'secure', text: 'refresh.key', trail: 'string' }, - { lead: 'custom', text: 'feature.flags', trail: 'json' }, - ], - views: ['Value', 'Type', 'Edit'], - detail: [ - { key: 'key', value: 'auth.token' }, - { key: 'store', value: 'AsyncStorage · async' }, - { key: 'type', value: 'string · 184 bytes' }, - { key: 'value', value: '"eyJhbGciOiJIUzI1NiIsInR5cCI6…"' }, - ], - }, - { - tab: 'Perf', - icon: Gauge, - filters: ['All', 'Frames', 'Memory', 'Long tasks', 'Startup'], - rows: [ - { lead: 'fps', text: 'JS thread', trail: '58', tone: 'ok' }, - { lead: 'fps', text: 'lowest this minute', trail: '31', tone: 'warn' }, - { lead: 'heap', text: 'Hermes allocated', trail: '42 MB' }, - { lead: 'task', text: 'long task, main bundle', trail: '180 ms', tone: 'warn' }, - { lead: 'task', text: 'long task, image decode', trail: '96 ms', tone: 'warn' }, - ], - views: ['Startup', 'Frames', 'Memory'], - detail: [ - { key: 'bundle', value: '412 ms' }, - { key: 'first render', value: '780 ms' }, - { key: 'runtime init', value: '96 ms' }, - { key: 'note', value: 'JS heap, not app memory' }, - ], - }, - { - tab: 'Debug', - icon: Bug, - filters: ['All', 'JS thread', 'Main thread'], - rows: [ - { lead: 'block', text: 'JS thread, 3 s' }, - { lead: 'block', text: 'main thread, 3 s' }, - { lead: 'crash', text: 'JS thread', tone: 'bad' }, - { lead: 'crash', text: 'main thread', tone: 'bad' }, - { lead: 'note', text: 'needs a development build' }, - ], - views: ['Effect', 'Why'], - detail: [ - { key: 'armed', value: 'tap again to crash', tone: 'bad' }, - { key: 'js block', value: 'shows as a long task, drops JS fps' }, - { key: 'main block', value: 'freezes the screen, JS stays fine' }, - { key: 'crash', value: 'read back off disk at next launch' }, - ], - }, -]; - /** * A drawing of the panel rather than a screenshot, so it themes with the page, weighs nothing and * can walk its own tabs. The track repeats the first pane at the end: the loop's wrap then lands on * an identical frame instead of snapping backwards through five panes. * * It owns its motion and knows nothing about where it is mounted, so it outlives the hero. + * + * The real package paints itself green. This follows the site's own palette instead, because a + * second accent colour in the hero would read as a different product rather than as a screenshot. + * Status colours stay semantic: a 200 is green wherever it appears. + * + * The row renderers below are fragments of this one picture, not components in their own right, so + * they live here rather than each earning a file nothing else would import. */ -export function DevtoolsPanel() { + +const TEXT: Record = { + ok: 'text-emerald-600 dark:text-emerald-400', + warn: 'text-amber-600 dark:text-amber-500', + bad: 'text-rose-600 dark:text-rose-400', + info: 'text-fd-primary', + muted: 'text-fd-muted-foreground', +}; + +const FILL: Record = { + ok: 'bg-emerald-500', + warn: 'bg-amber-500', + bad: 'bg-rose-500', + info: 'bg-fd-primary', + muted: 'bg-fd-muted-foreground', +}; + +/** Roughly what an editor gives JSON, which is what the package's own viewer is tuned against. */ +const VALUE: Record, string> = { + string: 'text-rose-700 dark:text-rose-300', + number: 'text-blue-700 dark:text-sky-300', + bool: 'text-blue-700 dark:text-sky-300', + null: 'text-fd-muted-foreground', + plain: 'text-fd-muted-foreground', +}; + +const GLYPHS = { json: Braces, img: ImageIcon, ws: Zap }; + +function Chip({ text, tone }: { text: string; tone?: Tone }) { + return ( + + {text} + + ); +} + +/** The record button is a ring around a square, not a glyph, so it is drawn rather than imported. */ +function RecordDot() { + return ( + + + + ); +} + +function Toolbar({ pane }: { pane: Pane }) { return ( -
-
- - - - +
+ {pane.picker ? ( + + + {pane.picker} + - Axonpack devtools - - - recording on device + ) : ( + <> + + + + )} + + + + {pane.tools.map((Tool, i) => ( + + ))} + + {pane.pills?.map((pill, i) => ( + + {pill} + + ))} + + {pane.badge && ( + + {pane.badge.text} + + )} +
+ ); +} + +function RequestRow({ row }: { row: Extract }) { + const Glyph = GLYPHS[row.glyph]; + return ( +
  • +
    + {row.method} + {row.pending ? ( + + + + ) : ( + + {row.status} + + )} + {row.time} +
    +
    + + {row.name} +
    +

    {row.url}

    +
    + {row.chips.map((chip) => ( + + ))} + +
    +
  • + ); +} + +function LogRow({ row }: { row: Extract }) { + return ( +
  • + {row.tag &&

    {row.tag}

    } +

    {row.text}

    + {row.error &&

    {row.error}

    } + {row.preview && ( +

    + + {row.preview} +

    + )} +
    + {row.repeat && {row.repeat}} + {row.time} + +
    +
  • + ); +} + +function CardRow({ row }: { row: Extract }) { + return ( +
  • +
    + + {row.title} + {row.time && ( + + {row.time} + + )} +
    +

    {row.text}

    +
    + {row.chips.map((chip) => ( + + ))} +
    +
  • + ); +} + +function EntryRow({ row }: { row: Extract }) { + return ( +
  • +
    + + {row.glyph} + {row.name} + {row.size}
    +

    + {row.type} + {row.preview} +

    +
  • + ); +} -
    -
    +function MetricRow({ row }: { row: Extract }) { + return ( +
  • + + {row.label} + {row.sub} + {row.value} +
  • + ); +} + +/** + * Dotted rather than solid, which is how the real chart draws a sample per frame. `non-scaling-stroke` + * is what keeps the dots round: the viewBox is stretched to the card's width, and without it every + * dot stretches with it. + */ +function Spark({ points }: { points: number[] }) { + const top = Math.max(...points); + const floor = Math.min(...points); + const span = top - floor || 1; + const plot = points + .map((p, i) => `${(i / (points.length - 1)) * 100},${26 - ((p - floor) / span) * 22}`) + .join(' '); + return ( + + + + ); +} + +function ChartCard({ chart }: { chart: Chart }) { + return ( +
    +
    + + {chart.title} + + {chart.meta && {chart.meta}} + {chart.value && !chart.big && ( + + {chart.value} + {chart.unit && ( + + {chart.unit} + + )} + + )} +
    + + {chart.legend && ( +
    + {chart.legend.map((entry) => ( + + + {entry.label} + {entry.value} + + ))} +
    + )} + + {chart.big && chart.value && ( +

    + {chart.value} + {chart.unit && {chart.unit}} +

    + )} + + {chart.points && ( +
    + {chart.axis && ( +
    + {chart.axis.map((label) => ( + {label} + ))} +
    + )} +
    + + {chart.span && ( +
    + {chart.span.map((label) => ( + {label} + ))} +
    + )} +
    +
    + )} + + {chart.bar !== undefined && ( +
    + +
    + )} + + {chart.note &&

    {chart.note}

    } +
    + ); +} + +function JsonBlock({ lines }: { lines: JsonLine[] }) { + return ( +
    + {lines.map((line, i) => ( +

    + {line.caret ? ( + line.caret === 'open' ? ( + + ) : ( + + ) + ) : ( + + )} + {line.key && ( + {line.key}: + )} + {line.value} +

    + ))} +
    + ); +} + +function DetailBlock({ block }: { block: Block }) { + if (block.kind === 'banner') { + return ( +
    + + {block.label} + + {block.title} +
    + ); + } + + if (block.kind === 'json') return ; + + if (block.kind === 'rows') { + return ( +
    + {block.rows.map(([key, value]) => ( +
    +
    {key}
    +
    {value}
    +
    + ))} +
    + ); + } + + if (block.kind === 'stack') { + return ( +
      + {block.frames.map(([fn, at], i) => ( +
    1. + {i} + + {fn} + {at} + +
    2. + ))} +
    + ); + } + + return ( +
    + {block.charts.map((chart) => ( + + ))} +
    + ); +} + +function RowList({ rows }: { rows: Row[] }) { + return ( +
      + {rows.map((row, i) => { + if (row.kind === 'request') return ; + if (row.kind === 'log') return ; + if (row.kind === 'card') return ; + if (row.kind === 'entry') return ; + return ; + })} +
    + ); +} + +function Blocks({ pane }: { pane: Pane }) { + return ( +
    + {pane.detail.search && ( +
    + + {pane.detail.search} + Aa + ab + .* +
    + )} + {pane.detail.blocks.map((block, i) => ( + + ))} +
    + ); +} + +/** + * Two halves, and which one leads is the pane's own call: Performance opens on Statistics, so its + * charts take the side every other tab gives to a list. + */ +function PaneView({ pane, hidden }: { pane: Pane; hidden: boolean }) { + const blocksLead = pane.primary === 'blocks'; + return ( +
    +
    + + {blocksLead ? : } + + {/* A docked bar, not a rounded box: the prompt is part of the panel edge it sits on. */} + {pane.prompt && ( +
    + + {pane.prompt} + +
    + )} +
    + +
    +
    + {pane.detail.tabs.map((tab, i) => ( + + {tab} + + ))} + +
    + + {blocksLead ? : } +
    +
    + ); +} + +export function DevtoolsPanel() { + return ( +
    +
    + {/* On the frame itself, the way a mac sidebar is part of the window rather than a panel + inside it. No divider: the gap to the content card is the separation. */} +
    {panes.map((pane, i) => ( @@ -176,82 +499,14 @@ export function DevtoolsPanel() { ))}
    -
    -
    + {/* The content is its own inset card, lighter than the chrome around it, which is the one + thing that makes a window read as a window rather than as a bordered box. A fixed height + keeps every pane the same size, so the window never resizes mid-slide, and the overflow + it causes is what makes each list read as longer than the frame. */} +
    +
    {[...panes, panes[0]].map((pane, i) => ( -
    -
    -
    - {pane.filters.map((filter, f) => ( - - {filter} - - ))} -
    -
      - {pane.rows.map((row) => ( -
    • - {row.lead} - {row.text} - {row.trail && ( - - {row.trail} - - )} -
    • - ))} -
    • - GET - /v1/feed - - - -
    • -
    -
    - -
    -
    - {pane.views.map((view, v) => ( - - {view} - - ))} -
    -
    - {pane.detail.map((line) => ( -
    -
    {line.key}
    -
    {line.value}
    -
    - ))} -
    -
    -
    +
    diff --git a/src/components/devtools-panes.ts b/src/components/devtools-panes.ts new file mode 100644 index 0000000..3708b51 --- /dev/null +++ b/src/components/devtools-panes.ts @@ -0,0 +1,453 @@ +import { + ArrowDown, + Bookmark, + CheckCheck, + Database, + Download, + Filter, + Gauge, + Plus, + Radio, + RefreshCw, + Settings, + Terminal, + TriangleAlert, + Upload, + type LucideIcon, +} from 'lucide-react'; + +/** + * What the hero panel draws, kept apart from the drawing of it. Every screen here is modelled on the + * real tab it names, down to which buttons that tab's toolbar carries, because the panel's whole job + * is to be recognisable to somebody who has run the package. + * + * Sample data only. Nothing here is fetched and nothing is a screenshot. + */ + +export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'muted'; + +/** A line in a syntax-coloured JSON tree, the same shape the package's own viewer renders. */ +export type JsonLine = { + indent: number; + /** Present on an object or array, which is what earns the disclosure triangle. */ + caret?: 'open' | 'closed'; + key?: string; + value: string; + kind?: 'string' | 'number' | 'bool' | 'null' | 'plain'; +}; + +export type Block = + | { kind: 'banner'; label: string; title: string } + | { kind: 'json'; lines: JsonLine[] } + | { kind: 'rows'; rows: [string, string][] } + | { kind: 'stack'; frames: [string, string][] } + | { kind: 'charts'; charts: Chart[] }; + +export type Chart = { + title: string; + meta?: string; + /** The headline reading, e.g. `56`. Sits big above the plot. */ + value?: string; + unit?: string; + legend?: { label: string; value: string }[]; + /** Plotted as a dotted polyline. Any range: the drawing normalises it. */ + points?: number[]; + /** A filled bar instead of a plot, for a reading that is a proportion. */ + bar?: number; + /** Y labels, top to bottom, drawn beside the plot the way the real chart labels its range. */ + axis?: string[]; + /** X labels, left to right, under the plot. */ + span?: string[]; + /** Sets the reading in the card's own size, for a card that is only a number. */ + big?: boolean; + note?: string; +}; + +export type Row = + /** Network. Four lines, the way the tab draws a request when large rows are on. */ + | { + kind: 'request'; + method: string; + status: string; + tone: Tone; + time: string; + name: string; + url: string; + glyph: 'json' | 'img' | 'ws'; + chips: string[]; + /** Still in flight, so it draws a moving bar where a finished request draws its status. */ + pending?: boolean; + } + /** Console. A tag, the message, and the first line of whatever object came with it. */ + | { kind: 'log'; tag?: string; text: string; error?: string; preview?: string; time: string; repeat?: string } + /** Crashes and Debug. A titled card with chips under it. */ + | { kind: 'card'; title: string; text: string; chips: string[]; time?: string; tone: Tone } + /** Storage. The type glyph is what tells the stores apart at a glance. */ + | { kind: 'entry'; glyph: string; tone: Tone; name: string; size: string; type: string; preview: string } + /** Performance interactions. The left bar carries the severity. */ + | { kind: 'metric'; label: string; sub: string; value: string; tone: Tone }; + +export type Pane = { + tab: string; + icon: LucideIcon; + /** Named the way the real toolbar reads, left to right, after the record and clear pair. */ + tools: LucideIcon[]; + /** The Storage adapter picker, which sits where the other tabs put their first button. */ + picker?: string; + /** Section pills, as Performance uses to switch between its three views. */ + pills?: string[]; + /** The running count the toolbar keeps on its right. */ + badge?: { text: string; tone: Tone }; + rows: Row[]; + /** Which half leads. Performance leads with Statistics, every other tab leads with its list. */ + primary?: 'rows' | 'blocks'; + /** Docked at the bottom of the list, currently only the Console prompt. */ + prompt?: string; + /** `active` is which tab the blocks below actually belong to, not always the first one. */ + detail: { tabs: string[]; active?: number; search?: string; blocks: Block[] }; +}; + +export const panes: Pane[] = [ + { + tab: 'Network', + icon: Radio, + tools: [ArrowDown, Filter, Bookmark, Download, Settings], + rows: [ + { + kind: 'request', + method: 'GET', + status: '', + tone: 'muted', + time: 'pending', + name: 'feed?page=2', + url: 'https://api.acme.dev/v1/feed?page=2', + glyph: 'json', + chips: ['Fetch/XHR', 'fetch'], + pending: true, + }, + { + kind: 'request', + method: 'GET', + status: '200', + tone: 'ok', + time: '0.23 s · 7:13:09 PM', + name: 'session', + url: 'https://api.acme.dev/v1/auth/session', + glyph: 'json', + chips: ['Fetch/XHR', 'fetch', '426 B'], + }, + { + kind: 'request', + method: 'GET', + status: '200', + tone: 'ok', + time: '0.26 s · 7:13:06 PM', + name: 'avatar-6aa14cb34f8e.svg', + url: 'https://cdn.acme.dev/files/6842b36ba01eccee19a4…', + glyph: 'img', + chips: ['Img', 'fetch', '1.1 KB'], + }, + { + kind: 'request', + method: 'GET', + status: '401', + tone: 'bad', + time: '0.19 s · 7:13:04 PM', + name: 'me', + url: 'https://api.acme.dev/v1/me', + glyph: 'json', + chips: ['Fetch/XHR', 'xhr', '88 B'], + }, + { + kind: 'request', + method: 'POST', + status: '201', + tone: 'ok', + time: '0.41 s · 7:13:01 PM', + name: 'orders', + url: 'https://api.acme.dev/v1/orders', + glyph: 'json', + chips: ['Fetch/XHR', 'xhr', '2.3 KB'], + }, + { + kind: 'request', + method: 'WS', + status: '101', + tone: 'info', + time: '7:12:58 PM', + name: 'live', + url: 'wss://api.acme.dev/live', + glyph: 'ws', + chips: ['WebSocket', 'ws', '12 frames'], + }, + ], + detail: { + tabs: ['Headers', 'Preview', 'Response', 'Timing'], + active: 1, + search: 'Search this body', + blocks: [ + { + kind: 'json', + lines: [ + { indent: 0, caret: 'open', value: '{error: true, status: 401, …}' }, + { indent: 1, key: 'code', value: '"TOKEN_EXPIRED"', kind: 'string' }, + { indent: 1, key: 'error', value: 'true', kind: 'bool' }, + { indent: 1, key: 'message', value: '"Access token has expired"', kind: 'string' }, + { indent: 1, key: 'retryAfter', value: '30', kind: 'number' }, + { indent: 1, caret: 'open', key: 'request', value: '{id: "8f2a…", method: "GET"}' }, + { indent: 2, key: 'id', value: '"8f2a4c19b7"', kind: 'string' }, + { indent: 2, key: 'method', value: '"GET"', kind: 'string' }, + { indent: 2, caret: 'closed', key: 'headers', value: '{authorization: "Bearer …", …}' }, + { indent: 1, key: 'status', value: '401', kind: 'number' }, + { indent: 1, key: 'traceId', value: 'null', kind: 'null' }, + ], + }, + ], + }, + }, + + { + tab: 'Console', + icon: Terminal, + tools: [Filter], + badge: { text: '2', tone: 'warn' }, + rows: [ + { + kind: 'log', + tag: '[Cart:service]', + text: 'config loaded', + preview: '{branchId: "69f1d0caec05e9", currency: "GBP", …}', + time: '7:12:43 PM', + }, + { + kind: 'log', + tag: '[Checkout]', + text: 'session restored', + preview: '{userId: 8812, retry: false}', + time: '7:12:44 PM', + }, + { + kind: 'log', + text: 'SYNC::: Error syncing queue job. false', + error: 'Error: No queue jobs to process.', + time: '7:12:49 PM', + repeat: '×13', + }, + { + kind: 'log', + tag: '[Cart:task]', + text: 'event received', + preview: '{eventType: "EXIT", cartId: "8f2a4c19b7"}', + time: '7:13:05 PM', + }, + ], + prompt: 'Run an expression', + detail: { + tabs: ['Message', 'Arguments', 'Source'], + active: 1, + blocks: [ + { kind: 'banner', label: 'UNCAUGHT ERROR', title: 'TypeError' }, + { + kind: 'rows', + rows: [ + ['Origin', 'CartScreen.tsx:42:11'], + ['Level', 'error'], + ['Repeated', '13 times'], + ], + }, + { + kind: 'json', + lines: [ + { indent: 0, caret: 'open', value: '{queue: "sync", pending: 0, …}' }, + { indent: 1, key: 'queue', value: '"sync"', kind: 'string' }, + { indent: 1, key: 'pending', value: '0', kind: 'number' }, + { indent: 1, key: 'lastRunAt', value: '"2026-09-11T19:12:49.108Z"', kind: 'string' }, + { indent: 1, key: 'error', value: 'null', kind: 'null' }, + ], + }, + ], + }, + }, + + { + tab: 'Perf', + icon: Gauge, + tools: [], + pills: ['Statistics', 'User timing', 'Interactions 51', 'Long tasks 4'], + primary: 'blocks', + rows: [ + { kind: 'metric', label: 'touchend', sub: 'handler 1.1 ms', value: '0.1 s', tone: 'warn' }, + { kind: 'metric', label: 'touchend', sub: 'handler 0.426 ms', value: '3.13 s', tone: 'bad' }, + { kind: 'metric', label: 'touchstart', sub: 'handler 0.468 ms', value: '0.89 s', tone: 'bad' }, + { kind: 'metric', label: 'touchend', sub: 'handler 2.3 ms', value: '0.11 s', tone: 'warn' }, + { kind: 'metric', label: 'touchend', sub: 'handler 9 ms', value: '0.25 s', tone: 'bad' }, + { kind: 'metric', label: 'touchend', sub: 'handler 1.4 ms', value: '0.12 s', tone: 'warn' }, + { kind: 'metric', label: 'touchstart', sub: 'handler 3.4 ms', value: '0.27 s', tone: 'bad' }, + { kind: 'metric', label: 'touchend', sub: 'handler 7.7 ms', value: '0.38 s', tone: 'bad' }, + ], + detail: { + tabs: ['Interactions', 'Long tasks'], + blocks: [ + { + kind: 'charts', + charts: [ + { + title: 'Frames per second', + meta: 'last 5 min', + legend: [ + { label: 'JS thread', value: '56' }, + { label: 'Main thread', value: '60' }, + ], + points: [54, 57, 52, 58, 49, 56, 55, 58, 53, 57, 44, 58, 56, 59, 51, 57, 55, 58, 31, 56, 58], + axis: ['65', '33', '0'], + span: ['5m ago', '2.5m', 'now'], + }, + { title: 'Interactions', value: '3.13', unit: 's', big: true, note: 'worst · 0.3 s average of 51' }, + { + title: 'JS heap', + value: '190.3', + unit: 'MB', + points: [120, 118, 92, 90, 91, 128, 130, 129, 131, 96, 98, 112, 118, 124, 130, 136, 140, 118, 122, 148, 152], + axis: ['209.3 MB', '104.7 MB', '0 B'], + span: ['2m ago', '1m', 'now'], + note: 'of 236.0 MB allocated', + }, + { title: 'Device memory', value: '24.0', unit: 'GB', bar: 100, note: '0 B available to this app' }, + ], + }, + ], + }, + }, + + { + tab: 'Storage', + icon: Database, + tools: [RefreshCw, Plus, Filter, Download, Upload], + picker: 'Async Storage 5', + rows: [ + { + kind: 'entry', + glyph: '{}', + tone: 'ok', + name: 'auth-user', + size: '25.2 KB', + type: 'OBJECT', + preview: '{"state":{"user":{"createdAt":"2026-09-09T15…', + }, + { + kind: 'entry', + glyph: 'Tt', + tone: 'bad', + name: 'auth.token', + size: '184 B', + type: 'STRING', + preview: '"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ…', + }, + { + kind: 'entry', + glyph: '01', + tone: 'info', + name: 'onboarding.seen', + size: '5 B', + type: 'BOOLEAN', + preview: 'false', + }, + { + kind: 'entry', + glyph: '{}', + tone: 'ok', + name: 'feature.flags', + size: '11.0 KB', + type: 'OBJECT', + preview: '{"newCheckout":true,"betaSearch":false,…', + }, + { + kind: 'entry', + glyph: 'Tt', + tone: 'bad', + name: 'cart.items', + size: '20.5 KB', + type: 'STRING', + preview: '"[{\\"id\\":\\"6a0c0d0943f19e4adbe31ac7\\",\\"qty…', + }, + ], + detail: { + tabs: ['Value', 'Raw', 'Edit', 'Info'], + active: 3, + blocks: [ + { + kind: 'rows', + rows: [ + ['Key', 'auth-user'], + ['Store', 'Async Storage (Async)'], + ['Shown as', 'Object'], + ['Stored as', 'string'], + ['Size', '25.2 KB (25784 characters)'], + ['Read', '7:15:15 PM'], + ['Editable', 'yes'], + ['Deletable', 'yes'], + ], + }, + ], + }, + }, + + { + tab: 'Crashes', + icon: TriangleAlert, + tools: [CheckCheck, Filter], + badge: { text: '2', tone: 'bad' }, + rows: [ + { + kind: 'card', + title: 'Error', + text: 'Deliberate crash from @axonpack/expo-devtools (JS thread)', + chips: ['Fatal JS error', '700'], + time: '19:15:55', + tone: 'bad', + }, + { + kind: 'card', + title: 'TypeError', + text: "Cannot read property 'id' of null", + chips: ['Render error', '412'], + time: '19:12:04', + tone: 'bad', + }, + { + kind: 'card', + title: 'Unhandled rejection', + text: 'Token refresh failed, 401', + chips: ['Promise', '208'], + time: '19:08:31', + tone: 'warn', + }, + ], + detail: { + tabs: ['Summary', 'Breadcrumbs'], + blocks: [ + { kind: 'banner', label: 'FATAL JS ERROR', title: 'Error' }, + { + kind: 'rows', + rows: [ + ['Captured', '2026-09-11 19:15:50'], + ['Thread', 'JS'], + ], + }, + { + kind: 'stack', + frames: [ + ['crashJsThread', 'index.ts.bundle:393969:20'], + ['crash', 'index.ts.bundle:393781:156'], + ['_performTransitionSideEffects', 'index.ts.bundle:60458:20'], + ['_receiveSignal', 'index.ts.bundle:60413:43'], + ['onResponderRelease', 'index.ts.bundle:60266:30'], + ['executeDispatch', 'index.ts.bundle:19912:17'], + ['run', 'native'], + ], + }, + ], + }, + }, + +]; diff --git a/src/components/hero.tsx b/src/components/hero.tsx index 4b3ec3f..380dfe8 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -1,8 +1,9 @@ import Link from 'next/link'; -import { ArrowRight, ArrowUpRight } from 'lucide-react'; +import { ArrowRight, ArrowUpRight, Star } from 'lucide-react'; import { InstallChip } from '@/components/copy-button'; import { DevtoolsPanel } from '@/components/devtools-panel'; import { content } from '@/lib/services/content.service'; +import { starLabel } from '@/lib/services/packages.service'; // The accent half of the headline is the tail, so the words carry on counting rather than restarting. const words = [ @@ -46,6 +47,12 @@ export function Hero() { className="group inline-flex h-10 items-center gap-2 rounded-lg px-4 text-sm font-medium text-fd-muted-foreground transition-colors hover:text-fd-foreground" > {content.hero.actions[1].label} + {starLabel && ( + + + {starLabel} + + )}
    diff --git a/src/components/library-grid.tsx b/src/components/library-grid.tsx index 00e9e5f..b3e7b24 100644 --- a/src/components/library-grid.tsx +++ b/src/components/library-grid.tsx @@ -1,16 +1,24 @@ -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Download } from 'lucide-react'; +import Link from 'next/link'; +import { Download, Package } from 'lucide-react'; +import { BUMP_STYLES } from '@/lib/constants/bump-styles.const'; +import { formatDate } from '@/lib/utils/format-date.util'; import { packages } from '@/lib/services/packages.service'; /** - * Fumadocs' own Card and Cards, the same pair `PackageCards` uses inside MDX, so a library looks - * the same on the landing page as it does in the docs. The version and download line is the only - * thing added on top, since those come from npm and the docs list has no notion of them. + * A card per library. Everything on it comes from npm and from the package's own changelog, so it + * says what is actually published rather than what someone remembered to write down: the version, + * the keywords the package ships with, its licence, its downloads and its last few releases. + * + * An `article` rather than one big link, because the card carries several of its own. A link inside + * a link is invalid, and the whole card being one target would make the changelog unreachable. + * + * A package without docs points at npm instead. Publishing is one job and writing the pages is + * another, so a card cannot assume the second happened. */ export function LibraryGrid() { return (
    -
    +

    Libraries @@ -20,24 +28,102 @@ export function LibraryGrid() { npm. If it is here, it is installable today.

    - + +
    {packages.map((pkg) => ( - -
    - +
    +
    + +

    + + {pkg.title} + +

    + v{pkg.version} +
    + +

    {pkg.description}

    + + {pkg.keywords.length > 0 && ( +
      + {/* Six is what fits on two rows at the narrowest the card gets. */} + {pkg.keywords.slice(0, 6).map((keyword) => ( +
    • + {keyword} +
    • + ))} +
    + )} + + {pkg.releases.length > 0 && ( +
    +
    +

    + Recent releases +

    + {pkg.hasChangelog && ( + + All {pkg.releases.length} → + + )} +
    +
      + {pkg.releases.slice(0, 3).map((release) => ( +
    • + {release.version} + {release.bump && ( + + {release.bump} + + )} + {release.date && ( + + {formatDate(new Date(release.date))} + + )} +
    • + ))} +
    +
    + )} + + {/* Pushed down so the footers line up however long the descriptions above them are. */} +
    {pkg.totalDownloads !== null && ( - + {pkg.totalDownloads.toLocaleString()} downloads )} {pkg.license && {pkg.license}} + + npm ↗ +
    - +
    ))} - +
    ); diff --git a/src/components/site-nav.tsx b/src/components/site-nav.tsx index 4fe6fce..6fac023 100644 --- a/src/components/site-nav.tsx +++ b/src/components/site-nav.tsx @@ -9,7 +9,7 @@ import { ThemeSwitch } from 'fumadocs-ui/layouts/shared/slots/theme-switch'; import { Logo } from './logo'; import { Icon } from '@/components/icon'; import { appName, gitConfig } from '@/lib/shared'; -import { documented, packages } from '@/lib/services/packages.service'; +import { documented, packages, starLabel } from '@/lib/services/packages.service'; import { getReleases } from '@/lib/services/blog-entries.service'; import { navLink } from '@/lib/services/content.service'; @@ -186,9 +186,12 @@ export function SiteNav(props: React.ComponentProps<'header'>) { target="_blank" rel="noreferrer noopener" aria-label={`${appName} on GitHub`} - className="grid size-9 place-items-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground" + className="inline-flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground" > + {starLabel && ( + {starLabel} + )}