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

@tastic/profile

v0.11.0

Published

Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control sch

Readme

@tastic/profile

Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a selection popover, an inline-editable roster screen, and a reusable identity badge. Deliberately blind to anything app-specific: a control scheme, a stats bucket, or anything else keyed by profile id lives entirely on the host app's own side.

Why a separate Profile type at all?

Every app built on this package needs its own extra fields on a profile — a key/gesture scheme, a push/pull control mode, whatever's specific to that game. Baking any of those into this package's own Profile type would mean every OTHER app carries fields it doesn't use. Instead, this package's Profile is deliberately minimal:

interface Profile {
  id: string
  name: string
  color: string
  tag: string
  createdAt: number
  updatedAt: number
}

A host app extends this on its own (interface Profile extends BaseProfile { keyScheme: KeyScheme }) and structural typing carries the richer shape straight through every component here that only ever reads the base fields — no wrapper or adapter needed. The one place this needs a little more than plain structural typing is ProfilePicker's onSelect callback, which hands a profile back out — see its own doc comment for why that component is generic over your own Profile type.

What's in here

  • Profile — the base identity type described above.

  • isValidProfile / isValidTag — hand-rolled validators (no RN import, so they stay usable in plain-Node contexts like Jest without touching react-native-paper/@rific/auto-paper's own RN-touching barrels). isValidTag accepts up to MAX_TAG_LENGTH plain characters or exactly one emoji (including a ZWJ-joined compound), and rejects mixing the two. isValidProfile checks only this package's own base fields — a host app composes its own richer validator on top the same way it extends the type:

    function isValidProfile(v: unknown): v is Profile {
      return isValidBaseProfile(v) && isValidKeyScheme((v as Partial<Profile>).keyScheme)
    }
  • ProfileChip — a profile's identity at a glance: its own color as a filled circle, with its tag (or a generic fallback icon, if empty) in a contrast-safe color on top. filled={false} drops the background fill for a selected-row context that already fills its own background.

  • ProfilePicker — a name trigger + selection popover: pick an existing profile, fall back to a null/"guest" selection, or (if onManage is passed) navigate to a management screen. Selection only — creating, renaming, and deleting all live in ProfilesManager. Generic over your own Profile type (see above). Placement props (all optional, see "Fake-rotated frames" below): align (force a horizontal side), alignOverride (substitute the whole placement result), and rotation (forwarded to the automatic measurement). Also exports getProfilePickerContentSize(profileCount, hasManageRow?) — the popover's { width, height }, for sizing your own placement hook.

  • ProfilesManager — the roster screen itself: create/rename/recolor/retag/delete, editing inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no router, no back button) — a host app renders this as a routed screen's body and supplies its own persistence via profiles/onCreate/onSave/onDelete. onCreate/onSave both take a ProfileEditPatch ({ name, color, tag }) — exported from this package's own public API (it was a private, unexported interface inside ProfilesManager.tsx until ProfilesScreen below needed to reference it from its own props).

  • ProfilesScreen — the routed-screen shell around ProfilesManager: the container View, theme-derived background/foreground, a back button, and the commit-pending-edit-before-navigate composition every app in the fleet used to hand-roll on its own around ProfilesManager (see "Routed screens: ProfilesScreen" below). Same ProfileEditPatch-shaped profiles/defaultColor/onCreate/onSave/onDelete as ProfilesManager itself, plus onBack. Owns its own ProfilesManagerHandle ref internally — there's no ref prop here, since nothing outside it needs one anymore.

  • loadSharedProfiles / saveSharedProfiles / isSharedProfileStoreAvailable — an optional cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple Developer Team, same "com.apple.security.application-groups" entitlement value on every participating app). This only ever carries the base Profile shape above — a host app's own extension fields (a key scheme, a control scheme, anything else) are NOT part of it and have to keep living in that app's own local storage, keyed by profile id, merged onto the shared base roster at read time (falling back to some per-app default whenever a shared profile's id isn't in that app's own local extension table yet — e.g. it was created on a different app in the group). This split is deliberate, not a limitation to work around: it's this package's own base-fields-only Profile boundary (see above), just applied to storage instead of just to the TypeScript type.

    // Conceptual sketch of the pattern — see "Redux helpers for the shared roster" below for a
    // ready-made version of it.
    const GROUP_ID = 'group.com.yourteam.yourgames'
    const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
    const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
    // ...on create/update/delete, write the base fields back with saveSharedProfiles(GROUP_ID, nextBase)
    // and the extension field to the app's own local storage, same as today.

    Backed by a small native Expo Module (ios/TasticProfileModule.swift) wrapping UserDefaults(suiteName:) — iOS only (see expo-module.config.json's "apple"-only platform list); isSharedProfileStoreAvailable is false on Android/web and on any iOS build that hasn't run expo prebuild since adding this App Group's entitlement, so a host app always needs its own local-storage fallback path regardless of platform. Requires the App Group itself to be declared in each consuming app's own Expo config, e.g.:

    // app.json
    { "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }

Fake-rotated frames: ProfilePicker placement

ProfilePicker positions its popover with @tastic/hud's useAutoAlign, which measures the trigger with measureInWindow and compares that rect against the raw window. Inside a fake rotation (a View turned +-90deg while the OS stays portrait-locked) the rect is in true screen space but the frame is not, so the hook measures along the wrong axes and can pick the wrong side — flipping the list upward off-screen, or running it past the frame's bottom. Two optional props give you the same seam @tastic/hud's InlineColorPicker has:

| Prop | Type | Default | Effect | | --- | --- | --- | --- | | align | 'left' \| 'right' \| 'center' | — | Forces the horizontal side; always beats the placement's own align. | | alignOverride | ProfilePickerAlignResult ({ align, verticalAlign, maxHeight, measured, triggerRef }, the same shape as useAutoAlign's return / @tastic/hud's AlignResult) | — | Replaces the automatic placement wholesale: its align/verticalAlign/maxHeight/measured drive the popover, and its triggerRef is the ref attached to the measured trigger. | | rotation | 0 \| 90 \| -90 \| 180 | 0 | Forwarded to the automatic useAutoAlign call (needs @tastic/hud >= 0.8.0). Ignored when alignOverride is given. |

With none of them set the picker behaves exactly as it always has. Note rotation is never read from an ambient provider here (unlike @tastic/hud's own pickers) — this package doesn't touch @tastic/core at runtime — so pass it explicitly.

To supply your own placement, run a rotation-aware placement hook against the popover's real size, using getProfilePickerContentSize so the two can't drift:

const open = host.openId === `${idPrefix}-profile`
const { width, height } = getProfilePickerContentSize(profiles.length, !!onManage)
const placement = useMyRotatedAlign(open, width, height) // returns a ProfilePickerAlignResult

<ProfilePicker idPrefix={idPrefix} host={host} profiles={profiles} /* ... */ alignOverride={placement} />

getProfilePickerContentSize(profileCount, hasManageRow = false) returns { width, height }: 220 wide, and 8 * 2 + (profileCount + 1 + (hasManageRow ? 1 : 0)) * 36 tall (the null/"guest" row, one row per profile, and the Manage row when onManage is passed).

The Profiles screen's color swatch: useColorPickerAlign

ProfilesManager (and ProfilesScreen, which forwards it) renders @tastic/hud's InlineColorPicker for the swatch popover in an edit row, and owns the popover state internally — so the host cannot pass alignOverride to it directly. Inside a fake-rotated frame that popover has the same misplacement (and InlineColorPicker also feeds useAutoAlign its ambient rotation, double-rotating an already-transformed rect). Pass a placement hook as useColorPickerAlign:

<ProfilesScreen useColorPickerAlign={useFrameAutoAlign} /* ...the usual props */ />

It is called as useColorPickerAlign(open, contentWidth, contentHeight) — open is whether the swatch popover is showing, and the size is what InlineColorPicker itself computes (@tastic/hud's getInlineColorPickerContentSize for the default swatch list at the raw window width) — and must return a ProfilePickerAlignResult, which is handed to InlineColorPicker as its alignOverride (its triggerRef is the ref that gets measured). It is a hook, not a plain function: give it a stable identity for the life of the mount (a module-level function), since switching it at runtime remounts the swatch. Omit it and behaviour is identical to before. Needs @tastic/hud >= 0.12.0 to have any effect — older hud has no alignOverride on InlineColorPicker and ignores it.

Routed screens: ProfilesScreen

ProfilesScreen is the routed-screen shell every app in the fleet was hand-rolling on its own around ProfilesManager: a container View, theme-derived bg/fg, a headerLeft back button, and the two-line managerRef.current?.commitPendingEdit(); onBack() composition every app's own back-button handler used to write out by hand (see ProfilesManager's own commitPendingEdit doc above for why that flush matters on a router that doesn't genuinely unmount a popped screen). It owns that ref itself — there's no ref prop on ProfilesScreen, since nothing outside it needs one any more.

Same profiles/defaultColor/onCreate/onSave/onDelete props as ProfilesManager — still ProfileEditPatch-shaped, still deliberately not widened to your own richer CreateProfileInput; compose any extra required fields (a key/control scheme, whatever's specific to your game) in your own closure at the call site, exactly as you already would calling ProfilesManager directly — plus onBack. fg/fgMuted/bg/cardBg/titleVariant/colorPreview are all independently optional and forward straight through to the internal ProfilesManager (bg is new here — ProfilesManager itself has no bg, since it doesn't own a container View of its own).

A whole routed screen, wired to your own store, is usually this small:

// app/profiles.tsx
import { defaultColors } from '@rific/auto-paper'
import { ProfilesScreen } from '@tastic/profile'
import { router } from 'expo-router'

import { useGameStats } from '@/hooks/useGameStats'
import { useProfiles } from '@/hooks/useProfiles'

export default function Profiles() {
  const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
  // Only for wiring onDelete below — clears a deleted profile's own stats record so it doesn't
  // stick around as an orphan once the profile itself is gone.
  const { removeProfileStats } = useGameStats()

  return (
    <ProfilesScreen
      profiles={profiles}
      defaultColor={defaultColors[0].value}
      onCreate={(patch) => createProfile(patch)}
      onSave={(id, patch) => updateProfile(id, patch)}
      onDelete={(id) => {
        deleteProfile(id)
        removeProfileStats(id)
      }}
      onBack={() => router.back()}
    />
  )
}

useProfiles/useGameStats are your own app's hooks, not exports of this package — profiles is whatever Profile[] (or your own extended shape) you already keep in Redux/context/state, and createProfile/updateProfile/deleteProfile are however you already persist it. onBack can be anything with no arguments — router.back(), a safeBack()-style helper that no-ops with no back stack behind it, navigation.goBack() — ProfilesScreen only ever calls it after flushing any in-progress inline edit first.

Redux helpers for the shared roster

The cross-app-roster sketch above is a pattern every @tastic game was hand-rolling on its own (a roster reducer, a per-profile extension table, the initial-load decision, a foreground-resync listener, a per-seat selection reducer). These five pieces are that logic extracted into reusable, Redux-shaped building blocks — plain TypeScript, no dependency on redux/react-redux themselves, so a host app plugs the reducer(s) into whatever store it already has:

  • profilesActions / profilesReducer / createProfileRecord — the base roster as a reducer: add/update/remove/setAll actions, plus createProfileRecord(input) to mint a complete Profile (id + timestamps) ready to dispatch via profilesActions.add.
  • createProfileExtensionSlice<TExtension>(namespace) — a factory for a host app's own per-profile extension fields (a control scheme, a key scheme, ...): returns a { actions: { set, remove }, reducer } pair over a plain Record<profileId, TExtension>, namespaced so more than one extension slice in the same app never collides. Not auto-cleared when profilesReducer removes a profile — an app already dispatches its own cleanup action(s) on delete, so this stays a self-contained reducer rather than new cross-slice wiring.
  • resolveInitialProfiles(groupId, localFallback) — the one-time initial-load decision: shared store unavailable → localFallback verbatim; shared store empty → seed it from localFallback; otherwise the shared roster always wins over a possibly-stale local snapshot.
  • useSharedProfilesSync({ groupId, onRemoteChange }) — keeps a Redux-backed roster in sync for the rest of the app's lifetime: call the returned syncToShared(next) right after a local roster change to mirror it out, and onRemoteChange fires with a freshly-loaded roster whenever the app returns to the foreground (skipped while a syncToShared write is still in flight, so a remote refresh can't clobber a write that hasn't landed yet).
  • createProfileSelectionSlice<TSeat>(namespace, defaultState) — a factory for the other per-seat piece of state: which profile id each seat currently has selected (parallel to createProfileExtensionSlice above, but keyed by seat instead of by profile id). Returns a { actions: { select, clearProfile, pruneToRoster }, reducer } pair over a plain Record<TSeat, string | null>. Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't exist at runtime), so callers pass their own defaultState — the same literal object (e.g. { 1: null, 2: null }) every app already declares today. select({ seat, profileId }) sets one seat's selection directly; clearProfile(profileId) walks every seat and nulls out any that currently points at that id — call it when a profile is deleted so no seat is left referencing a ghost id. pruneToRoster(ids) nulls every seat whose id is no longer in the given roster; dispatch it after both profilesActions.setAll paths (the initial resolveInitialProfiles reconcile and onRemoteChange), since a sibling app can delete a profile without this app's own delete ever running, and it returns the same state object when nothing changes. Always local, like the guest/CPU/override colors below, and never part of the shared App Group roster, since who's selected on this device is naturally per-app, per-device state.

Muted profile colors (ProfileColorProvider)

An app that draws profile colors through a transform (Solitaire blends them toward its muted card-back palette) wraps its tree once:

import { ProfileColorProvider } from '@tastic/profile'

const preview = useCallback((hex: string) => blendTowardCardBack(hex, dark), [dark])
<ProfileColorProvider preview={preview}>{children}</ProfileColorProvider>

Every ProfileChip then draws preview(profile.color) (contrast computed against that), ProfilePicker's selected row fills with it, and ProfilesManager/ProfilesScreen use it for their swatch previews unless an explicit colorPreview prop is passed. Components from other packages that render a ProfileChip (e.g. @tastic/hud's AchievementUnlockList) pick it up for free. Only what's drawn changes: saved colors, onCreate/onSave values and selection are untouched. useProfileColorPreview() returns the provider's function (or undefined), and useProfileDisplayColor(hex) resolves one color. Without a provider nothing changes.

Per-seat color persistence

A loadout/lobby screen's color for a given seat is never just "the selected profile's saved color" — a seat can be a guest (no profile selected), a CPU opponent, or a profile the player manually recolored for a clash-swap without touching the profile itself. Every @tastic game (AirHockey, BoxHockey, Pong, LightCycles, Snake) ends up needing the same three cooperating pieces of state to cover all three cases. This is the established pattern every game should follow — it was previously undocumented at the package level, which is exactly why two of the five apps (AirHockey, Snake) didn't pick up the third piece below until after they'd already shipped without it.

  • lastGuestColor — a Record<Seat, string>: what a seat's color was the last time it was a guest (no profile selected). Written only when a guest seat's color changes; a profile-selected seat never reads or writes it.
  • lastCpuColor — the same idea for the CPU slot, as a single remembered string rather than a per-seat record (a CPU opponent only ever occupies one fixed seat in every shipped game so far).
  • profileOverride — a Record<Seat, string | null>: a manual recolor or clash-swap landing on a seat that currently has a profile selected. null means "no override — track the profile's own saved color live." Cleared only when that seat's own profile selection genuinely changes, never by merely leaving and returning to the screen.

None of these three are exports of this package today. lastGuestColor/lastCpuColor predate it entirely, and profileOverride is still hand-rolled per app — typically alongside createProfileSelectionSlice's own selection state, in the same seat-scoped slice (see e.g. BoxHockey's src/redux/seatColorsSlice.ts) or folded into the app's own gameSlice.ts when there's no dedicated slice to put it in (see Snake's src/redux/gameSlice.ts). This section exists so a new app's author reads the convention here first, rather than hand-rolling something that quietly can't interop with the rest of the fleet.

The resolution order at a loadout/lobby screen, for one seat:

const color = profile
  ? (profileOverride[seat] ?? profile.color) // profile selected: override wins, else the profile's own color
  : isCpu
    ? lastCpuColor // CPU slot: its own remembered color
    : lastGuestColor[seat] // guest seat: that seat's own remembered color

lastGuestColor/lastCpuColor are only ever consulted when no profile is selected for that seat at all — a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active, so switching a seat back to "guest" later still recalls whatever guest color it had before a profile was ever selected there.

Install

npm install @tastic/profile

Published to the public npm registry via tag-based CI (OIDC trusted publishing — see npm run release:patch/:minor/:major).

Peer dependencies

react, react-native, react-native-paper, react-native-safe-area-context, @rific/auto-paper (getContrastColor, useAutoPaperTheme), @rific/feedback-press (TouchableRipple), @rific/focus-chain (useFocusChain), @rific/toaster (useToast), @tastic/hud (InlineColorPicker, PopoverBody, PopoverHost, useAutoAlign, usePopoverHost), @tastic/core (a peer of @tastic/hud itself) — none of these are bundled, so use whatever versions your app already has.

expo-modules-core is an optional peer — only resolved by loadSharedProfiles/saveSharedProfiles for the native App Group bridge described above; safe to omit entirely on an Android/web-only consumer.