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/hud

v0.19.0

Published

Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen

Readme

@tastic/hud

Visual component kit for local-multiplayer React Native games - inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs. Every interactive piece stays scoped to one player's own zone on a shared screen, so it never blocks the rest of the screen the way a full-screen modal would.

Depends on @tastic/core for live device- rotation tracking (see Rotation below) - every popover/dialog here reads it directly, so it stays legible even rotated 180° inside a @tastic/split-screen zone (that package's own two-player layout engine is what actually owns the rotated zone; this kit and split-screen don't depend on each other, they just both sit on @tastic/core).

Why not a modal?

A full-screen Dialog blocks the whole screen. In a two-player lobby that's a non-starter - player 2 can't touch anything while player 1's color picker is open. Every popover here renders inline, as an absolutely-positioned sibling of its own trigger, so it never blocks anything outside its own corner of the screen.

What's in here

  • usePopoverHost(): tracks which one popover (by id) is open within a group, so opening one closes any sibling already open in the same group.
  • PopoverBody: the low-level popover shell (position, caret, alignment). SectionedDropdown and InlineColorPicker are built on it.
  • useAutoAlign: measures a trigger's actual on-screen position and picks whichever alignment (left/right/center, above/below) keeps a popover from overflowing a screen edge - including a maxHeight for the caller to cap content and scroll when neither direction has enough room.
  • SectionedDropdown: a popover holding any mix of single-select ("pick one", radio-style) and multi-select ("pick some", checkbox-style, optional all/clear footer) sections, divided by rules.
  • InlineColorPicker: swatch-grid color popover, columns auto-clamped to 4 or 5 (the default swatches list is exactly 20 colors, so that's the only range that fills every row completely), with an optional "taken" color (shown disabled, or swappable). The trigger itself is a size- configurable circle showing either an icon or a short tag (e.g. a saved player identity's own initial or emoji) - tag renders at a larger ratio automatically when it's an emoji, since an emoji glyph reads visually smaller than a bold letter at the same font size.
  • TriggerGauge: decorative ring of tick marks around a trigger, showing which option(s) are active without opening the popover.
  • ReadyButton: a per-player or standalone ready toggle.
  • PlayerSetupPanel: one panel per player slot in a local-multiplayer loadout/lobby screen: a name header (a saved-profile switcher for a human seat, a CPU-difficulty LabeledDropdown for a non-human one, or a plain label when neither applies), a color picker (InlineColorPicker) optionally paired with a caller-rendered secondPicker slot, and a ReadyButton for human seats. Ported from five apps' own near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx (AirHockey, BoxHockey, Pong, LightCycles, Snake), which had all independently converged on this exact skeleton. See "The secondPicker slot" below before wiring up that one prop.
  • CornerActionButtons: Back (top-left) + Settings (top-right) icon buttons fixed at a screen's rotated corners. This isn't just a loadout/lobby-screen convention - it's the right default for any screen with top-corner icon buttons, home screens included: every app in the fleet still hand-rolls its own trophy/cog IconButton pair on the home screen specifically instead of reusing this component there, a real (if minor) missed-reuse case worth fixing next time one of those screens gets touched. Not the right fit for a screen where the top corners fall inside one player's own rotated zone (two-player face-to-face) - see SharedActionBand for that case instead. Each button's touch target is its own 40pt box. Pass the optional hitSlop (a number or Insets, forwarded to both buttons) to grow it without changing anything visible: hitSlop={4} makes a 48pt target, and the glyphs and press ripple stay as they are. It needs @rific/feedback-press 0.13.0 or later, whose IconButton makes hitSlop reach past Paper's clipped box (older versions silently ignore it). Omit it to keep the 40pt target. Leave room for it: the slop reaches past each button's box, and where it overlaps another touch target, whichever is drawn on top gets the press.
  • StaggeredWord / getStaggeredWordDuration - the letter-by-letter stagger-in animation every app's own hero-title screen had hand-rolled independently, identical down to the same 45ms stagger, 320ms per-letter duration, and back-ease overshoot. Renders one word/line at a time - a caller stacking several words (AirHockey's "AIR"/"HOCKEY", LightCycles' "Light"/"Cycles") renders more than one StaggeredWord and threads startIndex through so the whole wordmark reads as one continuous cascade instead of each word restarting its own stagger from zero. getStaggeredWordDuration(letterCount) is the matching total-duration formula, exported as a plain function rather than something read off a ref or hook, so a caller's own follow-on animation (a paddle rally, a bouncing puck, a looping trail) can compute once, outside render, when the lettering has mostly landed.
  • PressAwayOverlay: an invisible full-bleed tap-catcher for press-away-to-close. This is the one piece that needs care in a split-screen layout - see below.
  • BaseSettingsDialog / BaseStatsScreen / ConfirmDialog - shared shells for the settings sheet, an achievements/stats screen, and a plain confirm-or-cancel dialog, each factored out after being independently copied into several games. See Rotation below for how each of these (plus SectionedDropdown/InlineColorPicker) handles rotating inside a @tastic/split-screen zone.
  • OverlayCard / OverlayBackdrop / useOverlayCardColors - the card and dim behind ConfirmDialog and the how-to-play guide, in the app's own theme colors and blurred when the player's Blur setting allows it. See Overlay cards below.
  • DialogHeader / useCompactDialog - the one header every dialog opens with (an icon over a bold title in the theme's primary), and the rule for its compact form. See Overlay cards below.

Rotation

SectionedDropdown, InlineColorPicker, BaseSettingsDialog, and BaseStatsScreen all read the device's live rotation themselves, via @tastic/core's useRotation() - you don't need to pass anything for a popover/dialog to read correctly no matter which way the phone is held, as long as your app mounts @tastic/core's OrientationProvider (or @tastic/split-screen's AccelerometerOrientationProvider, a compat alias for the same thing) somewhere above it.

Each of the four also still accepts an explicit rotation prop, which overrides the ambient read when present - reach for it only when a specific instance genuinely needs to differ from the live app-wide rotation (e.g. a fading, lagged panelLayout value mid-transition, rather than the raw live reading - see @tastic/split-screen's own DualZoneLayout docs for why that lag exists).

This matters more than it might look: useAutoAlign (what SectionedDropdown/InlineColorPicker use to pick left/right/above/below) measures a trigger via measureInWindow, which reports the trigger's pre-transform layout position - confirmed against a real rotated screen, not just reasoned about. Inside a FakeLandscapeView-rotated zone (or any other ancestor rotated the same way - a bare CSS transform: rotate(), not a real OS-level orientation change), that pre-transform position can be nowhere near where the trigger actually is on screen, which is exactly the bug this rotation-awareness fixes: without it, a popover can pick an alignment for where its trigger used to be before rotating, clipping off the edge it's actually now near instead of the one it was near.

BaseSettingsDialog lays out in its own turned frame

The settings sheet doesn't rotate its card in place. It renders through one Paper Portal into a RotatedFrame (below) turned by its rotation, sized to the turned footprint, with rotated safe-area insets and its own Portal.Host, and the Dialog portals into that host. So at +/-90 the sheet's maxHeight: '90%' is measured against the phone's short edge and the whole list scrolls, instead of running off the screen with its top row (Lock Orientation) out of reach. The frame is pass-through and an identity frame at 0, so nothing changes in portrait. Pass rotation={0} when the dialog already sits under a Portal.Host inside a turned frame of your own.

This needs @rific/auto-paper 0.12.2 or later: the Dialog's own body now renders at the portal host, and only 0.12.2+ mounts that host inside auto-paper's own contexts (blur, Reanimated, defaults).

RotatedFrame

The frame itself, exported for a screen or overlay that should follow the phone as one piece:

import { RotatedFrame } from '@tastic/hud'

<RotatedFrame portalHost rotatedSafeArea maxContentWidth={640}>
  <StatsList />
</RotatedFrame>
  • rotation: the angle to turn by; defaults to the live useRotation().
  • rotatedSafeArea: re-provide the safe-area insets remapped onto the frame's own edges, for content that reads useSafeAreaInsets() itself. Leave it off for content that rotates insets on its own.
  • portalHost: mount a Paper Portal.Host inside the frame, so dialogs lay out and turn with it (tell them rotation={0}). Must be static per call site: flipping it remounts the contents.
  • passThrough: let touches fall through the frame's own boxes, for a frame laid over other content.
  • maxContentWidth: cap the centred content column, in the wide (landscape) footprint only.

The tree is identical at every angle, so turning the phone never remounts what's inside. Tap-driven content only: react-native-gesture-handler's deltas are untransformed, so never put a gesture-tracked board inside it.

The press-away pattern (read this before wiring it up)

A single full-screen PressAwayOverlay works fine for a one-player screen. It does not work for a two-player split screen: if player 1's press-away covers the whole screen, then any tap on player 2's side - even one that has nothing to do with player 1 - falls through and closes player 1's popover. That defeats the point of letting both players drive their own settings at once.

The fix is two overlays with an asymmetric relationship, not two independent halves:

const p1Host = usePopoverHost()
const p2Host = usePopoverHost()

return (
  <View style={styles.container}>
    {/* Player 1's overlay covers the ENTIRE screen. This is correct even though player 1 visually
    only owns "their side" - any shared-settings row that reads as "player 1's" (see your own
    layout) may not stay confined to a literal half of the screen, and this overlay needs to cover
    everywhere player 1 might have something open. */}
    <PressAwayOverlay active={p1Host.openId !== null} onPress={p1Host.close} />

    {/* Player 2's overlay is scoped to just their own zone (half the screen, whichever side
    they're on - see @tastic/split-screen's panelLayout for how to size this) - and, being a later
    sibling, it paints on top of player 1's overlay within that rect, so a tap there is always
    player 2's to own.

    Critically: this has to mount whenever EITHER host is open, not just p2Host. If it only mounted
    for p2Host, then closing player 2's popover would unmount it - exposing player 1's full-screen
    overlay underneath for the rest of that render, and the next tap anywhere on player 2's side
    (even if player 2 has nothing open) would fall through and close player 1's popover instead.
    Mounting it any time p1Host is open too "shields" player 2's zone from player 1's overlay
    unconditionally; its onPress (p2Host.close) is just a harmless no-op when p2Host is already
    closed. */}
    <PressAwayOverlay active={p1Host.openId !== null || p2Host.openId !== null} onPress={p2Host.close} style={styles.p2Zone} />

    {/* Real content goes after both overlays - plain paint order (later siblings on top) is what
    keeps every real trigger/button directly tappable; only genuinely empty space falls through to
    the overlays above. */}
    <Player1Panel host={p1Host} />
    <Player2Panel host={p2Host} style={styles.p2Zone} />
  </View>
)

The general rule for N players: the "owner" of the broadest zone (usually whoever's panel absorbs your shared/global settings) gets the whole-screen overlay; every other player's zone-scoped overlay must stay mounted (shielding, if not actually closing anything) whenever any host with broader reach is open - not just their own.

Integrating BaseSettingsDialog

BaseSettingsDialog is a shared shell, not a drop-in screen - every one of the five consuming apps (AirHockey, BoxHockey, Pong, LightCycles, Snake) wraps it in its own local src/components/SettingsDialog.tsx adapter rather than rendering it directly from a route. The adapter's whole job is to bridge whatever local settings mechanism the app uses onto this package's fixed prop contract - it should carry no UI of its own beyond that plumbing.

That local mechanism genuinely varies per app. AirHockey/BoxHockey/LightCycles thread a settings/setSettings pair in as props from a caller-owned GameSettings object (each adapter's own three call sites already have one in scope for other reasons). Pong reads that same shape off its own useGameSettings() hook internally instead of taking it as props. Snake - the one app in the fleet with real Redux Toolkit state - reads lockOrientation/deferBottomEdgeGestures via useSelector and writes them back via dispatch(gameActions.setLockOrientation(...)) directly inside the adapter, with nothing threaded through props at all. BaseSettingsDialog doesn't care which of these an app uses: sound, haptics, appearance, and update-checking are identical across every app and live entirely inside this package now, so lockOrientation/onLockOrientationChange, deferBottomEdgeGestures/onDeferBottomEdgeGestures and keepAwake/onKeepAwakeChange are the only settings an adapter actually needs to source itself, however it does that. Each pair is optional: omit one and its row doesn't show. The Keep Awake row only toggles the value; the app keeps the screen awake itself while it's on (for example with expo-keep-awake on its match screen).

Every adapter also passes version={release.otaVersion} (so the dialog's own update-check button knows what it's currently running) and onUpdateError, wired to a real useToast() call. Wire this one up - an adapter that leaves onUpdateError unset silently swallows a failed update check instead of surfacing it anywhere, which is a real bug this fleet actually shipped in one app before being caught and fixed. If your app has no toast system, route it to whatever your own error-surfacing convention is; just don't leave it as a no-op.

A minimal adapter, following the shape every current one uses:

export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, setSettings }: SettingsDialogProps) {
  const { error } = useToast()

  return (
    <BaseSettingsDialog
      visible={visible}
      onDismiss={onDismiss}
      rotation={rotation}
      version={release.otaVersion}
      lockOrientation={settings.lockOrientation}
      onLockOrientationChange={(value) => setSettings({ lockOrientation: value })}
      deferBottomEdgeGestures={settings.deferBottomEdgeGestures}
      onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })}
      keepAwake={settings.keepAwake}
      onKeepAwakeChange={(value) => setSettings({ keepAwake: value })}
      onUpdateError={(message) => error('Update check failed', message)}
    />
  )
}

