@hadoku/prefs-client
v1.1.27
Published
Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games), the one resolv
Maintainers
Readme
@wolffm/prefs-client
Client SDK for the hadoku unified preferences store. Wraps hadoku.me/prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a React usePrefs() hook.
See docs/planning/unified-prefs-2026-05-20.md for the architectural background.
Install
pnpm add @wolffm/prefs-client zod
# React consumers also:
pnpm add reactUsage
import { z } from 'zod';
import { createPrefsClient } from '@wolffm/prefs-client';
import { usePrefs } from '@wolffm/prefs-client/react';
const TaskPrefsSchema = z.object({
theme: z.string().optional(),
themeMode: z.enum(['simple', 'advanced']).optional(),
alwaysVerticalLayout: z.boolean().optional(),
});
export const taskPrefs = createPrefsClient({
appId: 'task',
schema: TaskPrefsSchema,
defaults: { theme: 'light', themeMode: 'simple' },
migrations: [
(blob) => {
if ('simpleMode' in blob) {
blob.themeMode = blob.simpleMode ? 'simple' : 'advanced';
delete blob.simpleMode;
}
return blob;
},
],
bootstrapToSessionStorage: { theme: 'hadoku-theme' },
});
function Settings() {
const { prefs, save, loading } = usePrefs(taskPrefs);
if (loading || !prefs) return null;
return (
<ThemeToggle
value={prefs.theme}
onChange={(t) => save({ theme: t }, { scope: 'device' })}
/>
);
}Key behaviours
- Cache: localStorage at
prefs-cache:{userId}:{appId}. Stale-while-revalidate. - Writes: 1s-debounced PUT. Multiple
save()calls within the window coalesce into one network call. - Conflicts: API-Always-Wins. On a 409 the SDK adopts the server blob and re-renders; the local patch is discarded, and the
save()(with an explicitexpectedVersion) orflush()waiting on it rejects withPrefsConflictError. expectedVersion: by default the SDK sends the last version it has seen (0 before the first read).save(patch, { expectedVersion: n })sendsn;{ expectedVersion: null }sends none — last write wins. Both send at once rather than debounced.- Cross-tab: BroadcastChannel keyed by
prefs:{userId}:{appId}propagates saves between tabs without a server roundtrip. - Anon → authed: call
client.promoteAnonPrefsToUser()after sign-in. If the server already has user-scope prefs (signed in on another device first), the server wins. - Dirty-field protection: a background refresh (or an incoming cross-tab broadcast) never overwrites a field with a save still pending/in-flight — it merges the server/remote blob with your unconfirmed local value instead. Otherwise a refresh landing between an optimistic write and its debounced PUT would visibly revert the change.
- FOUC bootstrap:
bootstrapToSessionStoragemirrors specific fields to sessionStorage so the inline<head>script can apply theme before React mounts. Mirroring only happens on an explicit localsave()by default — never on read/hydrate/refresh/broadcast-receive — so an app embedded under a host that owns the same sessionStorage key can't clobber it just by booting. PassmirrorBootstrapOnRead: trueto restore the old every-read mirroring for a key nothing else writes to.
Capacitor (native APK)
Storage is auto-detected: in a native Capacitor webview the SDK reads
the Capacitor.Plugins.Preferences global and uses it for the cache; in a
browser it uses localStorage. No configuration, no import, and no
@capacitor/preferences dependency added to web bundles — the detection
is a runtime global read, so web-only consumers are unaffected.
Requirements for the native side:
- The host APK must have
@capacitor/preferencesinstalled (so Capacitor registers the plugin global). The SDK does not bundle or import it. - The APK mints its own
hadoku_device_idin native storage, separate from the mobile browser's — so the native app and mobile Safari are two distinct devices by design.
You can still override with an explicit adapter (or force one in tests) via
createPrefsClient({ storage }). detectStorage() and capacitorStorage()
are exported if you want to resolve the adapter yourself.
Header-mode (service-to-service, no cookie)
Pass userKey when there's no hadoku_session cookie to ride (e.g. a CLI
or a native app that authenticates with a raw key instead of the cookie):
const client = createPrefsClient({
appId: 'task-mobile',
schema: TaskPrefsSchema,
userKey: process.env.HADOKU_API_KEY,
// storage auto-detects Capacitor; pass an explicit adapter only to override
});Platform rows (@wolffm/prefs-client/platform)
Rows that belong to no single app. Import the definition rather than retyping the appId, scope and schema, so the second app to need a setting finds the first one's row instead of inventing its own.
| Row | Fields | Scope | Owner |
| --------------------- | ------------------------ | ------ | ---------------------------------------------------------- |
| games (GAMES_ROW) | muted, volume (0..1) | device | every hadoku game: master sound, above a game's own levels |
| portfolio | theme | device | @wolffm/themes; do not write it from an app |
import { createPrefsClient } from '@wolffm/prefs-client';
import { GAMES_ROW, GAMES_DEFAULTS } from '@wolffm/prefs-client/platform';
const games = createPrefsClient({ ...GAMES_ROW, defaults: GAMES_DEFAULTS });
await games.save({ muted: true }, { scope: GAMES_ROW.scope });The profile picture is not a row here: it is ./avatar below, whose fallback is the
watchparty character.
Avatars (@wolffm/prefs-client/avatar)
One resolved avatar per hadoku user, chosen by the user in the fleet settings popout
(ConnectedSettings, @wolffm/task-ui-components): the picture's source and their character.
interface ResolvedAvatar {
source: 'sprite' | 'discord' | 'steam' | 'upload'; // what the picture is
url?: string; // present exactly when source !== 'sprite': square https image, >= 184px
sprite?: string; // the person's character, WHATEVER the source; absent = your default
}Browser, the signed-in user's own avatar:
import { createAvatarClient } from '@wolffm/prefs-client/avatar';
const { avatar } = await createAvatarClient().get(); // AvatarSettingsServer, everyone in a room, by userId, with the server's service-tier key:
import { resolveAvatars } from '@wolffm/prefs-client/avatar';
const byUser = await resolveAvatars(
{ serviceKey: process.env.SERVICE_KEY! },
userIds
);
// every requested id is present; unknown users resolve to { source: 'sprite', name: null }
byUser[id].name; // their live display name, or null for an unknown or closed accountNames come from here too. A server stores only userIds (IDENTITY_MODEL R12) and names
people, online or not, when it shows them: resolveAvatars(...)[id].name. Never keep a copy.
prefs-api reads the registry at most once a minute per isolate, so a rename shows within a
minute and a deleted account stops being named. Over HTTP it is POST /prefs/api/v1/_avatar/resolve
{ userIds } with a service-tier X-User-Key.
Rules for an app that renders avatars:
url→<img src>, nothing else: never fetch it server-side, never proxy it. Setreferrerpolicy="no-referrer". A URL can stop working (a changed Discord picture, an admin removal): onerror, fall back to the sprite.spriteis the character even when the picture is a Discord avatar: a game uses it as the piece and its colour. The app that owns the art builds its URL (watchparty owns art revisions; its publicGET /watchparty/api/characterslists them). Without one, use your own default.- Do not use Steam's or Discord's logos, and do not imply either endorses the app.
- Resolve on join and on reconnect; avatars change rarely.
resolveAvatarsbatches past 100 ids. - Source choice, character choice, linking (
linkInPopup: no page navigation), uploads and removal all happen in the settings popout. Don't build your own.
Avatar cosmetics (@wolffm/prefs-client/cosmetics)
What a person wears with their avatar: a name effect, a name gradient, a name font, an
avatar overlay (an image the person can move, turn and resize, or a drawn ring or glow), and a
message reveal for their chat lines. Items are earned or gifted, never bought; one per slot.
People equip them in the settings popout's avatar dialog. Every resolved avatar carries what is
worn as cosmetics (ResolvedAvatar.cosmetics, only items the person owns).
import {
ensureCosmeticsStyles,
colourStyle,
nameHtml,
avatarHtml,
messageHtml,
} from '@wolffm/prefs-client/cosmetics';
ensureCosmeticsStyles(); // once: the stylesheet, and the clean-up that ends a message reveal
const worn = person.cosmetics; // from resolveAvatars() or the owner's settings
row.style.cssText = colourStyle(character.color); // every text colour derives from this
row.innerHTML =
avatarHtml({ src: pictureUrl, size: 40, worn }) +
nameHtml(person.name, worn) +
messageHtml(text, live ? worn : undefined); // reveal only messages arriving liveRules for an app that renders them:
- Colour comes from the character. Put the character's colour in
--hc-baseon the row (colourStyle). Never give a name effect or gradient its own colour. - Reveal live messages only, never history or a scroll-back load. The reveal ends as plain text.
- Pause what is off screen: toggle
hc-idleon rows an IntersectionObserver reports hidden. - Leave room for overlays. An overlay may reach about a third of the avatar's size above it
and a quarter to each side (
PLACEMENT_ZONE); don't clip the avatar's box. - Images and fonts load same-origin from
/cosmetics/(hadoku_sitepublic/cosmetics); a font downloads only when someone wears it. - Adding an item:
COSMETICSincosmetics-contract.ts, its CSS incosmetics.ts, an image inpublic/cosmetics/art/.test/cosmetics.test.tsfails if any of the three is missing, and if a loop animates anything but transform or opacity.
