@hermesihq/react 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,87 +1,138 @@
1
- # Changelog
2
-
3
- All notable changes to `@hermesihq/react`.
4
-
5
- This file describes what a consumer gets, not how it was built. The repository's
6
- history is where the reasoning lives.
7
-
8
- ## Versioning
9
-
10
- **`0.x` means the public API can still change.** A minor bump (`0.1` → `0.2`) may
11
- contain a breaking change; a patch bump will not. That is the ordinary `0.x` reading
12
- of semver and it is chosen deliberately rather than by default: this SDK's shape is
13
- still being learned from the first integrations, and promising stability before anyone
14
- has built against it would be a promise made to nobody and broken later.
15
-
16
- `1.0.0` is the commitment that a breaking change requires a major bump. It waits until
17
- at least one real integration exists.
18
-
19
- Each release lists breaking changes first, because that is the only section that
20
- decides whether an upgrade is a decision or a formality.
21
-
22
- ## 0.1.0 (2026-09-28)
23
-
24
- The first published release. Nothing precedes it, so there is nothing to migrate from
25
- and no deprecations; the entries below describe the surface rather than changes to one.
26
-
27
- ### The inbox
28
-
29
- - `HermsClient`: the framework-agnostic core. Reads and writes the subscriber's inbox,
30
- opens a realtime stream for new items and unread counts, and falls back to polling
31
- when the runtime has no `EventSource` or the stream keeps failing. It imports nothing
32
- from React, so a Vue or vanilla wrapper is a thin layer rather than a second
33
- implementation.
34
- - `HermsProvider` / `useHermsContext`: one shared connection per provider, fanned out
35
- to every hook beneath it. Mounting three components does not open three streams.
36
- - `useInbox`: the item list, with filtering, cursor pagination, and mark-read,
37
- mark-all-read, archive and delete.
38
- - `useUnreadCount`: the badge, kept live off the same stream.
39
- - `HermsInbox`: a rendered bell and panel, keyboard-navigable, in English or French.
40
- Optional: the hooks above are the whole API if you would rather build your own.
41
-
42
- ### Preferences
43
-
44
- - `client.getPreferences()` and `client.updatePreference({ channel, enabled, categoryId })`,
45
- plus the `usePreferences` hook.
46
- - `PATCH` answers with the whole updated state, so a caller never refetches. The hook
47
- does **not** update optimistically: turning off a channel is a consent decision, and
48
- showing it as done before the server agreed tells somebody they have opted out when
49
- they may not have.
50
- - A setting of `null` means no preference was expressed and the default applies. It is
51
- not `false`. Categories with `isCritical: true` are always delivered. Render them so
52
- a subscriber can see what they receive, but not as a control.
53
- - There is deliberately no `unsubscribe()`. `POST /v1/client/unsubscribe` is the target
54
- of a `List-Unsubscribe` one-click link (RFC 8058) whose token is minted into an
55
- outgoing email's headers and invoked by a mail client, not by application code. The
56
- equivalent here is `updatePreference({ channel: 'email', enabled: false })`.
57
-
58
- ### Identities and errors
59
-
60
- - `client.registerChannel` / `client.deregisterChannel`, and `HERMS_CHANNELS`: the
61
- channels an identity can be registered for, exported as an array so a preference
62
- centre can iterate them, with `HermsChannel` derived from it.
63
- - `HermsApiError`: every failed request rejects with it, carrying the API's `type`,
64
- `code`, `message`, `request_id`, `detail` and `docUrl`. A response that is not JSON
65
- (a proxy's error page, an empty body where one was expected) arrives as this too,
66
- rather than as a `TypeError` from inside the SDK.
67
- - `decodeSubscriberTokenExp`: reads a subscriber token's expiry so a host can refresh
68
- ahead of it. It parses; it does not validate a signature and cannot, since this
69
- package never holds a key.
70
-
71
- ### Requirements
72
-
73
- React 18 or newer, as a peer dependency with no upper bound. That is deliberate and
74
- the opposite of how this repository pins its own dependencies: an application locks its
75
- versions so two builds of one commit are identical, whereas a library that bounds a
76
- peer range forces a warning on every consumer the day React ships a major, whether or
77
- not anything actually broke. You control React; this package should not have an opinion
78
- about which one you run.
79
-
80
- Ships ESM and CommonJS builds with type declarations for both, and
81
- `@hermesihq/react/styles.css` for `HermsInbox`.
82
-
83
- ### Known gaps
84
-
85
- - No `<HermsPreferences />` component. The headless surface for one is here; the
86
- rendered interface is not, because it wants a design pass rather than an invention.
87
- - No Vue or vanilla wrapper yet.
1
+ # Changelog
2
+
3
+ All notable changes to `@hermesihq/react`.
4
+
5
+ This file describes what a consumer gets, not how it was built. The repository's
6
+ history is where the reasoning lives.
7
+
8
+ ## Versioning
9
+
10
+ **`0.x` means the public API can still change.** A minor bump (`0.1` → `0.2`) may
11
+ contain a breaking change; a patch bump will not. That is the ordinary `0.x` reading
12
+ of semver and it is chosen deliberately rather than by default: this SDK's shape is
13
+ still being learned from the first integrations, and promising stability before anyone
14
+ has built against it would be a promise made to nobody and broken later.
15
+
16
+ `1.0.0` is the commitment that a breaking change requires a major bump. It waits until
17
+ at least one real integration exists.
18
+
19
+ Each release lists breaking changes first, because that is the only section that
20
+ decides whether an upgrade is a decision or a formality.
21
+
22
+ ## 0.2.1 (2026-10-01)
23
+
24
+ ### Fixed
25
+
26
+ - **The panel had no styling.** `<HermsInbox />` renders its panel in a portal under `<body>`, so
27
+ the panel is not inside the bell's root, and every colour variable was defined only on that
28
+ root. Measured in a browser on 0.2.0: the panel was transparent, had no border and square
29
+ corners, and its text stayed black in dark mode. The `theme` and `colorScheme` props put their
30
+ values on the root, so they could never reach the panel either. The panel now carries the
31
+ variables, the forced colour scheme and the `theme` values itself, and forcing light mode on a
32
+ dark system works. If you worked around this with your own CSS on `.herms-inbox__panel`, it may
33
+ now be redundant.
34
+ - `className` is now applied to the panel as well as to the bell, so one rule themes both.
35
+ - The panel is sized with `box-sizing: border-box`. Its 380px width now includes its border; it
36
+ used to render at 382px.
37
+ - The dialog has an accessible name, taken from its title. It used to be announced as just
38
+ "dialog".
39
+
40
+ ## 0.2.0 (2026-09-30)
41
+
42
+ No breaking changes, and nothing to change in your code: everything this package exported
43
+ before it exports now, from the same place.
44
+
45
+ **The client and the state moved into a new package, `@hermesihq/js`, which this one now
46
+ depends on.** The list, count and preference state that lived inside the hooks is now held
47
+ in framework-free stores, and the hooks are a thin binding over them. That is what lets a
48
+ Vue, Angular or plain JavaScript page use the same code. You do not need to install or
49
+ import `@hermesihq/js` yourself; `HermsClient`, `HermsApiError`, `HERMS_CHANNELS` and the
50
+ rest are still exported from here. If you also import `@hermesihq/js` directly, npm
51
+ installs one shared copy, so both see the same `HermsClient` class.
52
+
53
+ Four things a consumer can notice:
54
+
55
+ - **A further page for the previous filter no longer lands in the new list.** With
56
+ `useInbox`, switching tabs while "load more" was in flight could append the old tab's
57
+ rows to the new tab's list. Any response that arrives after the list was reloaded, the
58
+ filter changed or the hook unmounted is now discarded.
59
+ - **A load's result and its loading flag now change together.** The rows, or the error,
60
+ used to be published one update before `isLoading` cleared, so a component that
61
+ renders on every update could briefly show "failed and still loading" or "here are
62
+ your rows and still loading".
63
+ - **The functions `useInbox` and `usePreferences` return keep their identity.**
64
+ `loadMore`, `markRead`, `archive` and the rest used to be recreated whenever
65
+ `isLoadingMore` or `hasMore` changed, so a `useCallback` or `memo` listing one re-ran
66
+ on every page.
67
+ - **Two providers over one client no longer close each other's connection.** The
68
+ real-time stream is reference-counted: it opens for the first and closes with the last.
69
+
70
+ A listener that throws is now reported to the host without stopping the other listeners
71
+ or corrupting the state it was notified about.
72
+
73
+ ## 0.1.0 (2026-09-28)
74
+
75
+ The first published release. Nothing precedes it, so there is nothing to migrate from
76
+ and no deprecations; the entries below describe the surface rather than changes to one.
77
+
78
+ ### The inbox
79
+
80
+ - `HermsClient`: the framework-agnostic core. Reads and writes the subscriber's inbox,
81
+ opens a realtime stream for new items and unread counts, and falls back to polling
82
+ when the runtime has no `EventSource` or the stream keeps failing. It imports nothing
83
+ from React, so a Vue or vanilla wrapper is a thin layer rather than a second
84
+ implementation.
85
+ - `HermsProvider` / `useHermsContext`: one shared connection per provider, fanned out
86
+ to every hook beneath it. Mounting three components does not open three streams.
87
+ - `useInbox`: the item list, with filtering, cursor pagination, and mark-read,
88
+ mark-all-read, archive and delete.
89
+ - `useUnreadCount`: the badge, kept live off the same stream.
90
+ - `HermsInbox`: a rendered bell and panel, keyboard-navigable, in English or French.
91
+ Optional: the hooks above are the whole API if you would rather build your own.
92
+
93
+ ### Preferences
94
+
95
+ - `client.getPreferences()` and `client.updatePreference({ channel, enabled, categoryId })`,
96
+ plus the `usePreferences` hook.
97
+ - `PATCH` answers with the whole updated state, so a caller never refetches. The hook
98
+ does **not** update optimistically: turning off a channel is a consent decision, and
99
+ showing it as done before the server agreed tells somebody they have opted out when
100
+ they may not have.
101
+ - A setting of `null` means no preference was expressed and the default applies. It is
102
+ not `false`. Categories with `isCritical: true` are always delivered. Render them so
103
+ a subscriber can see what they receive, but not as a control.
104
+ - There is deliberately no `unsubscribe()`. `POST /v1/client/unsubscribe` is the target
105
+ of a `List-Unsubscribe` one-click link (RFC 8058) whose token is minted into an
106
+ outgoing email's headers and invoked by a mail client, not by application code. The
107
+ equivalent here is `updatePreference({ channel: 'email', enabled: false })`.
108
+
109
+ ### Identities and errors
110
+
111
+ - `client.registerChannel` / `client.deregisterChannel`, and `HERMS_CHANNELS`: the
112
+ channels an identity can be registered for, exported as an array so a preference
113
+ centre can iterate them, with `HermsChannel` derived from it.
114
+ - `HermsApiError`: every failed request rejects with it, carrying the API's `type`,
115
+ `code`, `message`, `request_id`, `detail` and `docUrl`. A response that is not JSON
116
+ (a proxy's error page, an empty body where one was expected) arrives as this too,
117
+ rather than as a `TypeError` from inside the SDK.
118
+ - `decodeSubscriberTokenExp`: reads a subscriber token's expiry so a host can refresh
119
+ ahead of it. It parses; it does not validate a signature and cannot, since this
120
+ package never holds a key.
121
+
122
+ ### Requirements
123
+
124
+ React 18 or newer, as a peer dependency with no upper bound. That is deliberate and
125
+ the opposite of how this repository pins its own dependencies: an application locks its
126
+ versions so two builds of one commit are identical, whereas a library that bounds a
127
+ peer range forces a warning on every consumer the day React ships a major, whether or
128
+ not anything actually broke. You control React; this package should not have an opinion
129
+ about which one you run.
130
+
131
+ Ships ESM and CommonJS builds with type declarations for both, and
132
+ `@hermesihq/react/styles.css` for `HermsInbox`.
133
+
134
+ ### Known gaps
135
+
136
+ - No `<HermsPreferences />` component. The headless surface for one is here; the
137
+ rendered interface is not, because it wants a design pass rather than an invention.
138
+ - No Vue or vanilla wrapper yet.
package/README.md CHANGED
@@ -1,128 +1,122 @@
1
- # @hermesihq/react
2
-
3
- Hermesi's embeddable inbox SDK: a framework-agnostic
4
- `HermsClient` core plus React bindings (`<HermsProvider>`, `useUnreadCount()`,
5
- `useInbox()`, `<HermsInbox />`).
6
-
7
- ## Scope of this package, honestly
8
-
9
- This repo has no monorepo tooling (no pnpm/Turborepo workspaces, no root
10
- `package.json`). The dashboard (`frontend/`) still consumes this package as
11
- TypeScript source in development, via a `resolve.alias` in
12
- `frontend/vite.config.ts` (for the real bundler) and a matching `paths` entry
13
- in `frontend/tsconfig.app.json` (for `tsc -b`), not through `node_modules`,
14
- and not via a `file:` dependency. That wiring is deliberately left as-is: the
15
- dashboard isn't this package's audience, external integrators are, and
16
- changing how the dashboard resolves its own in-repo copy isn't needed to make
17
- *this package* installable elsewhere.
18
-
19
- `npm run build` now runs `tsup` (`tsup.config.ts`), which really does produce
20
- `dist/index.js` (ESM), `dist/index.cjs` (CJS), `dist/index.d.ts`/`.d.cts`, and
21
- `dist/index.css` (extracted from `HermsInbox.tsx`'s `import
22
- './HermsInbox.css'`). `main`/`module`/`types`/`exports` point at that `dist/`
23
- output, `files` publishes only `dist/`, this `README.md`, and `LICENSE`
24
- (MIT, see that file), and `prepublishOnly` reruns the typecheck and the
25
- build before anything ships. `private: true` has been removed. `npm publish`
26
- from this directory (after `npm login` with an account that has publish
27
- rights on the `@hermesi` npm org/scope) now does the real thing.
28
-
29
- The `repository`/`homepage`/`bugs` URLs were placeholders pointing at
30
- `github.com/hermesi/hermesi`, a repository that is not this one. npm renders
31
- those as the package's own links, so they would have sent every integrator
32
- somewhere that does not exist. They point at the real remote now.
33
-
34
- **Still worth deciding before v1:** semver policy, a bundle-size CI gate, and a
35
- fuller SSR render test matrix (see "Not in this pass" below).
36
-
37
- ## What's real here
38
-
39
- The packaging is real too, now: `npm run verify:package` builds, packs,
40
- installs the tarball into a throwaway consumer and imports from it under ESM,
41
- CJS and two TypeScript module resolutions, and CI runs it on every change.
42
- Until that existed nothing had ever loaded `dist/`: the dashboard imports this
43
- package's *source* through a bundler alias, so every test proved the source
44
- works and nothing at all about what npm would serve.
45
-
46
- `HermsClient` makes real HTTP requests
47
- against `/v1/client/inbox/*`, opens a real `EventSource` against
48
- `/v1/client/inbox/stream` (falling back to polling `/v1/client/inbox/counts`
49
- every 60s when SSE isn't available or keeps failing), and
50
- `<HermsInbox />` is a real, accessible bell + dropdown panel wired to that
51
- client through the headless hooks.
52
-
53
- A full working consumer lives in this package's repository, at
54
- `frontend/src/pages/InboxWidgetDemoPage.tsx`. It is linked rather than
55
- described because an integrator installing from npm does not have that
56
- file. See the repository link in `package.json`.
57
-
58
- ## Exports a consumer needs and this README used to omit
59
-
60
- `HermsApiError`: every failed request rejects with it, carrying the API's own
61
- `type`, `code`, `message` and `request_id`. Catching it by type is the
62
- difference between showing a user "rate limited, try again" and showing them
63
- nothing. It was exported and undocumented, which means it was effectively
64
- unavailable.
65
-
66
- `HERMS_CHANNELS`: the channels an identity can be registered for, as an array
67
- so a preference centre can iterate them, with `HermsChannel` derived from it.
68
-
69
- `decodeSubscriberTokenExp`: reads a subscriber token's `exp` client-side so a
70
- host can refresh ahead of expiry. It parses; it does **not** validate a
71
- signature and cannot, since this package never holds a key.
72
-
73
- `client.registerChannel({ channel, identifier })` and
74
- `client.deregisterChannel(channel, identifier)`: where a subscriber can be
75
- reached on a channel that needs an address.
76
-
77
- `client.getPreferences()` / `client.updatePreference({ channel, enabled,
78
- categoryId })` and the `usePreferences()` hook: the subscriber's own
79
- notification settings, for building a preference centre. `PATCH` answers with
80
- the whole updated state, so a caller never refetches, and the hook does **not**
81
- update optimistically: turning off a channel is a consent decision, and showing
82
- it as done before the server agreed shows somebody they have opted out when
83
- they may not have.
84
-
85
- A setting of `null` means no preference was expressed and the default applies.
86
- It is not `false`, and collapsing the two would opt somebody out of something
87
- they never declined. Categories with `isCritical: true` are always delivered.
88
- Render them so a subscriber can see what they receive, but not as a control.
89
-
90
- There is deliberately **no** `unsubscribe()`, although `POST
91
- /v1/client/unsubscribe` exists. It is the target of a `List-Unsubscribe`
92
- one-click link (RFC 8058) whose token Hermesi mints into an outgoing email's
93
- headers, invoked by a mail client rather than by application code. Wrapping it
94
- here would invite you to build a button on a token you cannot obtain. The
95
- equivalent is `updatePreference({ channel: 'email', enabled: false })`.
96
-
97
- ## Not in this pass
98
-
99
- For `<HermsPreferences />`, the headless surface is here
100
- (`usePreferences()`), the rendered component is not: a preference centre is a
101
- real interface design, and inventing one without that pass is how you get a
102
- screen that technically works. A Vue/vanilla wrapper (the framework-agnostic core is
103
- what makes one *possible* later, not a promise it exists), a category-filter
104
- picker inside `<HermsInbox />`'s own chrome, and the real-repo-URL/semver/CI
105
- follow-ups called out just above. See the task this package was built
106
- against for the exact, deliberate cut lines.
107
-
108
- ## Layout
109
-
110
- ```
111
- src/
112
- core/ HermsClient: no React import anywhere in this directory.
113
- HermsClient.ts
114
- subscriberToken.ts Reads (never verifies/mints) a token's `exp` claim.
115
- types.ts
116
- react/
117
- HermsProvider.tsx
118
- useUnreadCount.ts
119
- useInbox.ts
120
- HermsInbox.tsx
121
- HermsInbox.css Scoped under `.herms-inbox`, CSS custom properties only:
122
- no Tailwind dependency, so a host app's Tailwind build
123
- can never bleed into the widget (and vice versa).
124
- locale.ts A tiny built-in EN/FR string table, not i18next, so this
125
- package stays independent of whatever i18n stack (or
126
- none) a host app runs.
127
- index.ts
128
- ```
1
+ # @hermesihq/react
2
+
3
+ React bindings for Hermesi's in-app inbox: a ready-made bell and panel, and the hooks it is
4
+ built on if you would rather build your own.
5
+
6
+ ```sh
7
+ npm install @hermesihq/react
8
+ ```
9
+
10
+ Requires React 18 or newer. It depends on [`@hermesihq/js`](https://github.com/hermesihq/sdk/tree/main/packages/js),
11
+ which npm installs for you. You do not import it yourself unless you want the stores directly.
12
+
13
+ ## Before you start
14
+
15
+ Three things: a **public key** (`hm_pk_...`, safe to ship in a bundle), your **API base URL
16
+ ending in `/v1/client`**, and a **subscriber token minted by your own backend**. The token
17
+ proves which subscriber this page acts for. Your backend signs it with your secret key; this
18
+ package never sees that key and cannot mint a token. You give the client a function that
19
+ asks your backend for a fresh one, and it calls that function before every request.
20
+
21
+ The base URL is the client API, not the host root: every request is this string plus a path
22
+ such as `/inbox`. A base URL without `/v1/client` makes every request a 404.
23
+
24
+ ## Quick start
25
+
26
+ ```tsx
27
+ import { HermsClient, HermsInbox, HermsProvider } from '@hermesihq/react'
28
+ import '@hermesihq/react/styles.css'
29
+
30
+ const client = new HermsClient({
31
+ publicKey: 'hm_pk_prod_...',
32
+ apiBaseUrl: 'https://your-hermesi-host/v1/client',
33
+ getSubscriberToken: () => fetch('/api/hermesi-token').then((response) => response.text()), // calls *your* backend
34
+ })
35
+
36
+ export function AppHeader() {
37
+ return (
38
+ <HermsProvider client={client}>
39
+ <HermsInbox />
40
+ </HermsProvider>
41
+ )
42
+ }
43
+ ```
44
+
45
+ Two lines are easy to miss. The stylesheet import is required: **the bundle does not load its
46
+ own CSS**, so without it the panel renders with no styling. And `HermsProvider` must sit above
47
+ anything that uses a hook, or the hook throws an error that names the missing provider.
48
+
49
+ Create the client **once**, at module scope or in a `useMemo`, not on every render. A new
50
+ client each render closes and reopens the real-time connection each render.
51
+
52
+ ## `<HermsInbox />`
53
+
54
+ A real `<button>` with an accessible name that includes the unread count, and a panel with
55
+ arrow-key navigation, `Home` and `End`, and `Escape` to close.
56
+
57
+ | Prop | Default | |
58
+ |---|---|---|
59
+ | `placement` | `'bottom-end'` | `'bottom-start'`, `'bottom-end'`, `'top-start'` or `'top-end'` |
60
+ | `onItemClick` | | Called with the item when one is activated. Use it to drive your own router. Without it, an item with an `actionUrl` navigates there. Either way the item is marked read first. |
61
+ | `theme` | | `{ accent, radius }`, the two most re-themed values |
62
+ | `colorScheme` | `'auto'` | `'auto'` follows the visitor's OS setting; `'light'` or `'dark'` force one |
63
+ | `locale` | `'en'` | `'en'` or `'fr'` |
64
+ | `className` | | Added to the root element **and to the panel**, so one rule themes both |
65
+
66
+ Everything else is reachable by overriding these CSS custom properties: `--herms-color-accent`,
67
+ `--herms-color-accent-foreground`, `--herms-color-bg`, `--herms-color-border`,
68
+ `--herms-color-danger`, `--herms-color-hover`, `--herms-color-muted`, `--herms-color-surface`,
69
+ `--herms-color-text`, `--herms-color-unread-dot`, `--herms-font-family` and `--herms-radius`.
70
+ Set them on a rule that matches the `className` you pass: it is applied to the bell and to the
71
+ panel. The panel is rendered under `<body>`, not inside the bell, so a rule on an ancestor of the
72
+ bell does not reach it. The stylesheet is scoped under `.herms-inbox` and uses no Tailwind, so
73
+ your build cannot bleed into the widget or the reverse.
74
+
75
+ ## Hooks
76
+
77
+ Use these when the bell and panel are not what you want. Same data, no chrome.
78
+
79
+ | Hook | Returns |
80
+ |---|---|
81
+ | `useUnreadCount()` | `{ unread, unseen, isLoading }`, kept live |
82
+ | `useInbox({ status?, category? })` | `{ items, isLoading, isLoadingMore, error, hasMore, loadMore, markRead, markAllRead, archive, remove, refetch }` |
83
+ | `usePreferences()` | `{ preferences, isLoading, error, setPreference, reload }` |
84
+
85
+ They are a thin binding over the stores in `@hermesihq/js`, which is where the behaviour is
86
+ documented. The parts worth knowing here:
87
+
88
+ - The functions a hook returns keep the same identity for as long as it is mounted, so they
89
+ are safe in a `useCallback` or `memo` dependency list.
90
+ - Changing `status` or `category` reloads the list and keeps the previous rows on screen,
91
+ flagged as loading, until the new ones arrive. A response for the old filter that lands
92
+ late is discarded.
93
+ - Mutations go to the server first and patch local state with what it answered. A failed one
94
+ rejects.
95
+ - `usePreferences` does not update optimistically. Turning off a channel is a consent
96
+ decision, and showing it as done before the server agreed tells somebody they have opted
97
+ out when they may not have.
98
+ - A setting of `null` means no preference was expressed and the default applies. It is not
99
+ `false`, and treating them as the same would opt somebody out of something they never
100
+ declined. Categories with `isCritical: true` are always delivered: show them, but not as a
101
+ control.
102
+
103
+ ## Server rendering
104
+
105
+ Importing this package touches no DOM, and a component using the hooks renders its loading
106
+ state on the server. Nothing connects and no request is made until it mounts in the browser.
107
+
108
+ ## Also exported
109
+
110
+ `HermsClient`, `HermsApiError`, `HERMS_CHANNELS` and `decodeSubscriberTokenExp`, with their
111
+ types, are re-exported from `@hermesihq/js`, so an existing import from this package keeps
112
+ working. Every failed request rejects with a `HermsApiError`; quote its `requestId` in a
113
+ support conversation.
114
+
115
+ ## Not included
116
+
117
+ A rendered preference centre. The headless `usePreferences()` is here; the interface is not,
118
+ because it wants a design pass rather than an invention.
119
+
120
+ ## License
121
+
122
+ MIT