Overlay cards (OverlayCard, OverlayBackdrop, useOverlayCardColors)

ConfirmDialog and HowToPlayDialog draw their card and the dim behind it with these, and a game's own overlay (a result banner, a game-over card) should too, so every card in the fleet looks the same:

import { DialogHeader, OverlayBackdrop, OverlayCard, overlayActionStyles, useCompactDialog, useOverlayCardColors } from '@tastic/hud'

function GameOverCard({ onRestart }: { onRestart: () => void }) {
  const { fgMuted } = useOverlayCardColors()
  const { compact, onLayout } = useCompactDialog()
  return (
    <OverlayBackdrop onLayout={onLayout}>
      <OverlayCard accessibilityViewIsModal>
        <DialogHeader icon='flag-checkered' title='Game Over' compact={compact} />
        <Text style={{ color: fgMuted }}>Score 42</Text>
        <View style={overlayActionStyles.actions}>...</View>
      </OverlayCard>
    </OverlayBackdrop>
  )
}
  • Colors come from the app's theme. Text is onSurface (fg) and onSurfaceVariant (fgMuted), and the dim is the theme's backdrop, the same scrim auto-paper's own Dialog uses. The card is the Settings sheet's surface: auto-paper's BlurView with no elevation (the theme's plain surface, no wash of the primary), no border, and the Dialog's corner radius. So every card and the Settings sheet look like one family, in each game's palette and light/dark mode.
  • Every dialog opens with DialogHeader. A 64pt icon over a large bold title, both in the theme's primary (pass color for another role, as ConfirmDialog does with danger when destructive). ConfirmDialog, the how-to-play guide (titled "How to Play") and BaseSettingsDialog all use it; an app's own cards should too. With compact the icon shrinks to 32pt and sits beside a smaller title. useCompactDialog(rotation?) decides it: put its onLayout on the OverlayBackdrop, and it is compact when the space the dialog lays out in is under COMPACT_DIALOG_HEIGHT (528pt) along the card's own vertical axis, so a phone held sideways. One-button dialogs end in a single contained primary button (overlayActionStyles.actionButtonSingle), the Settings sheet's Done included.
  • Blur is automatic. The card blurs what's behind it when the player's Blur setting is on and the app hands expo-blur to auto-paper's Provider (expoBlur={ExpoBlur}). Otherwise it paints that raised surface color instead: on Android (BlurView needs a blur target there, which a card has none of), in an app without expo-blur, or with Blur off. Beyond passing expoBlur, a game wires nothing for this.
  • The Blur setting lives in BaseSettingsDialog. Its APPEARANCE section shows a Blur row under the appearance picker that flips auto-paper's own blur theme setting, which the settings sheet, Paper menus and these cards all follow. The row only shows where blur can actually happen: not on Android, not in an app that never passed expoBlur, and not with hideAppearance.
  • getOverlayCardColors is gone. It returned the old fixed black/white card (#F2F2F2/#111111), which ignored the theme and never blurred. Use useOverlayCardColors for a card's text and edges.

Zone-aware popovers (useZoneClampedAlign)

useAutoAlign - and everything built on it, including SectionedDropdown, InlineColorPicker, and LabeledDropdown - only knows about the full device window edge. It has no concept of a @tastic/split-screen zone boundary: the shared neutral band between two players' halves, or the line where one player's zone stops and the other's begins. A popover host that lives entirely inside one player's zone (rather than centered app-wide, the way BaseSettingsDialog is) can end up choosing an alignment that opens toward the shared band, or overflows past the zone boundary into the other player's half - not because useAutoAlign measured wrong, but because the window edge it correctly measured against isn't the boundary that actually matters there.

LightCycles hit this for real - its PlayerSetupPanel-hosted color/CPU-difficulty pickers live inside a rotated split-screen zone - and originally built its own local fix for it rather than waiting for this package to grow zone-awareness. That fix is now useZoneClampedAlign, a first-class export of this package: it wraps useAutoAlign's result and re-derives the vertical decision (and maxHeight) against the zone's real edge, via @tastic/split-screen's useZoneBounds(), instead of the window edge - including correcting for a 180°-rotated zone inverting PopoverBody's local 'above'/'below' labels relative to their real-world direction. @tastic/split-screen is a peer dependency of this package because of this hook specifically - it's only ever imported if you actually call useZoneClampedAlign.

Same signature and return shape as useAutoAlign (open, contentWidth, contentHeight in; align, verticalAlign, maxHeight, measured, triggerRef out - exported as AlignResult, see below) so it's a drop-in replacement anywhere useAutoAlign is called directly. Outside a @tastic/split-screen zone (useZoneBounds() returns null), it's a pure passthrough to useAutoAlign's own decision - safe to reach for even in a component that might or might not end up inside one:

const auto = useZoneClampedAlign(open, POPOVER_WIDTH, contentHeight)

LabeledDropdown, SectionedDropdown, and InlineColorPicker all accept the result of the above via an alignOverride prop (typed AlignResult, also exported), substituting it wholesale for their own internal, zone-blind useAutoAlign call:

<SectionedDropdown id='p1-arena' host={host} sections={sections} accentColor={color} mutedColor={muted} dark={dark} alignOverride={useZoneClampedAlign(open, MENU_WIDTH, contentHeight)} />

ControlSchemePicker forwards both alignOverride and rotation straight through to the SectionedDropdown it renders internally, so the same call works there too. PlayerSetupPanel's own internal color/CPU-difficulty pickers aren't reachable this way (they're not a ReactNode slot your own code renders - see "The secondPicker slot" below), so it exposes colorAlignOverride and cpuDifficultyAlignOverride props instead, forwarded straight to InlineColorPicker's and LabeledDropdown's own alignOverride internally. Computing open for either one needs that picker's own popover id, which PlayerSetupPanel derives internally from idPrefix - call getColorPopoverId(idPrefix)/getCpuDifficultyPopoverId(idPrefix) (both exported) rather than guessing or hardcoding the naming convention yourself:

const cpuOpen = host.openId === getCpuDifficultyPopoverId('p2')
const cpuAlign = useZoneClampedAlign(cpuOpen, LABELED_DROPDOWN_POPOVER_WIDTH, getLabeledDropdownContentHeight(cpuDifficultyOptions.length))

<PlayerSetupPanel idPrefix='p2' host={host} cpuDifficulty={difficulty} cpuDifficultyOptions={cpuDifficultyOptions} onCpuDifficultyChange={setDifficulty} cpuDifficultyAlignOverride={cpuAlign} /* ...the rest of your usual props */ />

For any other popover-hosting component that might sit inside a split-screen zone, call useZoneClampedAlign directly in place of useAutoAlign.

The secondPicker slot (read this before wiring one up)

PlayerSetupPanel's secondPicker is a caller-rendered ReactNode slot next to the built-in color picker, not a fixed prop shape - the five apps this component was extracted from each wanted a genuinely different second control there (a ControlSchemePicker, a raw SectionedDropdown, or nothing at all), so rather than this package baking in one shape most consumers don't need, the panel just renders whatever secondPicker is given, wherever it's given.

If whatever you slot in opens its own popover, pass its popover id as secondPickerId too:

<PlayerSetupPanel
  idPrefix='p1'
  host={sharedHost}
  color={color}
  onColorChange={setColor}
  swatches={swatches}
  isHuman
  secondPicker={<ControlSchemePicker id='p1-scheme' host={sharedHost} value={scheme} onChange={setScheme} />}
  secondPickerId='p1-scheme'
  dark={dark}
/>

Skipping secondPickerId when your slotted picker does open a popover is easy to get wrong, and it fails silently rather than throwing: PlayerSetupPanel uses it to decide whether its own pickerRow needs the elevated z-index that lets a popover escape the row's bounds and paint above a later sibling (the ReadyButton beneath it, or another panel next to it - see pickerRowOpen in the component's own source). Without secondPickerId, the panel has no way to know your picker's popover is open, so that elevation never kicks in, and your popover can silently render underneath sibling content instead of on top of it. If your secondPicker never opens a popover of its own (a plain toggle, static content, or nothing at all), omit secondPickerId - there's nothing for it to track. And if that slotted picker's own popover lives inside a split-screen zone, see "Zone-aware popovers" above for how to wire alignOverride/rotation into whatever you construct there.

Quit confirmation (useQuitConfirmation)

Captures the onBackPress + <ConfirmDialog> "Quit Match?" pattern every fleet game.tsx used to hand-wire on its own (Snake, AirHockey, BoxHockey, Pong, LightCycles): interrupt backing out of a match with a confirmation only when there's progress worth losing, otherwise back out immediately.

function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack: () => void): QuitConfirmation

