Tilt-to-Hide (Mask Sensitive Values)¶
Spec: specs/047-mask-sensitive-values/
Tilt-to-hide masks on-screen monetary figures automatically: on a mobile device, balances show while the phone is held at a viewing tilt and hide the moment it is laid flat. It is a single app-wide setting in Settings → Privacy, enabled by default, and it is display-only — underlying values and on-chain state never change.
Architecture¶
| Piece | File | Role |
|---|---|---|
| Classifier | frontend/src/lib/privacy/tilt.js |
Pure {beta,gamma} → 'viewing' \| 'hidden' with hysteresis. No DOM. |
| Provider | frontend/src/contexts/PrivacyContext.jsx |
Subscribes to deviceorientation, derives live hidden, handles iOS permission + capability detection. |
| Hook | frontend/src/hooks/usePrivacy.js |
usePrivacy() → { hidden, enabled, support, permission, requestMotionPermission }. Safe default (values shown) when no provider. |
| Primitive | frontend/src/components/common/SensitiveValue.jsx |
Masks a value at its render site. |
| Preference | frontend/src/contexts/UserPreferencesContext.jsx |
Per-account tiltToHide flag (localStorage key tilt_to_hide, default true). |
| Settings UI | frontend/src/components/account/PrivacyPreferencesPanel.jsx |
The on/off toggle + mobile-only / permission messaging. |
The persisted on/off flag lives in UserPreferencesContext (per connected
account). The fast-changing viewing state lives in PrivacyProvider, mounted in
main.jsx just inside UserPreferencesProvider, so orientation updates never
touch storage.
How masking is applied¶
There is no single money formatter in the app, so masking is applied at each render site by wrapping the formatted string:
import SensitiveValue from '../common/SensitiveValue' // adjust relative path
<SensitiveValue className="portfolio-total">{formatUsdFull(total)}</SensitiveValue>
- Renders a
<span>by default; passas="strong"/as="div"to match the original element. - When hidden it renders a fixed
••••placeholder, removes the real value from the DOM (so it can't be copied) and sets the accessible name to "hidden" (so screen readers don't announce it). - The placeholder is constant, so it never leaks the value's magnitude or digit count.
What to wrap¶
Wrap monetary figures: balances, portfolio total / subtotals, per-asset amounts and USD values, wager/pool stakes, activity-history amounts, payouts.
Do not wrap: participant counts, dates, timers, wager/pool IDs, addresses, percentages that aren't money, or public market prices/odds (e.g. Polymarket cents, DEX exchange rates) — those aren't the user's holdings.
Wrap where the value is rendered in JSX, not inside pure string helpers
(wagerCardHelpers.js, wagerVm.js, lib/account/format.js) — those stay pure.
For values drawn on a canvas/SVG (e.g. Recharts axis ticks) you can't use the
component; read usePrivacy().hidden and substitute a placeholder in the
formatter instead (see PnlChartCanvas.jsx).
Device support & permissions¶
- Tilt-to-hide needs a motion/orientation sensor. On desktop, sensor-less devices, or when motion access is denied, values are simply shown and the Preferences panel says the feature is mobile-only. There is no manual fallback.
- iOS 13+ gates
DeviceOrientationEventbehindDeviceOrientationEvent.requestPermission(), which must be called from a user gesture. Enabling the toggle triggers the prompt; a denial degrades to "values shown".
Deployment requirement (Permissions-Policy)¶
DeviceOrientationEvent is gated by the Permissions-Policy response header.
frontend/nginx.conf and frontend/nginx.conf.template must allow the
sensor for self:
If these are set to the empty allowlist accelerometer=() / gyroscope=(), the
sensor is disabled and tilt-to-hide silently no-ops in production (while still
working in local dev, which sends no such header). Verify after deploy:
Tuning¶
Thresholds live in TILT_DEFAULTS in lib/privacy/tilt.js:
enterFlatDeg (enter hidden), exitFlatDeg (enter viewing), settleMs. The gap
between enter/exit is the hysteresis dead-band that prevents flicker.
Testing¶
- Unit-test the classifier directly with angle inputs (
test/privacy/tilt.test.js). - Simulate orientation with
global.dispatchOrientation({ beta, gamma })(mock intest/setup.js); seetest/privacy/PrivacyContext.test.jsxandtest/privacy/tiltHide.integration.test.jsx. usePrivacy()returns "values shown" without a provider, so existing component tests keep passing without wrapping every render inPrivacyProvider.