npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@artstorefronts/arthelper-nav

v0.1.0

Published

The ArtHelper suite navigation bar — a config-driven, cross-origin app switcher.

Downloads

1,401

Readme

@artstorefronts/arthelper-nav

The ArtHelper suite navigation bar — a config-driven, cross-origin app switcher for the satellite apps in the ArtHelper suite.

This document is for an engineer wiring the bar into a host app that lives outside this monorepo. If you're working inside this repo, see docs/llms/suite-nav.md for how the pieces fit together end to end.

Agent implementation prompt

Copy the block below and hand it to a coding agent working in the host app. It is written to be self-contained apart from this README, which it tells the agent to read — the agent will have it locally once the package is installed.

# Task: mount the ArtHelper suite nav bar in this app

This app is part of the ArtHelper suite. Add the shared suite bar so the suite reads as one
product. The bar is a published package — you are wiring it in, not building it.

## First

```bash
npm install @artstorefronts/arthelper-nav
```

Then read `node_modules/@artstorefronts/arthelper-nav/README.md` in full before writing any
code. It is the authoritative API reference and explains the layout contract, the collapsed
cookie, persistence and degradation behaviour. Peer dependencies are `react`, `react-dom`
and `lucide-react` — install them yourself if this app doesn't already have them.

## Find this app's catalog id

`activeAppId` must be the id the shared catalog uses for this app — not this repo's name.
List them:

```bash
curl -s https://api.arthelper.ai/rest/nav-config | jq -r '.apps[].id'
```

Pick the one that is this app and use it verbatim. A wrong value means the bar cannot
indicate where the user is and the collapse control has no chip to dock. If none of the ids
is obviously this app, stop and ask — an id is added by an operator, not by you.

## Wire it in

1. **Import the stylesheet** by deep path in your global CSS entry:
   `@import '@artstorefronts/arthelper-nav/dist/index.css';`
   Under Tailwind v4 every `@import` must precede other rules. The CSS is scoped with an
   `ahsuite-` prefix and uses no Tailwind utilities, so it needs no `@source` entry and
   cannot be purged.

2. **Wrap the app root in `NavProvider`**, passing `configUrl` and `activeAppId`.

3. **Render `NavBar` as the first child of the app shell, in normal document flow.** It
   occupies real layout space; the page should shift down exactly once, at first paint.
   Never wrap it in a fixed/absolute container and never portal it.

4. **Put `NavTrigger` in this app's own header, beside its logo.** This is not optional:
   when the bar is collapsed, `NavTrigger` is the only way to restore it. Ship without it
   and a user who collapses the bar can never get it back. It hides itself while the bar is
   expanded, so you just place it.

5. **If this app server-renders**, read the collapsed cookie on the server and pass it as
   `initialCollapsed`. Import the cookie's name from the package rather than retyping it.
   Without this, a user who collapsed the bar gets an expanded bar in the first byte and a
   56px jump after hydration.

6. **Pass `onAppClick`** so this app's clicks reach the fleet-wide chart. The package ships
   no analytics SDK — transport, identity and consent stay with you — but it does export the
   event's shape, so use it rather than hand-writing the payload:

   ```ts
   import { suiteNavClickEvent } from '@artstorefronts/arthelper-nav';

   onAppClick={(args) => yourTracker.track(...suiteNavClickEvent(args, '<this-app-id>'))}
   ```

   Hand-writing it is how a satellite ends up sending `app_id` instead of `target_app_id`,
   which does not fail — it silently splits the chart. One callback covers both the desktop
   tiles and the mobile dropdown. If your tracker batches, flush on the way out: a tile is a
   real cross-app `<a href>`, so the document is replaced and a queued batch dies with it.

## `configUrl` — the mistake to avoid

It is the ArtHelper API's **REST** endpoint, ending `/rest/nav-config`:

- production: `https://api.arthelper.ai/rest/nav-config`
- local: your ArtHelper stack's API port, same path

Read it from this app's environment config with a sensible default; do not hardcode one
value. **It is not the GraphQL endpoint.** Pointing it at `/graphql` returns 404 and the bar
falls back _silently_ to its compiled-in catalog — no console error, no visible failure,
just a bar that never reflects the admin screen. If tiles never update, curl the URL before
debugging anything else.

