From 81a4469a79de36355f31192282ec9badc288cc85 Mon Sep 17 00:00:00 2001 From: Muhammad Bappi Date: Fri, 11 Sep 2026 19:50:33 +0600 Subject: [PATCH 1/9] feat: give each library card its releases and its links The card was a title, a description and a line of metadata. It now carries the icon and version, the keywords the package ships with, its last three releases with their bump badges and dates, and links to the changelog and to npm. All of it comes from npm and from the package's own changelog, so the card says what is published rather than what someone remembered to write down. No features list: there is none in the data, and inventing one would be worse than leaving it out. An article rather than one big link, since the card now has several of its own and a link inside a link is invalid. The title carries an ::after overlay so the whole card stays clickable, with the other two raised above it. Bump badges reuse BUMP_STYLES, so a patch cannot be one colour here and another on the changelog. --- src/components/library-grid.tsx | 112 ++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 13 deletions(-) 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 ↗ +
- +
))} - +
); From f722462e6388f906cf90904edc4bd2ab998a9f52 Mon Sep 17 00:00:00 2001 From: Muhammad Bappi Date: Fri, 11 Sep 2026 19:55:29 +0600 Subject: [PATCH 2/9] docs: take the em dashes out of the introduction The repo's writing rules rule them out, and the fix is to change the sentence rather than swap the dash for a comma, so all three are rewritten. --- content/docs/index.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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. From 8e70c4e2ae7119a1c4216a6f561f353673f796bb Mon Sep 17 00:00:00 2001 From: Muhammad Bappi Date: Fri, 11 Sep 2026 19:55:29 +0600 Subject: [PATCH 3/9] fix: stop the blog header drawing a line nobody else draws The landing page's header deliberately has none and the docs pages have no top bar at all, so fumadocs' navbar on the blog was the only header on the site with one under it. The colour goes rather than the border, so nothing moves by the pixel the border occupies. --- src/app/global.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/global.css b/src/app/global.css index 4881e7e..78c5c72 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -53,3 +53,10 @@ 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; +} From 7cfbf761448e58c73124fd32b70804e907841caf Mon Sep 17 00:00:00 2001 From: Muhammad Bappi Date: Fri, 11 Sep 2026 20:26:58 +0600 Subject: [PATCH 4/9] feat: make the devtools panel read as a window The title bar is gone and the panel is a frame with padding rather than a bordered box: a wider radius, a light rim, and the content sitting inside it as its own inset card. The card is lighter than the chrome around it, which is the part that actually does the work. In dark mode the two swap, since a dark window has the lighter chrome and the darker content. The sidebar sits on the frame itself, the way a mac sidebar belongs to the window rather than being a panel inside it, so its divider goes and the gap to the content card is the separation. --- src/components/devtools-panel.tsx | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/components/devtools-panel.tsx b/src/components/devtools-panel.tsx index 833abf1..b1822c4 100644 --- a/src/components/devtools-panel.tsx +++ b/src/components/devtools-panel.tsx @@ -147,22 +147,11 @@ const panes: Pane[] = [ */ export function DevtoolsPanel() { return ( -
-
- - - - - - Axonpack devtools - - - recording on device - -
- -
-
+
+
+ {/* 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) => ( -
+ {/* 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. */} +
{[...panes, panes[0]].map((pane, i) => (
Date: Fri, 11 Sep 2026 20:26:58 +0600 Subject: [PATCH 5/9] fix: read changelogs from the package, not from another repo's main CI checks out this repository alone, so the local monorepo path never resolves there and every changelog came from raw.githubusercontent at axonpack/axonpack main. That tied this build to another repository's branch state: react-pretty-print is on npm but its source has never reached that main, so the fetch 404s, the throw is uncaught, and the next deploy would have failed. So would every nightly cron after it. The changelog comes from the package's own npm tarball now, read with a small tar walk rather than a dependency. A published package cannot 404 its own tarball, because the tarball is what was published. It is also what the README always said this site does. An unreadable changelog no longer fails the build either. It warns and leaves the committed page alone, since a deploy should not die because npm blipped. Verified by rebuilding the directory layout with no monorepo above it, which is CI's situation exactly: both packages read from the tarball, exit 0, and the three generated files came out byte-identical to the local-checkout run. --- scripts/sync-changelog.mjs | 74 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) 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. From 026ec4ad22ab68cdc46c38f6eb5acf8f0fb5a1bf Mon Sep 17 00:00:00 2001 From: Muhammad Bappi Date: Fri, 11 Sep 2026 22:22:01 +0600 Subject: [PATCH 6/9] feat: rebuild the hero panel out of the real tabs Each pane now draws what its tab actually renders: a JSON tree, charts with axes, a stack listing. The sample data moved to devtools-panes.ts so the drawing and the content are separate files. Drops the Debug pane, so the carousel is five panes over 20s and the keyframes are recut to match. --- src/components/devtools-panel.module.css | 39 +- src/components/devtools-panel.tsx | 694 ++++++++++++++++------- src/components/devtools-panes.ts | 453 +++++++++++++++ 3 files changed, 950 insertions(+), 236 deletions(-) create mode 100644 src/components/devtools-panes.ts 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 b1822c4..cb433af 100644 --- a/src/components/devtools-panel.tsx +++ b/src/components/devtools-panel.tsx @@ -1,150 +1,484 @@ -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. + */ + +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} + + + ) : ( + <> + + + + )} + + + + {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 (
    @@ -156,7 +490,7 @@ export function DevtoolsPanel() { @@ -166,83 +500,13 @@ 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. */} -
    -
    + 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'], + ], + }, + ], + }, + }, + +]; From 84f5e722bb75cd7804f98b8c8ffe86b9117f843d Mon Sep 17 00:00:00 2001 From: Muhammad Bappi Date: Fri, 11 Sep 2026 22:22:05 +0600 Subject: [PATCH 7/9] feat: show the repo's star count Read from the GitHub API at build time, next to the download counts, so the page stays static. A rate limit or an outage hides the number instead of failing the build. --- scripts/fetch-packages.mjs | 37 ++++++++++++++++++++++++++-- src/components/hero.tsx | 9 ++++++- src/components/site-nav.tsx | 7 ++++-- src/lib/services/packages.service.ts | 12 +++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) 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/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/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} + )}