@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-navPeer 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-configThis 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
normalizea 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
localStorageproperty 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 asfallbackConfig.
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_versionthan 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.