The endpoint is public, unauthenticated and CORS-open. **Do not send credentials** — the
response carries a literal `Access-Control-Allow-Origin: *`, which browsers reject for
credentialed requests.

## Sticky, and the audit that goes with it

Decide whether the bar is sticky:

- **This app's document scrolls** (a normal page): use `<NavBar sticky />`, and offset your
  own sticky header's `top` by the bar's height. **That offset must react to collapse** —
  read `collapsed` from `useNav()` and use `collapsed ? 0 : <bar height>`. A constant offset
  leaves a visible void at scroll-top once the bar collapses.
- **The shell is a fixed-height flex column** that never scrolls the document: do not pass
  `sticky` and change no offsets.

**Then audit the whole app for surfaces that assume the header's bottom edge, or that they
can occupy the full viewport height.** Grep for `top-14`, `top: 3.5rem`, `h-screen`,
`h-svh`, `h-dvh`, `100vh`, `100svh`, `100dvh`, and `calc(100vh - N)` patterns. Each one is
now short by the bar's height while the bar is expanded, and will sit _behind_ your header
or run off the bottom of the screen.

This is the single largest source of breakage when adopting the bar. The two apps inside the
ArtHelper monorepo needed **nine** such fixes between them, including a `position: fixed`
sidebar rail, two pages subtracting a hardcoded pixel count, and an overlay panel rendered
through a portal that could not inherit a CSS variable at all. Derive them all from one
shared value rather than sprinkling `collapsed ?` checks through components, and remember
that a portalled surface may need the value passed to it another way.

Judge each hit: a surface inside a scroll container that never touches the viewport edge is
unaffected, and a modal meant to cover the chrome should keep covering it. Say which you
changed and which you deliberately did not.

## What is not configurable

The bar's **look** — colour, alignment, accent, the wordmark — is compiled into the package,
not served in the payload. Do not look for theme props or try to restyle it from the host;
changing it is a package release. Only the app catalog is configurable.

## Expected behaviour, so you don't chase non-bugs

- **Only some apps appear.** An app renders only once an operator has both enabled it and
  given it a destination in ArtHelper's admin screen.
- **This app's own chip always renders**, even if its catalog entry is disabled or has no
  destination. With no destination it is a non-navigating indicator chip. That is correct —
  it tells the user where they are.
- **The bar survives an endpoint outage** by falling back to a catalog compiled into the
  package, so it renders at full height rather than blank and never blocks first paint.
- **The catalog is persisted** after the first successful fetch, so repeat loads paint the
  last known catalog rather than the compiled-in one.

## Verify in a real browser, not by reasoning

1. The bar renders above your header; measure its height with `getBoundingClientRect()` and
   confirm it matches the documented height exactly.
2. Collapse it: height 0, your header rises to the very top with no gap, chip appears beside
   your logo. Restore it: back to full height.
3. Reload while collapsed — it stays collapsed.
4. Click another app's tile — the address bar becomes that destination.
5. If sticky: scroll a few hundred pixels in both bar states and confirm your header is
   neither covered nor floating above a void.
6. Below the `md` breakpoint the tiles become a labelled dropdown; every app is reachable and
   it dismisses without leaving the page.
7. Block the config endpoint in devtools — the bar still renders at full height with this
   app's own chip, not blank.
8. If this app server-renders, check the console for hydration warnings. There must be none.

## Do not

- Do not fork, vendor or patch the package. Report upstream problems instead.
- Do not hardcode any app's destination — destinations are config.
- Do not infer `activeAppId` from the URL; the host declares it.
- Do not send credentials with the config request.
- Do not render a second bar if this app is ever framed inside another ArtHelper surface.
- Avoid `yarn link` unless necessary; if you use it, make sure the bundler resolves a single
  copy of React or you will hit the duplicate-React hooks error. `npm pack` plus installing
  the tarball avoids that and resolves more like the real thing.

Architecture in one line

Configure the catalog in data, ship everything else in code. The app catalog lives in a single database row, served as JSON over a public HTTP endpoint. Every mounted host fetches that JSON on load, so a catalog edit made in the admin editor is live for every app in the suite on their next page load — no deploy, no version bump, no coordinated release across repos.

