POST /auth/guest mints a real user row (wanderer-<4 hex>, collision
retry, unusable random password) and issues the normal session cookie,
per-IP rate limited at 5/hour. EnterPage gains the guest action;
the séance shows a dismissible claim-a-name note for wanderer- users.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The page had a viewport tag and a 960px breakpoint that stacked the
columns for tablets, but nothing below it — so everything from 320px to
960px shared one layout. Real phones sit at 390-430px, where three things
actually broke:
- Five mode tabs on one row. They now scroll horizontally instead of
wrapping into a stack tall enough to push the board off screen.
- The ask row (text input + ask + summon) crammed onto one line. The
input now takes the full row and the two buttons share the next.
- The four judgment verdicts sat two-up, leaving each too narrow for its
rune plus label. They go full width — these are irreversible,
consequential choices and must not be mis-tapped.
Every interactive target now clears 44px (Apple's HIG floor; Android's
48dp is close enough that one rule serves both), and the ask input is
exactly 16px because iOS Safari zooms the whole page when a focused input
is any smaller and leaves the layout zoomed after blur.
A second breakpoint at 380px handles SE-class phones, mainly by giving the
board less height so the transcript stays visible without scrolling.
Audited the rest first rather than assuming: the many `max-width` rules
are mobile-safe (they cap, they don't force), and InventoryPanel's grids
already use auto-fill/minmax and self-collapse. Only the fixed
`repeat(2, 1fr)` grids needed touching.
355 frontend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MOON INFLUENCE ON SUMMONING
Astronomy previously only decided whether a channel's familiar spirit
returned. It now shapes *who comes through*:
- Rarity skews with real moon illumination. At full moon the rare and
mythic weights roughly triple while common recedes, so a mythic
summoning becomes a reason to go out on the right night rather than a
flat lottery. Deliberately a skew and never a gate — every tier stays
reachable on every night, because someone who can only play midweek
should not be locked out of the good spirits.
- Hidden traits take a small moonlit nudge: power and volatility rise,
alignment drifts slightly darker. Capped at 0.12 and clamped to [0,1],
so a full moon intensifies what a spirit already is instead of
rewriting it. Deceptiveness is untouched — whether a spirit lies is its
own nature, not the sky's doing.
- The mint prompt is told the phase, and explicitly told the entity must
never mention or seem aware of it. It shapes who they are, not their
dialogue; a ghost remarking on the moonlight would break the illusion
instantly.
Tests assert the outcomes shift in practice (mythic rate over 4000 draws,
rare-tier counts across 300 fallback profiles), not merely that the code
runs. test_mint_prompt_never_receives_traits now allows `sky` while still
forbidding `traits`: moon phase is public, observable state anyone can look
up, hidden ground truth is not.
GEOMAGNETIC (app/geomagnetic.py)
Real NOAA SWPC planetary K-index, verified against the live endpoint —
which caught a real bug: I had written the parser against an
array-of-arrays shape, and the actual feed serves a list of objects
(`estimated_kp` float, `kp_index` int, `kp` a display string with a letter
suffix). Fixed, and the tests now use the real captured shape. Cached,
never blocking, and a failed refresh keeps serving the last real value —
an hour-old genuine measurement beats nothing, and geomagnetic conditions
do not change fast enough for that to mislead.
MAGNETOMETER WIRED
MagnetometerListener existed but was never connected. The EMF panel now
runs it alongside the motion listener where the hardware exists, so the
"EMF meter" measures actual magnetic field in µT rather than only
inferring disturbance from movement. Additive: the motion path is
untouched and remains the only option on iOS. Its field jitter also feeds
the entropy pool.
DEAD CODE
Removed .evp-scope and .radio-waterfall, orphaned when both panels moved to
the shared SpectrumScope. Audited every other flagged export first and left
them alone — they are used internally, and "not imported elsewhere" is not
the same as dead.
Adds docs/CHANNELS.md recording what each channel measures and, honestly,
what has actually been verified against hardware versus only written
carefully.
311 backend + 355 frontend tests pass; i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both measure genuine physics rather than dressing up a random number.
Bluetooth (lib/bluetooth.ts): BLE advertises in the 2.4GHz ISM band, and
the human body is mostly water, which absorbs 2.4GHz strongly — the same
physics that makes a microwave work and that degrades your wifi when
someone stands between you and the router. So RSSI genuinely drops when a
body crosses the path. That makes signal-strength variance a real,
physically-grounded movement signal. The module reports exactly that and
nothing more: it never claims a drop *is* a presence, only that the field
changed. Threshold is 6dB — above the 2-4dB of idle multipath wander, and
inside the 3-10dB a real body actually causes.
Magnetometer (lib/magnetometer.ts): the existing EMF mode infers field
disturbance from DeviceMotion/DeviceOrientation, which is a real
measurement but measures *movement*, not magnetism — a phone sitting still
beside a running motor reads nothing. This reads the actual magnetometer,
so the EMF meter measures what an EMF meter is supposed to. Real
ghost-hunting EMF meters are just magnetometers, and the spikes they pick
up come from mains wiring, motors and moving ferrous mass — all of which
this picks up, for the same real reasons. 3uT threshold clears the ~0.5-1uT
sensor noise while still catching household sources. Earth's constant
25-65uT background is explicitly what the rolling baseline exists to
subtract.
Both are additive: neither replaces the existing motion-based EMF, which
stays the fallback because it works on iOS where neither of these do
(no Web Bluetooth, no Generic Sensor API in any iOS browser). Both reuse
the time-aware EMA baseline shape from coldSpot.ts, since advertisement
and sensor intervals are irregular and a fixed per-sample alpha would
weight a burst and a long gap identically.
Web Bluetooth types are declared locally rather than pulling in
@types/web-bluetooth for three shapes — same approach lib/emf.ts already
takes with its non-standard sensor types.
Also renders unprompted 'manifest' utterances as an intrusion: violet edge,
full opacity (unlike the faded ambient/fragment murmurs), and a brief
blur-in. The unsettling part is that it is perfectly clear and completely
unbidden.
355 frontend tests pass (26 new); i18n parity gate passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First sub-project of the "make contact feel real" arc (spec:
docs/superpowers/specs/2026-07-23-possession-presentation-design.md).
Direct Contact replies now feel like a spirit fighting through static to
hold the channel rather than a plain chat bubble:
- backend/app/possession.py: compute_stability(rarity, magnitude, rng) —
a 0.05-0.98 score per reply (rarer entity + stronger triggering anomaly =
cleaner signal), rng injectable for a later quantum-RNG source.
- ws.py sends stability on reply_start; audio synthesis for that reply gets
noise/bitcrush scaled by instability (1 - stability) via a new
instability param on synthesize_spirit_voice — effects.py itself is
untouched, only the params fed into it.
- frontend/src/lib/possession.ts: renderPossessedText — pure, deterministic
(tick-seeded, no Math.random) text corruption with self-correcting
glitch bursts, wired into Transcript.tsx's streaming reply display.
Stored transcript/reply text is unaffected — this is presentation only.
78/78 backend, 137/137 frontend tests passing.