interface QuitConfirmation {
  confirmVisible: boolean
  requestBack: () => void
  cancelBack: () => void
}

Wire confirmVisible/cancelBack straight to <ConfirmDialog visible={...} onCancel={...}>, and requestBack to the back button's onPress. The hook deliberately doesn't own or render the <ConfirmDialog> itself - title/message/icon/labels/rotation stay authored at each call site (a score pairing in most apps, a round-history pip row in LightCycles), matching this package's established "hooks return data, JSX stays at the call site" convention (usePopoverHost, useAutoAlign, useZoneClampedAlign all do the same).

hasProgress is a lazy () => boolean getter, not a plain boolean - this is a load-bearing correction, not a style choice. A plain-boolean argument forces the caller to compute it every render from state that, in several apps, doesn't exist yet at that point in the render. AirHockey/BoxHockey/Pong all fold this hook's own confirmVisible into a paused = settingsOpen || confirmVisible that gates useGameState(paused) - and useGameState's return value (scores, lives, bricks) is exactly what hasProgress needs to read. A plain boolean here would create a real circular render dependency: confirmVisible → paused → useGameState(paused) → {scores/lives/bricks} → hasProgress → confirmVisible. The lazy getter breaks the cycle: hasProgress is only invoked inside requestBack, at tap time, well after useGameState has already produced that render's state - the getter's closure just reads whatever scores is bound to by then, the same way a hand-rolled onBackPress closure always did before this hook existed:

// AirHockey's game.tsx - hasProgress reads `scores`, a value useGameState (below) hasn't produced
// yet at the point useQuitConfirmation is called. That's fine: the getter is only ever called from
// inside requestBack, well after this render has finished and `scores` is a real value.
const [settingsOpen, setSettingsOpen] = useState(false)
const { confirmVisible, requestBack, cancelBack } = useQuitConfirmation(() => scores[0] > 0 || scores[1] > 0, safeBack)
const paused = settingsOpen || confirmVisible

const { state } = useGameState(board, scoreToWin, friction, paused)
const { scores } = state

// ...

<IconButton icon='arrow-left' onPress={requestBack} />

<ConfirmDialog
  visible={confirmVisible}
  title='Quit Match?'
  message={`${scores[0]} - ${scores[1]}`}
  confirmLabel='Quit'
  cancelLabel='Cancel'
  onConfirm={safeBack}
  onCancel={cancelBack}
/>

A game with no such circular dependency (Snake reads humanScore/opponentScore, LightCycles reads roundHistory.length) still passes a getter - () => humanScore > 0 || (opponentScore ?? 0) > 0; it's just a plain wrapper around an expression that could have been a bare boolean there; the signature stays uniform across every call site rather than special-casing the apps that don't currently need the deferred read.