The payload is { schema_version, apps } and nothing else. The bar's look and its wordmark are compiled in, on purpose — see Theming and the wordmark. The last good catalog is persisted per browser so a return visit paints it rather than the compiled-in fallback — see Persistence.

Install

npm install @artstorefronts/arthelper-nav

Peer dependencies (from package.json — install these yourself, they are not bundled):

{
  "lucide-react": ">=0.446.0",
  "react": "^18.0.0 || ^19.0.0",
  "react-dom": "^18.0.0 || ^19.0.0"
}

Need a change to the bar itself? Its look, layout and behaviour ship in this package rather than being configurable — see Theming and the wordmark — so changing them means a new release. Ask the ArtHelper team; releases are automatic and quick. What you can change without one is which apps appear, in what order and pointing where: that lives in the config row, editable from ArtHelper's admin screen and picked up on next load.

Minimal mounting example

// Import the stylesheet by its deep path — it is not re-exported from the package root.
import '@artstorefronts/arthelper-nav/dist/index.css';
import { NavProvider, NavBar, NavTrigger } from '@artstorefronts/arthelper-nav';

function AppRoot() {
  return (
    <NavProvider activeAppId="my-app" configUrl="https://api.arthelper.com/rest/nav-config">
      {/* NavBar first, so it occupies real layout space at the top of the shell. */}
      <NavBar />
      <AppHeader />
      <AppContent />
    </NavProvider>
  );
}

function AppHeader() {
  return (
    <header>
      <Logo />
      {/* NavTrigger is the RESTORE control. Omit it and a viewer who collapses the
          bar has no way to bring it back. */}
      <NavTrigger />
    </header>
  );
}

NavProvider renders no DOM of its own — NavBar and whatever else you nest inside it stay direct children of your own layout, so the 56px the bar reserves comes purely from NavBar itself, not from a wrapper.

Components and props

NavProvider

The context host. Fetches config, resolves the app list, and owns collapsed state. Must wrap NavBar, NavTrigger, and any component calling useNav().

| Prop | Type | Required | Description | | ------------------ | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | configUrl | string | yes | Full URL of the public nav-config endpoint. See Deriving configUrl below. | | activeAppId | string | yes | The catalog id of the app the host itself is. Never inferred from the URL or the payload — the host always declares it. | | onAppClick | (args: NavAppClickArgs) => void | no | Fired immediately before navigation. The package's only analytics seam — see onAppClick. | | fallbackConfig | NavConfigPayload | no | Overrides the package's compiled-in fallback, e.g. for a satellite with a different first-party destination set. | | initialCollapsed | boolean | no | The collapsed cookie value a server-rendered host already read, so the first byte paints the correct state. See SSR and the collapsed cookie. | | children | ReactNode | no | — |

NavBar

Renders the bar itself: waffle toggle, wordmark, the desktop tile row (or, below the 768px breakpoint, a labelled dropdown trigger and panel with the same entries as rows).

| Prop | Type | Default | Description | | ----------- | --------- | ------- | -------------------------------------------------------------------------------------- | | sticky | boolean | false | Opt in only for a host whose document actually scrolls. See sticky below. | | className | string | — | Appended to the bar's root class list. |

NavTrigger

The docked waffle-plus-active-app chip a host places in its own header, next to its own logo. This is the restore control: while the bar is collapsed (NavBar reserves nothing and is out of the accessibility tree), NavTrigger is the only way back. It is display: none while the bar is expanded. A host that mounts NavBar without also mounting NavTrigger ships a bar a viewer can collapse and never recover.

| Prop | Type | Default | Description | | ----------- | -------- | ------- | ------------------------------------------ | | className | string | — | Appended to the trigger's root class list. |

Exported types

NavContextValue, NavBarProps, NavTriggerProps, NavProviderProps, NavApp, NavAppClickArgs, NavConfigPayload, ResolvedNavApp — all exported from the package root (src/index.ts), also useful if you're typing fallbackConfig or onAppClick yourself.

