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:
25
README.md
25
README.md
@@ -351,18 +351,33 @@ One WebSocket per contact session: `/ws/session`, authenticated by the
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend** — pytest, **328 tests**. Requires `quantumancy_test` (dropped
|
||||
and recreated every run — never run two suites concurrently against it; the
|
||||
autouse drop/create fixture will fight itself):
|
||||
**Backend** — pytest, **400 tests**. Pass your NORMAL `DATABASE_URL`;
|
||||
`tests/conftest.py` derives the test database itself by appending `_test` to
|
||||
the name, so the command below creates and uses `quantumancy_test`. That
|
||||
database is dropped and recreated every run — never run two suites
|
||||
concurrently against it; the autouse drop/create fixture will fight itself.
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@localhost:5432/quantumancy_test \
|
||||
DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@localhost:5432/quantumancy \
|
||||
OLLAMA_BASE_URL=http://10.30.20.107:11434 \
|
||||
python -m pytest -q
|
||||
```
|
||||
|
||||
**Frontend** — Vitest + Testing Library, **366 tests**, plus a hard i18n
|
||||
Do **not** point `DATABASE_URL` at `quantumancy_test` here — the suffix is
|
||||
appended, not checked for, so you would get `quantumancy_test_test`, which
|
||||
does not exist, and all 400 tests error at setup. To use a database that
|
||||
isn't `<your db>_test`, set `TEST_DATABASE_URL` explicitly instead; its name
|
||||
must still end in `_test`.
|
||||
|
||||
Two guards in `conftest.py` make it safe to name the live database here: the
|
||||
derived URL must differ from `DATABASE_URL`, **and** the derived database
|
||||
name must end in `_test`. The second exists because the first alone is
|
||||
defeated by `localhost` vs `127.0.0.1` addressing the same database. This
|
||||
matters — an early version of the suite ran `drop_all` against production
|
||||
and destroyed live séance data.
|
||||
|
||||
**Frontend** — Vitest + Testing Library, **385 tests**, plus a hard i18n
|
||||
en/es parity gate (`npm run pretest`):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +109,23 @@ 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>
|
||||
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
{active && <EvilMeterGauge meter={meter} label={evilMeterLabel(meter)} />}
|
||||
|
||||
<div className="ghost-log-divider" aria-hidden="true">
|
||||
@@ -143,6 +164,8 @@ export function GhostLog() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? '🔊' : '▶'}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -212,6 +212,10 @@ function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string
|
||||
<h2 className="codex-dossier-heading">
|
||||
{t('codex.detail.voiceSignature', 'voice signature')}
|
||||
</h2>
|
||||
{/* 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]) => (
|
||||
@@ -222,6 +226,7 @@ function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="codex-dossier-footer">
|
||||
|
||||
@@ -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%),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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%),
|
||||
|
||||
@@ -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%),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user