Achievements catalog (AchievementCatalogSection, ActivityStatSection)

Two presentational components for an achievements/stats screen, designed to pair with @tastic/achievements's own getAchievementCatalogRows - the row-computation logic (reading ACHIEVEMENT_CATALOG, unlockedAchievements, and stats) lives in that headless package (no react-native/Paper deps of its own); the two components that actually render the result live here. Mirrors this package's existing AchievementRow (hud, presentational, fully-precomputed props) / achievement-engine-shaped (pure data) boundary.

AchievementCatalogSection - an "ALL ACHIEVEMENTS" heading plus one <AchievementRow> per row. Returns a Fragment, not a View: every call site renders the heading and rows as flat siblings inside BaseStatsScreen's own gap-spaced ScrollView content, and a wrapping container here would double up that spacing. Resolves each row's locked-vs-unlocked badgeColor itself (row.tierColor once row.unlockedAt is set, else the locked color) rather than upstream, since getAchievementCatalogRows deliberately leaves that choice to whatever renders the rows.

interface AchievementCatalogSectionProps {
  rows: AchievementCatalogRow[] // from @tastic/achievements' getAchievementCatalogRows
  label?: string // default 'ALL ACHIEVEMENTS'
  fg?: string
  fgMuted?: string
}

ActivityStatSection - the "ACTIVITY" <StatSection> (Days Played / Day Streak / Best Day Streak). Takes a plain structural ActivityStats shape rather than importing @tastic/achievements' own DayStreakState type, even though the fields match exactly - this component has zero dependency, not even type-only, on @tastic/achievements; any caller's own stats object with these three fields satisfies it:

interface ActivityStatSectionProps {
  stats: { distinctDaysPlayed: number; currentDayStreak: number; bestDayStreak: number }
}

Composing both with @tastic/achievements' own row computation:

import { getAchievementCatalogRows } from '@tastic/achievements'
import { AchievementCatalogSection, ActivityStatSection, BaseStatsScreen } from '@tastic/hud'

const rows = getAchievementCatalogRows(ACHIEVEMENT_CATALOG, deviceStats, statsView, unlockedAchievements, profileId)

return (
  <BaseStatsScreen /* ...your usual props */>
    <ActivityStatSection stats={statsView} />
    <AchievementCatalogSection rows={rows} />
  </BaseStatsScreen>
)

AchievementCatalogSection and AchievementUnlockList (below) are the only places in this package with a @tastic/achievements dependency, and both are type-only (AchievementCatalogRow, AchievementTier) - never a value/runtime import. See Peer Dependencies below.

Achievement unlocks on a result card (AchievementUnlockList)

The achievements a match just unlocked, for a game-over or round-over card: one row per achievement, each with its owner's avatar (the seat's ProfileChip, or a plain avatar in the seat's color for a guest), a tier-colored icon badge, and the title. Rows are listed seat by seat. Renders nothing when no seat unlocked anything, so it can sit in the card unconditionally.

import { ACHIEVEMENT_TIER_COLORS } from '@tastic/achievements'
import { AchievementUnlockList } from '@tastic/hud'

<AchievementUnlockList
  unlocks={seatUnlocks} // Partial<Record<Seat, { id, icon, title, tier }[]>>
  seats={[1, 2]}
  owners={{ 1: { profile: p1Profile, color: p1Color }, 2: { profile: null, color: p2Color } }}
  tierColors={ACHIEVEMENT_TIER_COLORS} // a prop, so @tastic/achievements stays type-only here
/>

Optional fg (title color, defaults from the theme) and style (the list container).

For muted profile colors, mount @tastic/profile's ProfileColorProvider: the ProfileChips here pick it up. A guest seat's avatar draws the color you pass as-is, so pass it already muted.