useNav() is also exported, for a component nested under NavProvider that needs collapsed, toggle, activeApp, or apps directly — the two host mounts in this monorepo use it, for example, to publish the bar's live height as a CSS variable.

activeAppId

The host is always the one source of truth for which app it is. NavProvider never infers this from the current URL and never reads it from the config payload — you pass it explicitly, and it must match one of the ids in the nav-config app catalog (ask the ArtHelper team for your app's id, or check the payload at GET /rest/nav-config).

That entry is always rendered and indicated (its tile carries the active styling) even if the payload has it disabled or omits a destination for it. If your own app has no href in the catalog yet, its tile renders as an inert indicator instead of a link — it never disappears from the bar.

Deriving configUrl

No new environment variable is needed. Derive it from whatever ArtHelper API base URL your app already has, by swapping the graphql path segment for rest and appending /nav-config:

const NAV_CONFIG_URL = `${ARTHELPER_API_BASE.replace('graphql', 'rest')}/nav-config`;
// e.g. https://api.arthelper.com/graphql -> https://api.arthelper.com/rest/nav-config

This is the same derivation both in-monorepo hosts (client/, town-square/) use.

onAppClick: the analytics seam

onAppClick fires immediately before navigation, from one shared handler behind both the desktop tile row and the mobile dropdown — so it covers both surfaces and cannot double-fire.

The package ships no analytics SDK, deliberately. Bundling one would run a second instance alongside yours, racing it on device id and session, and would emit without your identity or consent handling — producing clicks that cannot be joined to users. Transport, identity and consent are yours.

What the package does own is the event's shape, because that is the part that drifts:

import { suiteNavClickEvent } from '@artstorefronts/arthelper-nav';

<NavProvider
  configUrl={NAV_CONFIG_URL}
  activeAppId="art-books"
  onAppClick={(args) => amplitude.track(...suiteNavClickEvent(args, 'art_books'))}
>

It returns a [name, properties] pair, so the two cannot be paired wrongly. Properties are target_app_id (the app clicked — the dimension click-throughs group on), source_app (this app, passed in because hosts sharing a product surface share an activeAppId and so cannot be told apart by it), and collapsed.

SUITE_NAV_CLICK_EVENT is exported too if your tracker needs the name separately.

If your tracker batches, flush on the way out. A tile is a real cross-app <a href>, so the document is replaced and a queued batch dies with it — both ArtHelper hosts use their tracker's on-exit variant for exactly this reason.

SSR and the collapsed cookie

The collapsed flag lives in a cookie (arthelper_suite_nav_collapsed), not localStorage. That's deliberate: a server-rendered host can read the cookie during SSR and pass its value in as initialCollapsed, so the very first byte the browser paints already has the bar in the right state — no expanded-then-collapsed jump after hydration.

// Next.js App Router example
import { cookies } from 'next/headers';
import { COLLAPSED_COOKIE } from '@artstorefronts/arthelper-nav';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const initialCollapsed = cookieStore.get(COLLAPSED_COOKIE)?.value === 'true';

  return (
    <NavProvider activeAppId="my-app" configUrl={NAV_CONFIG_URL} initialCollapsed={initialCollapsed}>
      {children}
    </NavProvider>
  );
}

The cookie is per-origin. app.example.com and www.example.com are different registrable domains and share no cookie scope, so collapsing the bar on one origin does not sync to another — each origin's viewer sets their own collapsed state independently.

Theming and the wordmark

