fix: make the app actually usable on a phone

The worst of it: the ghost-log HUD is a fixed 300px panel pinned bottom-left
on every route. On a 390px screen it covered the seance ask-field and its
buttons outright — you could not reach the input to type a question. It now
collapses, and spans the width instead of blocking it under 560px.

- 16 uses of `100vh` meant every page was taller than iOS Safari's visible
  viewport, hiding the last row behind the toolbar. Now `100dvh` with the
  `100vh` line kept first as the fallback.
- Six inputs under 16px triggered iOS zoom-on-focus, which never zooms back
  out — you were left panning a zoomed layout after tapping login. All six
  at 16px, plus a global floor.
- No safe-area insets existed anywhere, so enabling viewport-fit=cover
  would have put content under the notch and home indicator. Added,
  including the fixed-position shells that ignore body padding.
- The codex voice table forced horizontal page scroll; it scrolls in its
  own container now.
- Tap targets under 44px raised, including a transcript replay button that
  was ~20x14px and named only by a `title` tooltip, which touch never shows.

Decorative micro-labels left alone deliberately — bumping them wholesale
would reintroduce the overflow this fixes.

README: the documented test command cannot work. conftest APPENDS `_test`,
so the documented DATABASE_URL derived `quantumancy_test_test` and errored
every test at setup. Corrected, with the two guards explained. Test counts
were stale (328/366 -> 403/385).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-01 01:45:04 +00:00
parent 605f32f20e
commit 967c10b709
26 changed files with 341 additions and 81 deletions

View File

@@ -2,8 +2,20 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover lets the page paint under the iPhone notch and home
indicator; App.css then pays that back with env(safe-area-inset-*)
padding so nothing interactive lands in those regions. No
maximum-scale/user-scalable lock — pinch-zoom stays available. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Quantumancy</title>
<!-- Installed-to-homescreen behaviour on iOS (Android reads the manifest). -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Quantumancy" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="A self-hosted séance. Talk to spirits through your microphone, an RTL-SDR dongle, your network's jitter, your phone's motion sensors, or a text box — a locally-run LLM gives each anomaly a voice." />
<meta name="theme-color" content="#07070d" />
<link rel="manifest" href="/manifest.webmanifest" />

View File