How-to-play guide (@tastic/hud/guide)

A short, skippable, paged "how to play" card that shows once on a fresh install and can be replayed on demand from Settings. Import it from the @tastic/hud/guide subpath, not the main barrel: it's a standalone build that pulls in only react, react-native, react-native-paper, react-native-reanimated, @rific/auto-paper and @rific/feedback-press - none of Skia, @tastic/core, @tastic/profile, @tastic/split-screen, @tastic/achievements or @rific/updater (all marked optional in peerDependenciesMeta), so an app that has none of them can use it. A test (src/__tests__/guideImports.test.ts) fails if any file under src/guide/ ever imports one.

import { createGuideSlice, GuideProvider, SeatDiagram, SwipeHint, useAutoShowGuide, useGuide } from '@tastic/hud/guide'

// 1. A persisted "seen" version - its own slice, mounted under `guide` in your root reducer.
const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, reducer, selectVersionSeen }

// 2. Once, near the root, wrapping your screens (inside PersistGate + your theme/Paper providers).
//    `rotation` is your game's live fake-landscape rotation (useRotation / getViewRotation).
<GuideProvider
  steps={[{ title: 'Steer Your Cycle', body: 'Swipe up, down, left or right.', art: <SwipeHint direction='up' /> }]}
  currentVersion={1}
  seenVersion={useSelector(guide.selectVersionSeen)}
  onSeen={(v) => dispatch(guide.actions.markSeen(v))}
  rotation={rotation}
>
  <Screens />
</GuideProvider>

// 3. On the Home screen, and nowhere else: opens it once per session for a player who hasn't seen it.
useAutoShowGuide()

// 4. Replay from Settings - BaseSettingsDialog renders a "How to Play" row when this is passed.
const { open } = useGuide()
<BaseSettingsDialog {...props} onShowHowToPlay={open} />
  • Existing players are grandfathered. On a fresh install redux-persist's REHYDRATE has no payload; an existing player has a payload that simply predates the guide key. createGuideSlice stamps the second with currentVersion instead of 0, so an OTA update never greets them with a tutorial (they can still replay it). Only finishing or skipping writes; a replay never does. Bump currentVersion only for a real controls/rules change - everyone sees it again.

  • If your last shipped build predates your redux-persist store, you also need the migrate. Those players have no root store yet, which looks exactly like a fresh install. Pass the AsyncStorage keys the shipped build wrote (read them from the last shipped commit, not today's source):

    const persistConfig = {
      key: 'root',
      storage: AsyncStorage,
      migrate: createReturningPlayerMigrate(AsyncStorage, ['pong.settings', 'pong.stats', 'pong.achievements'])
    }

    No root store but any of those keys present means "played an earlier build": that launch rehydrates with an empty payload, which the slice grandfathers. An existing root store passes through untouched.

  • Home only. Call useAutoShowGuide() from the Home screen - that placement is what keeps it from firing over a live match or a two-seat loadout, where a single centered card can't be read right-way-up by both players. Pass false to hold it back (e.g. while a deep link is being handled).

  • Leave the row off during a live match. Omit onShowHowToPlay on any screen where Settings pauses or overlays a running game.

  • Content is the app's. A GuideStep is { title, body, art? }; only the app knows its own controls and win condition. Shared illustrations for the arcade games: SwipeHint (animated finger sweeping toward an arrowhead; pointer='mouse' for a desktop seat that plays by dragging), DirectionKeysHint (an inverted-T of key caps, its keyboard counterpart; pass the seat's real keys as labels, arrow icons when omitted, axis='vertical' for up/down-only games), SeatDiagram (a phone split into two seat-colored halves) and SeatDevicesHint (its desktop counterpart: a keyboard and/or mouse, each in the color of the seat using it). All take their colors from the theme's primary/secondary by default. Give every card an illustration: the pager sizes all pages to the tallest, so a card without one leaves a gap.

  • Fleet copy conventions. Title Case titles ("Last One Riding Wins"), one-word buttons. The action row is ConfirmDialog's (overlayActionStyles): outlined Skip (first card only) or Back on the left, contained Next on the right, Ready on the last card. None of that is configurable: every game gets the identical dialog and supplies only its cards (Jay, 2026-09-24: "only their content should be different"). Short bodies, and only say what's true in every mode ("your half" is only true in two-player).

  • Not onboarding. LightCycles and Snake already use that word for their 3-2-1-GO countdown.

Fleet chrome without the heavy peers (@tastic/hud/chrome)