There is no theming, deliberately. The bar ships one look — dark "Forest", tiles centred on the bar — baked into dist/index.css, and the wordmark (arthelper, linking to https://www.arthelper.com) is baked into the component. No part of either can be set by a host or by the payload.

Both used to be configurable: the payload carried a theme block (mode, accent_color, alignment) and a logo block (label, href) that an admin edited in the nav editor. That was a mistake, and the reason generalizes to every configurable field. The bar paints the package's compiled-in fallback before the fetch resolves and swaps in the served config afterwards, so any configured value that disagrees with the fallback's guess produces a visible change on every page load — a dark bar turning light, tiles jumping from left to centre, a wordmark rewriting itself. Colour, layout and the wordmark are release decisions, reviewed once and then stable. The app catalog is the one genuine operational switch, and it is all the payload carries now.

The wordmark's href is the site root, not the resolved home catalog entry. Deriving it was considered and rejected: it would put a static piece of chrome back on config, and home points at /community while the wordmark has always gone to the root.

Changing the look or the wordmark now means a package release. A host that wants the bar on differently-coloured chrome should change its own surrounding chrome, not the bar.

A payload that still carries theme or logo has them dropped during normalization like any other unrecognized field, and an older renderer that still expects them degrades to its own compiled-in defaults rather than throwing.

Persistence

The catalog is the one field that can still differ from the compiled-in fallback, and because the tile row is centred on the bar, that difference is expensive: every tile the fetch adds or removes shifts the whole row by half a tile, so a two-tile fallback resolving to a seven-tile config moves the Home tile 230px.

So a successful fetch persists the normalized payload to localStorage under ahsuite-nav-config-v1, and on mount the bar prefers that over the compiled-in fallback. The fallback becomes the first-ever-visit case.

Things worth knowing if you are wiring this into a host:

  • The persisted blob is re-validated on read, through the same normalize a network body goes through. A blob written months ago by an older renderer is untrusted input.
  • Entries expire after 30 days, so a long-dormant browser revalidates rather than painting something ancient.
  • Every storage access is wrapped, including the localStorage property read itself — Safari's private mode and Chrome with site data blocked throw on access, not just on write. An unreachable store simply means the fallback paints.
  • It cannot help a server-rendered host's first frame. The server has no access to the viewer's localStorage, so its HTML is always the compiled-in fallback. The read runs in a client-only layout effect — before the browser paints, after hydration has committed — which removes the hydration-time swap but not the SSR one. If that first frame matters to you, pass the catalog you want server-rendered as fallbackConfig.

The layout contract

NavBar reserves exactly 56px from first paint, using the package's compiled-in fallback config while the real payload is in flight — so config arrival never shifts the page. Collapsing the bar (toggle(), or the waffle button, or NavTrigger) removes it from layout entirely: height goes to 0 and the 56px is handed back to the page, not just visually hidden.

sticky

Pass sticky only when your host's own document actually scrolls (e.g. a normal page layout) — in that case the bar should stick to the top of the viewport as the page scrolls beneath it. Leave it false (the default) for a host that is a fixed-height flex column whose document itself never scrolls (only an inner pane does) — sticky positioning there is dead weight.

If your host is sticky and also has its own sticky header below the bar, that header's top offset has to account for the bar's height — and that offset has to react to collapse, or a viewer who collapses the bar is left with a 56px void at the top of the page where your header no longer starts. Read collapsed from useNav() and publish the live height (0 collapsed, 56px expanded) as a CSS variable your header's top reads, rather than hardcoding 56px once.

Degradation

The bar is designed to never render blank and never block first paint:

  • Endpoint outage or timeout — the fetch has a hard 2.5s timeout; on any failure (network error, timeout, non-2xx, unparseable body) the bar falls back to its compiled-in config and keeps whatever it was already showing. No visible error, no console noise by default.
  • Unknown fields — anything in the payload the renderer doesn't recognize is dropped during normalization, never carried through and never thrown on.
  • A higher schema_version than this renderer understands — ignored the same way; the renderer keeps working on the fields it does know.
  • A corrupt, stale or unreachable localStorage — treated as "nothing persisted": the compiled-in fallback paints and the fetch revalidates. Never throws.

The schema_version contract

Changes to the wire payload are additive within a version: fields are only ever added, never removed or repurposed, for a given schema_version. A renderer must ignore fields it doesn't recognize rather than throw. That's what lets the suite's asynchronously-deployed satellite fleet run mixed renderer versions against one shared payload — an older @artstorefronts/arthelper-nav keeps working after a newer field ships on the config row. The version number itself is bumped only for a genuinely breaking change.

The one exception so far: theme was removed from the payload without bumping schema_version off 1. Removal is breaking under this contract, and it was allowed only because no stable version had ever been released against the old shape — the two 0.1.0-alpha.* publishes both tolerate the field's absence and degrade rather than throw, which was verified against the actual tarballs. After the first stable publish the same removal would require a version bump and a transition period.