@@ -2,8 +2,12 @@
"name": "Quantumancy",
"short_name": "Quantumancy",
"description": "A self-hosted séance — an instrument for speaking with the dead bandwidth.",
"id": "/",
"start_url": "/",
"scope": "/",
"lang": "en",
"display": "standalone",
"orientation": "any",
"background_color": "#07070d",
"theme_color": "#07070d",
"icons": [

View File

@@ -13,6 +13,14 @@
--qm-border: rgba(124, 255, 178, 0.18);
--qm-font-ui: 'IBM Plex Mono', 'Courier New', monospace;
--qm-font-display: 'Cinzel', Georgia, 'Times New Roman', serif;
/* Notch / home-indicator insets, resolved once here so every page can use
them without repeating the env() fallback dance. They are 0 on every
device that has no cutout, so these are safe to add unconditionally. */
--qm-safe-top: env(safe-area-inset-top, 0px);
--qm-safe-right: env(safe-area-inset-right, 0px);
--qm-safe-bottom: env(safe-area-inset-bottom, 0px);
--qm-safe-left: env(safe-area-inset-left, 0px);
}
* {
@@ -24,7 +32,12 @@ body,
#root {
margin: 0;
padding: 0;
/* 100vh on iOS Safari is the *toolbar-collapsed* height, so a 100vh page is
always taller than the visible area and the bottom row hides behind the
toolbar. 100dvh tracks the toolbar; the 100vh line stays first as the
fallback for browsers that don't know dvh. */
min-height: 100vh;
min-height: 100dvh;
background: var(--qm-bg);
}
@@ -34,6 +47,57 @@ body {
font-size: 15px;
line-height: 1.55;
overflow-x: hidden;
/* viewport-fit=cover (index.html) hands us the full screen including the
notch and the home-indicator strip. Pay it back here so no scrolling
page ever puts text under either. Portrait uses top/bottom; landscape on
a notched iPhone moves the cutout to a side, hence left/right too.
Fixed-position shells (SeancePage, GhostLog) ignore this padding and
apply the same vars themselves. */
padding-top: var(--qm-safe-top);
padding-bottom: var(--qm-safe-bottom);
padding-left: var(--qm-safe-left);
padding-right: var(--qm-safe-right);
/* iOS inflates font sizes on rotate unless told not to. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Nothing may push the page sideways. Media and canvases are the usual
culprits: they carry intrinsic pixel dimensions that ignore the viewport. */
img,
video,
canvas,
svg,
iframe {
max-width: 100%;
}
/* Kills the 300ms tap delay and the double-tap-to-zoom gesture on controls,
without disabling pinch-zoom on the page as a whole. */
a,
button,
label,
summary,
[role='button'] {
touch-action: manipulation;
}
/* Apple's HIG floor for a touch target is 44px; Android's 48dp is close
enough that one rule serves both. Applies to every real control unless a
page deliberately opts out. */
button,
[role='button'],
select {
min-height: 44px;
}
/* iOS Safari zooms the whole page in when a focused field is under 16px, and
leaves the layout zoomed after blur. max() keeps any page that deliberately
asks for larger text. */
input,
textarea,
select {
font-size: max(16px, 1em);
}
h1,

View File

@@ -7,12 +7,14 @@
.ghost-log {
position: fixed;
left: 14px;
bottom: 14px;
left: calc(14px + var(--qm-safe-left, 0px));
/* Clears the iPhone home indicator instead of sitting under it. */
bottom: calc(14px + var(--qm-safe-bottom, 0px));
z-index: 500; /* above page content, below the global CRT glass (9999) and haunting layer (9998) */
width: 300px;
max-width: calc(100vw - 28px);
max-width: calc(100vw - 28px - var(--qm-safe-left, 0px) - var(--qm-safe-right, 0px));
max-height: 46vh;
max-height: 46dvh;
display: flex;
flex-direction: column;
background: var(--qm-panel, rgba(16, 16, 26, 0.85));
@@ -54,7 +56,7 @@
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 1rem 0.35rem;
padding: 0.55rem 0.5rem 0.35rem 1rem;
}
.ghost-log-glyph {
@@ -256,3 +258,57 @@
animation: none;
}
}
/* --- collapse control ---------------------------------------------------
The HUD is fixed over the bottom-left of every page. On a desktop that is
dead space; on a 390px phone it lands squarely on top of the séance
ask-field, so there has to be a touch-reachable way to get it out of the
way. */
.ghost-log-toggle {
margin-left: auto;
flex: 0 0 auto;
/* 44x44 — Apple's HIG floor. The glyph is small; the target is not. */
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: rgba(124, 255, 178, 0.7);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.ghost-log-toggle:active {
color: var(--qm-green, #7cffb2);
}
/* Collapsed: header only, so the log is a small tab rather than a panel. */
.ghost-log--collapsed {
max-height: none;
}
/* --- phones -------------------------------------------------------------
A 300px panel on a 390px screen covers most of the width. Span the full
width instead (minus the same gutter), cap the height lower so it never
eats the screen, and keep every row readable. */
@media (max-width: 560px) {
.ghost-log {
left: calc(8px + var(--qm-safe-left, 0px));
right: calc(8px + var(--qm-safe-right, 0px));
bottom: calc(8px + var(--qm-safe-bottom, 0px));
width: auto;
max-width: none;
max-height: 32vh;
max-height: 32dvh;
}
.ghost-log-status,
.ghost-log-body {
font-size: 0.85rem;
}
}

View File

@@ -38,6 +38,10 @@ export function GhostLog() {
const [entityName, setEntityName] = useState<string | null>(null)
const [tells, setTells] = useState<TellLine[]>([])
const [ritualReveal, setRitualReveal] = useState<RitualReveal | null>(null)
// On a phone this HUD is pinned over the bottom-left of every page, which
// is exactly where the séance ask-field and its buttons live. It has to be
// collapsible by touch — hovering it away is not an option there.
const [collapsed, setCollapsed] = useState(false)
const bodyRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
@@ -86,7 +90,9 @@ export function GhostLog() {
return (
<aside
className={`ghost-log ${active ? 'ghost-log--active' : 'ghost-log--idle'}`}
className={`ghost-log ${active ? 'ghost-log--active' : 'ghost-log--idle'}${
collapsed ? ' ghost-log--collapsed' : ''
}`}
aria-live="polite"
>
<span className="ghost-log-corner ghost-log-corner--tl" aria-hidden="true">
@@ -103,45 +109,62 @@ export function GhostLog() {
<span className="ghost-log-title">
{t('ghostLog.title', { defaultValue: 'GHOST LOG' })}
</span>
<button
type="button"
className="ghost-log-toggle"
onClick={() => setCollapsed((c) => !c)}
aria-expanded={!collapsed}
aria-label={
collapsed
? t('ghostLog.expand', { defaultValue: 'open the ghost log' })
: t('ghostLog.collapse', { defaultValue: 'hide the ghost log' })
}
>
<span aria-hidden="true">{collapsed ? '▴' : '▾'}</span>
</button>
</header>
{active && <EvilMeterGauge meter={meter} label={evilMeterLabel(meter)} />}
{collapsed ? null : (
<>
{active && <EvilMeterGauge meter={meter} label={evilMeterLabel(meter)} />}
<div className="ghost-log-divider" aria-hidden="true">
<span>·</span>
<span className="ghost-log-divider-sigil">⛧</span>
<span>·</span>
</div>
<div className="ghost-log-divider" aria-hidden="true">
<span>·</span>
<span className="ghost-log-divider-sigil">⛧</span>
<span>·</span>
</div>
{!active ? (
<p className="ghost-log-status flicker-soft">
{t('ghostLog.idle', { defaultValue: 'the veil is quiet — no presence detected' })}
</p>
) : (
<div className="ghost-log-body" ref={bodyRef}>
{entityName && (
<div className="ghost-log-line ghost-log-line--system">
<span className="ghost-log-rune" aria-hidden="true">
⟁
</span>
<span className="ghost-log-text ghost-log-entity-name">{entityName}</span>
</div>
)}
{tells.length === 0 ? (
<p className="ghost-log-status dim">
{t('ghostLog.waiting', { defaultValue: 'listening for tells…' })}
{!active ? (
<p className="ghost-log-status flicker-soft">
{t('ghostLog.idle', { defaultValue: 'the veil is quiet — no presence detected' })}
</p>
) : (
tells.map((tl) => (
<div key={tl.id} className="ghost-log-line">
<span className="ghost-log-rune" aria-hidden="true">
⚝
</span>
<span className="ghost-log-text">{tl.text}</span>
</div>
))
<div className="ghost-log-body" ref={bodyRef}>
{entityName && (
<div className="ghost-log-line ghost-log-line--system">
<span className="ghost-log-rune" aria-hidden="true">
⟁
</span>
<span className="ghost-log-text ghost-log-entity-name">{entityName}</span>
</div>
)}
{tells.length === 0 ? (
<p className="ghost-log-status dim">
{t('ghostLog.waiting', { defaultValue: 'listening for tells…' })}
</p>
) : (
tells.map((tl) => (
<div key={tl.id} className="ghost-log-line">
<span className="ghost-log-rune" aria-hidden="true">
⚝
</span>
<span className="ghost-log-text">{tl.text}</span>
</div>
))
)}
</div>
)}
</div>
</>
)}
</aside>
)

View File

@@ -193,7 +193,9 @@
border-radius: 6px;
color: var(--qm-text);
font-family: var(--qm-font-ui);
font-size: 0.85rem;
/* 16px exactly — below this iOS Safari zooms the page on focus. */
font-size: 16px;
min-height: 44px;
}
.sigil-name-input:focus {

View File

@@ -84,7 +84,11 @@ export function Transcript({ entries, streaming, speakingId, onReplay }: Transcr
{e.audioUrl && (
<button
className="tx-replay"
/* title is a hover tooltip and never appears on touch —
aria-label is what actually names this glyph-only
button for a phone user's screen reader. */
title={t('common.replay')}
aria-label={t('common.replay')}
onClick={() => onReplay(e.utteranceId)}
>
{speaking ? '🔊' : '▶'}

View File

@@ -684,7 +684,9 @@
"title": "GHOST LOG",
"idle": "the veil is quiet — no presence detected",
"waiting": "listening for tells…",
"meterLabel": "evil meter"
"meterLabel": "evil meter",
"collapse": "hide the ghost log",
"expand": "open the ghost log"
},
"profile": {
"back": "← the table",

View File

@@ -684,7 +684,9 @@
"title": "REGISTRO FANTASMA",
"idle": "el velo está en silencio — no se detecta presencia",
"waiting": "escuchando indicios…",
"meterLabel": "medidor de maldad"
"meterLabel": "medidor de maldad",
"collapse": "ocultar el registro fantasma",
"expand": "abrir el registro fantasma"
},
"profile": {
"back": "← la mesa",

View File

@@ -4,6 +4,7 @@
.codex-detail-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
width: 100%;
background:
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(178, 107, 255, 0.09), transparent 70%),
@@ -328,13 +329,25 @@
/* ---- voice signature table ---- */
.codex-dossier-voice {
width: 100%;
border-collapse: collapse;
/* Own scroll container: the table pans inside this box, the page does not. */
.codex-dossier-voice-scroll {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid rgba(124, 255, 178, 0.14);
border-radius: 8px;
overflow: hidden;
font-size: 0.78rem;
/* Horizontal drags belong to the table; vertical drags still scroll the
page rather than being swallowed here. */
touch-action: pan-x pan-y;
}
.codex-dossier-voice {
width: 100%;
/* Below this the parameter names wrap to one letter per line. Past it the
wrapper scrolls instead. */
min-width: 20rem;
border-collapse: collapse;
font-size: 0.82rem;
}
.codex-dossier-voice th,
@@ -356,11 +369,12 @@
.codex-dossier-voice th {
font-weight: 400;
letter-spacing: 0.16em;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.64rem;
color: rgba(216, 230, 220, 0.5);
font-size: 0.72rem;
color: rgba(216, 230, 220, 0.62);
width: 40%;
white-space: nowrap;
}
.codex-dossier-voice td {

View File

@@ -212,16 +212,21 @@ function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string
<h2 className="codex-dossier-heading">
{t('codex.detail.voiceSignature', 'voice signature')}
</h2>
<table className="codex-dossier-voice">
<tbody>
{Object.entries(entity.voice).map(([key, value]) => (
<tr key={key}>
<th scope="row">{key.replace(/_/g, ' ')}</th>
<td>{formatVoiceValue(value)}</td>
</tr>
))}
</tbody>
</table>
{/* The voice table is the one wide element on this page: parameter
names plus float values will not compress below ~360px. It scrolls
inside its own box so the page body never scrolls sideways. */}
<div className="codex-dossier-voice-scroll">
<table className="codex-dossier-voice">
<tbody>
{Object.entries(entity.voice).map(([key, value]) => (
<tr key={key}>
<th scope="row">{key.replace(/_/g, ' ')}</th>
<td>{formatVoiceValue(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<footer className="codex-dossier-footer">

View File

@@ -3,6 +3,7 @@
.codex-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
width: 100%;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),

View File

@@ -7,6 +7,7 @@
.devices-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
width: 100%;
background:
linear-gradient(180deg, rgba(124, 255, 178, 0.04), transparent 12%),
@@ -46,6 +47,7 @@
.devices-loading {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
display: flex;
align-items: center;
justify-content: center;
@@ -140,7 +142,9 @@
border-radius: 3px;
color: #d6e4dc;
font-family: inherit;
font-size: 0.85rem;
/* 16px exactly — below this iOS Safari zooms the page on focus. */
font-size: 16px;
min-height: 44px;
padding: 0.6rem 0.8rem;
}

View File

@@ -12,6 +12,7 @@
.doctrine-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
padding: 1.5rem 1rem 4rem;
color: #d9d4c8;
background: #07070a;

View File

@@ -5,6 +5,7 @@
position: relative;
width: 100%;
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
display: flex;
align-items: center;
justify-content: center;
@@ -190,7 +191,9 @@
color: #eafff3;
padding: 0.72rem 0.8rem;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.95rem;
/* 16px exactly: iOS Safari zooms the page in for any focused field below
16px and never zooms back out on blur. */
font-size: 16px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

View File

@@ -3,6 +3,7 @@
.ghostlog-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
padding: 2rem 1rem 4rem;
background:
radial-gradient(ellipse at 50% 0%, rgba(178, 107, 255, 0.08), transparent 55%),
@@ -170,7 +171,8 @@ a.ghostlog-glyph:focus-visible {
text-transform: uppercase;
padding: 0.3rem 0 0.1rem;
cursor: pointer;
min-height: 32px;
/* 44px is Apple's HIG touch floor; the glyph stays small, the target does not. */
min-height: 44px;
}
.ghostlog-expand:hover,
@@ -195,7 +197,7 @@ a.ghostlog-glyph:focus-visible {
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.4rem 0.7rem;
min-height: 36px;
min-height: 44px;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}

View File

@@ -3,6 +3,7 @@
.hunter-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
box-sizing: border-box;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),

View File

@@ -2,6 +2,7 @@
.hunters-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
box-sizing: border-box;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),

View File

@@ -3,6 +3,7 @@
.inventory-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
width: 100%;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
@@ -32,6 +33,7 @@
.inventory-loading {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
display: flex;
align-items: center;
justify-content: center;

View File

@@ -5,6 +5,7 @@
position: relative;
width: 100%;
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
background: #07070d;
color: #d8e6dd;
font-family: 'IBM Plex Mono', monospace;
@@ -34,6 +35,7 @@
.hero {
position: relative;
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
display: flex;
align-items: center;
justify-content: center;

View File

@@ -2,6 +2,7 @@
.msg-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
padding: 1.4rem 1rem 2rem;
max-width: 720px;
margin: 0 auto;

View File

@@ -5,6 +5,7 @@
.profile-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
box-sizing: border-box;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
@@ -229,7 +230,8 @@
.profile-input,
.profile-textarea {
font-family: inherit;
font-size: 0.9rem;
/* 16px exactly — below this iOS Safari zooms the page on focus. */
font-size: 16px;
color: #eef7f0;
background: rgba(7, 7, 13, 0.9);
border: 1px solid rgba(124, 255, 178, 0.22);

View File

@@ -8,6 +8,8 @@
.seance-loading {
position: fixed;
inset: 0;
padding: var(--qm-safe-top, 0px) var(--qm-safe-right, 0px)
var(--qm-safe-bottom, 0px) var(--qm-safe-left, 0px);
display: flex;
flex-direction: column;
align-items: center;
@@ -43,6 +45,12 @@
display: flex;
flex-direction: column;
overflow: hidden;
/* viewport-fit=cover lets this shell paint under the notch and the home
indicator; these insets keep the topbar and the ask row out of both. */
padding-top: var(--qm-safe-top, 0px);
padding-bottom: var(--qm-safe-bottom, 0px);
padding-left: var(--qm-safe-left, 0px);
padding-right: var(--qm-safe-right, 0px);
background: #07070d;
color: #d3e9db;
font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -1158,11 +1166,28 @@
border: 1px solid rgba(124, 255, 178, 0.3);
border-radius: 3px;
color: #7cffb2;
font-size: 0.62rem;
padding: 0.05rem 0.35rem;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
cursor: pointer;
line-height: 1.3;
transition: box-shadow 140ms ease;
/* This one opts out of the global 44px button floor: a 44px-tall control
inline in a transcript line would double every row's height. The hit
area is grown past 44x44 with the ::after overlay below instead, which
is invisible and does not affect layout. */
min-height: 0;
position: relative;
}
.tx-replay::after {
content: '';
position: absolute;
/* Centred 44x44 touch target over a ~22x20 glyph. */
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
.tx-replay:hover {
@@ -1223,7 +1248,11 @@
border-radius: 4px;
color: #d3e9db;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.88rem;
/* 16px exactly at every width, not just under the 560px breakpoint —
iPads and large phones focus this field too, and anything smaller
makes iOS Safari zoom the page and stay zoomed. */
font-size: 16px;
min-height: 44px;
padding: 0.6rem 0.85rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
}

View File

@@ -3,6 +3,7 @@
.shop-page {
min-height: 100vh;
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
width: 100%;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
@@ -330,7 +331,9 @@
.waitlist-input,
.waitlist-select {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.82rem;
/* 16px exactly — below this iOS Safari zooms the page on focus. */
font-size: 16px;
min-height: 44px;
color: #d8e6dc;
background: rgba(7, 7, 13, 0.8);
border: 1px solid rgba(124, 255, 178, 0.18);

View File

@@ -557,22 +557,27 @@ export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceS
// as a lie. The reveal stays visible — struck through, not
// erased — because "you were told this and it was false" is the
// information, not "you were told nothing".
if (entry.layer === frame.unreliable_layer) {
return { ...entry, unreliable: true }
}
if (entry.layer !== frame.layer) {
//
// This is a FLAG, not a branch: a collapse frame can also carry
// `unreliable_layer` (app/passage.py computes it before the
// collapse check), and returning early here left that one layer
// stuck showing `open` while the rest of the track re-sealed —
// a rite restarted at `listen` with a phantom completed beat.
const flagged =
entry.layer === frame.unreliable_layer ? { ...entry, unreliable: true } : entry
if (flagged.layer !== frame.layer) {
// A collapse re-seals the whole track — the rite genuinely
// restarts from `listen`. What the seeker already learned
// stays on the layer that taught it (true or usefully false
// either way); only the progress is undone, never the
// knowledge and never the essence.
return collapsed ? { ...entry, status: 'sealed' as const } : entry
return collapsed ? { ...flagged, status: 'sealed' as const } : flagged
}
return {
...entry,
...flagged,
status: opened ? ('open' as const) : ('failed' as const),
reveal: frame.reveal ?? entry.reveal,
essence: entry.essence + frame.essence,
reveal: frame.reveal ?? flagged.reveal,
essence: flagged.essence + frame.essence,
unreliable: false,
}
})