The shared dialogs, stats screen pieces, corner buttons and style tokens, from a standalone entry that never touches the main barrel. It's for an app that has @tastic/core and @rific/updater but none of Skia, @tastic/profile or @tastic/split-screen (Hangman). It exports, with their props types:

  • Dialogs: BaseSettingsDialog, ConfirmDialog, UpdateDialog, RotatedFrame
  • Stats: BaseStatsScreen, StatSection, StatRow, AchievementRow, LOCKED_BADGE_COLOR, AchievementCatalogSection, ActivityStatSection
  • Chrome: CornerActionButtons, ContentGutter, StaggeredWord, getStaggeredWordDuration
  • Overlay cards: OverlayCard, OverlayBackdrop, useOverlayCardColors, DialogHeader, useCompactDialog, isCompactDialogHeight, COMPACT_DIALOG_HEIGHT
  • Style tokens: overlayStyles, overlayActionStyles, MONO_FONT

Each is the same component the main entry exports and is documented above. Only the import path changes:

import { BaseSettingsDialog, ConfirmDialog, CornerActionButtons } from '@tastic/hud/chrome'

It pulls in only react, react-native, react-native-paper, react-native-reanimated, react-native-safe-area-context, @rific/auto-paper, @rific/feedback-press, @rific/updater and @tastic/core. AchievementCatalogSection uses @tastic/achievements for types only, so nothing loads it at runtime, but an app whose TypeScript resolves the react-native export condition (the fleet's tsconfig does) type-checks hud's source and needs it installed. AchievementUnlockList isn't here because it renders @tastic/profile's ProfileChip. A test (src/__tests__/chromeImports.test.ts) walks the import graph from src/chrome/index.ts and fails if anything it reaches imports another package, value-imports @tastic/achievements, reaches the main barrel, or has a .web twin.

Metro resolves both entries to the same source files, so an app that imports some components from here and others from @tastic/hud still bundles each module once.

Install

Published to the public npm registry as @tastic/hud. Everything described above, including the self-reading rotation behavior on these four components (defaulting to @tastic/core's own useRotation() rather than a plain 0), PlayerSetupPanel, and StaggeredWord, is live on npm.

npm install @tastic/hud

ContentGutter

Caps a play area at maxContentWidth and splits the leftover width into two equal gutters (leftGutter/rightGutter decorations). The width it clamps against is @tastic/core's useRotatedWindowDimensions(), so under a fake-landscape ancestor (FakeLandscapeView: OS window stays portrait, the screen is only visually rotated) it caps against the swapped, post-rotation width instead of the never-changing raw window width. It is an exact identity with useWindowDimensions() when no rotation applies.

Peer dependencies

None of these are bundled, so use whatever versions your app already has. Six are marked optional in peerDependenciesMeta so a light app can install hud without the ones it doesn't use: ./guide needs none of the six, and ./chrome needs only @tastic/core and @rific/updater (plus @tastic/achievements to type-check, see above). npm does not install optional peers for you, so an app that imports the main @tastic/hud entry must list every peer that entry loads in its own dependencies, including the four optional ones marked "main entry" below. Without them, Metro fails to bundle with "Unable to resolve module".

| Peer | Floor | Needed by | | --- | --- | --- | | react, react-native | >=19.0.0, >=0.76.0 | every entry | | react-native-paper | >=5.0.0 | main entry, ./guide, ./chrome | | react-native-reanimated | >=3.0.0 | main entry (StaggeredWord), ./guide, ./chrome (StaggeredWord) | | @rific/auto-paper | >=0.12.2 | main entry (BaseSettingsDialog needs the nested Portal.Host first shipped in 0.12.2; OverlayCard uses BlurView and useBlur, and BaseSettingsDialog's Blur row uses useBlurModule and useThemeSettings, all in 0.12.2), ./guide, ./chrome | | @rific/feedback-press | >=0.10.2 | main entry (useSoundSettings first shipped in 0.10.2), ./guide, ./chrome | | react-native-safe-area-context | >=5.0.0 | main entry (useZoneClampedAlign), ./chrome (RotatedFrame, BaseSettingsDialog) | | @tastic/core | >=0.12.0, optional | main entry: useRotation() and most dialogs; RotatedFrame and BaseSettingsDialog need FakeLandscapeView's rotation/passThrough, first shipped in 0.12.0; ./chrome (the same dialogs) | | @rific/updater | >=0.4.0, optional | main entry and ./chrome: BaseSettingsDialog, UpdateDialog | | @tastic/profile | >=0.4.0, optional | main entry: PlayerSetupPanel, AchievementUnlockList (ProfileChip) | | @tastic/split-screen | >=0.5.0, optional | main entry: useZoneClampedAlign | | @shopify/react-native-skia | >=2.6.0, optional | TriggerGaugeHost's gauge (lazy-loaded behind SkiaGate) and ./skia-gate | | @tastic/achievements | >=0.2.0, optional | AchievementCatalogSection, AchievementUnlockList, type-only (AchievementCatalogRow, first exported in 0.2.0; AchievementTier); ./chrome needs it installed only to type-check |