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
|
## Testing
|
||||||
|
|
||||||
**Backend** — pytest, **328 tests**. Requires `quantumancy_test` (dropped
|
**Backend** — pytest, **400 tests**. Pass your NORMAL `DATABASE_URL`;
|
||||||
and recreated every run — never run two suites concurrently against it; the
|
`tests/conftest.py` derives the test database itself by appending `_test` to
|
||||||
autouse drop/create fixture will fight itself):
|
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
|
```bash
|
||||||
cd backend && source venv/bin/activate
|
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 \
|
OLLAMA_BASE_URL=http://10.30.20.107:11434 \
|
||||||
python -m pytest -q
|
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`):
|
en/es parity gate (`npm run pretest`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,8 +2,20 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<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="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" />
|
<meta name="theme-color" content="#07070d" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
"name": "Quantumancy",
|
"name": "Quantumancy",
|
||||||
"short_name": "Quantumancy",
|
"short_name": "Quantumancy",
|
||||||
"description": "A self-hosted séance — an instrument for speaking with the dead bandwidth.",
|
"description": "A self-hosted séance — an instrument for speaking with the dead bandwidth.",
|
||||||
|
"id": "/",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"lang": "en",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#07070d",
|
"background_color": "#07070d",
|
||||||
"theme_color": "#07070d",
|
"theme_color": "#07070d",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
--qm-border: rgba(124, 255, 178, 0.18);
|
--qm-border: rgba(124, 255, 178, 0.18);
|
||||||
--qm-font-ui: 'IBM Plex Mono', 'Courier New', monospace;
|
--qm-font-ui: 'IBM Plex Mono', 'Courier New', monospace;
|
||||||
--qm-font-display: 'Cinzel', Georgia, 'Times New Roman', serif;
|
--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 {
|
#root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
background: var(--qm-bg);
|
background: var(--qm-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +47,57 @@ body {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
overflow-x: hidden;
|
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,
|
h1,
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
|
|
||||||
.ghost-log {
|
.ghost-log {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 14px;
|
left: calc(14px + var(--qm-safe-left, 0px));
|
||||||
bottom: 14px;
|
/* 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) */
|
z-index: 500; /* above page content, below the global CRT glass (9999) and haunting layer (9998) */
|
||||||
width: 300px;
|
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: 46vh;
|
||||||
|
max-height: 46dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--qm-panel, rgba(16, 16, 26, 0.85));
|
background: var(--qm-panel, rgba(16, 16, 26, 0.85));
|
||||||
@@ -54,7 +56,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding: 0.55rem 1rem 0.35rem;
|
padding: 0.55rem 0.5rem 0.35rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-log-glyph {
|
.ghost-log-glyph {
|
||||||
@@ -256,3 +258,57 @@
|
|||||||
animation: none;
|
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 [entityName, setEntityName] = useState<string | null>(null)
|
||||||
const [tells, setTells] = useState<TellLine[]>([])
|
const [tells, setTells] = useState<TellLine[]>([])
|
||||||
const [ritualReveal, setRitualReveal] = useState<RitualReveal | null>(null)
|
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)
|
const bodyRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +90,9 @@ export function GhostLog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<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"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<span className="ghost-log-corner ghost-log-corner--tl" aria-hidden="true">
|
<span className="ghost-log-corner ghost-log-corner--tl" aria-hidden="true">
|
||||||
@@ -103,45 +109,62 @@ export function GhostLog() {
|
|||||||
<span className="ghost-log-title">
|
<span className="ghost-log-title">
|
||||||
{t('ghostLog.title', { defaultValue: 'GHOST LOG' })}
|
{t('ghostLog.title', { defaultValue: 'GHOST LOG' })}
|
||||||
</span>
|
</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>
|
</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">
|
<div className="ghost-log-divider" aria-hidden="true">
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span className="ghost-log-divider-sigil">⛧</span>
|
<span className="ghost-log-divider-sigil">⛧</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!active ? (
|
{!active ? (
|
||||||
<p className="ghost-log-status flicker-soft">
|
<p className="ghost-log-status flicker-soft">
|
||||||
{t('ghostLog.idle', { defaultValue: 'the veil is quiet — no presence detected' })}
|
{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…' })}
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
tells.map((tl) => (
|
<div className="ghost-log-body" ref={bodyRef}>
|
||||||
<div key={tl.id} className="ghost-log-line">
|
{entityName && (
|
||||||
<span className="ghost-log-rune" aria-hidden="true">
|
<div className="ghost-log-line ghost-log-line--system">
|
||||||
⚝
|
<span className="ghost-log-rune" aria-hidden="true">
|
||||||
</span>
|
⟁
|
||||||
<span className="ghost-log-text">{tl.text}</span>
|
</span>
|
||||||
</div>
|
<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>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -193,7 +193,9 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--qm-text);
|
color: var(--qm-text);
|
||||||
font-family: var(--qm-font-ui);
|
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 {
|
.sigil-name-input:focus {
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export function Transcript({ entries, streaming, speakingId, onReplay }: Transcr
|
|||||||
{e.audioUrl && (
|
{e.audioUrl && (
|
||||||
<button
|
<button
|
||||||
className="tx-replay"
|
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')}
|
title={t('common.replay')}
|
||||||
|
aria-label={t('common.replay')}
|
||||||
onClick={() => onReplay(e.utteranceId)}
|
onClick={() => onReplay(e.utteranceId)}
|
||||||
>
|
>
|
||||||
{speaking ? '🔊' : '▶'}
|
{speaking ? '🔊' : '▶'}
|
||||||
|
|||||||
@@ -684,7 +684,9 @@
|
|||||||
"title": "GHOST LOG",
|
"title": "GHOST LOG",
|
||||||
"idle": "the veil is quiet — no presence detected",
|
"idle": "the veil is quiet — no presence detected",
|
||||||
"waiting": "listening for tells…",
|
"waiting": "listening for tells…",
|
||||||
"meterLabel": "evil meter"
|
"meterLabel": "evil meter",
|
||||||
|
"collapse": "hide the ghost log",
|
||||||
|
"expand": "open the ghost log"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"back": "← the table",
|
"back": "← the table",
|
||||||
|
|||||||
@@ -684,7 +684,9 @@
|
|||||||
"title": "REGISTRO FANTASMA",
|
"title": "REGISTRO FANTASMA",
|
||||||
"idle": "el velo está en silencio — no se detecta presencia",
|
"idle": "el velo está en silencio — no se detecta presencia",
|
||||||
"waiting": "escuchando indicios…",
|
"waiting": "escuchando indicios…",
|
||||||
"meterLabel": "medidor de maldad"
|
"meterLabel": "medidor de maldad",
|
||||||
|
"collapse": "ocultar el registro fantasma",
|
||||||
|
"expand": "abrir el registro fantasma"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"back": "← la mesa",
|
"back": "← la mesa",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
.codex-detail-page {
|
.codex-detail-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(178, 107, 255, 0.09), transparent 70%),
|
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(178, 107, 255, 0.09), transparent 70%),
|
||||||
@@ -328,13 +329,25 @@
|
|||||||
|
|
||||||
/* ---- voice signature table ---- */
|
/* ---- voice signature table ---- */
|
||||||
|
|
||||||
.codex-dossier-voice {
|
/* Own scroll container: the table pans inside this box, the page does not. */
|
||||||
width: 100%;
|
.codex-dossier-voice-scroll {
|
||||||
border-collapse: collapse;
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
border: 1px solid rgba(124, 255, 178, 0.14);
|
border: 1px solid rgba(124, 255, 178, 0.14);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
/* Horizontal drags belong to the table; vertical drags still scroll the
|
||||||
font-size: 0.78rem;
|
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,
|
.codex-dossier-voice th,
|
||||||
@@ -356,11 +369,12 @@
|
|||||||
|
|
||||||
.codex-dossier-voice th {
|
.codex-dossier-voice th {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.64rem;
|
font-size: 0.72rem;
|
||||||
color: rgba(216, 230, 220, 0.5);
|
color: rgba(216, 230, 220, 0.62);
|
||||||
width: 40%;
|
width: 40%;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codex-dossier-voice td {
|
.codex-dossier-voice td {
|
||||||
|
|||||||
@@ -212,16 +212,21 @@ function Dossier({ entity, locale }: { entity: CodexEntityDetail; locale: string
|
|||||||
<h2 className="codex-dossier-heading">
|
<h2 className="codex-dossier-heading">
|
||||||
{t('codex.detail.voiceSignature', 'voice signature')}
|
{t('codex.detail.voiceSignature', 'voice signature')}
|
||||||
</h2>
|
</h2>
|
||||||
<table className="codex-dossier-voice">
|
{/* The voice table is the one wide element on this page: parameter
|
||||||
<tbody>
|
names plus float values will not compress below ~360px. It scrolls
|
||||||
{Object.entries(entity.voice).map(([key, value]) => (
|
inside its own box so the page body never scrolls sideways. */}
|
||||||
<tr key={key}>
|
<div className="codex-dossier-voice-scroll">
|
||||||
<th scope="row">{key.replace(/_/g, ' ')}</th>
|
<table className="codex-dossier-voice">
|
||||||
<td>{formatVoiceValue(value)}</td>
|
<tbody>
|
||||||
</tr>
|
{Object.entries(entity.voice).map(([key, value]) => (
|
||||||
))}
|
<tr key={key}>
|
||||||
</tbody>
|
<th scope="row">{key.replace(/_/g, ' ')}</th>
|
||||||
</table>
|
<td>{formatVoiceValue(value)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="codex-dossier-footer">
|
<footer className="codex-dossier-footer">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
.codex-page {
|
.codex-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
.devices-page {
|
.devices-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(124, 255, 178, 0.04), transparent 12%),
|
linear-gradient(180deg, rgba(124, 255, 178, 0.04), transparent 12%),
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
|
|
||||||
.devices-loading {
|
.devices-loading {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -140,7 +142,9 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: #d6e4dc;
|
color: #d6e4dc;
|
||||||
font-family: inherit;
|
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;
|
padding: 0.6rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
.doctrine-page {
|
.doctrine-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
padding: 1.5rem 1rem 4rem;
|
padding: 1.5rem 1rem 4rem;
|
||||||
color: #d9d4c8;
|
color: #d9d4c8;
|
||||||
background: #07070a;
|
background: #07070a;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -190,7 +191,9 @@
|
|||||||
color: #eafff3;
|
color: #eafff3;
|
||||||
padding: 0.72rem 0.8rem;
|
padding: 0.72rem 0.8rem;
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
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;
|
outline: none;
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
.ghostlog-page {
|
.ghostlog-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
padding: 2rem 1rem 4rem;
|
padding: 2rem 1rem 4rem;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at 50% 0%, rgba(178, 107, 255, 0.08), transparent 55%),
|
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;
|
text-transform: uppercase;
|
||||||
padding: 0.3rem 0 0.1rem;
|
padding: 0.3rem 0 0.1rem;
|
||||||
cursor: pointer;
|
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,
|
.ghostlog-expand:hover,
|
||||||
@@ -195,7 +197,7 @@ a.ghostlog-glyph:focus-visible {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.4rem 0.7rem;
|
||||||
min-height: 36px;
|
min-height: 44px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
.hunter-page {
|
.hunter-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
.hunters-page {
|
.hunters-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
.inventory-page {
|
.inventory-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
|
|
||||||
.inventory-loading {
|
.inventory-loading {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
background: #07070d;
|
background: #07070d;
|
||||||
color: #d8e6dd;
|
color: #d8e6dd;
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
.msg-page {
|
.msg-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
padding: 1.4rem 1rem 2rem;
|
padding: 1.4rem 1rem 2rem;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
.profile-page {
|
.profile-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||||
@@ -229,7 +230,8 @@
|
|||||||
.profile-input,
|
.profile-input,
|
||||||
.profile-textarea {
|
.profile-textarea {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.9rem;
|
/* 16px exactly — below this iOS Safari zooms the page on focus. */
|
||||||
|
font-size: 16px;
|
||||||
color: #eef7f0;
|
color: #eef7f0;
|
||||||
background: rgba(7, 7, 13, 0.9);
|
background: rgba(7, 7, 13, 0.9);
|
||||||
border: 1px solid rgba(124, 255, 178, 0.22);
|
border: 1px solid rgba(124, 255, 178, 0.22);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
.seance-loading {
|
.seance-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -43,6 +45,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
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;
|
background: #07070d;
|
||||||
color: #d3e9db;
|
color: #d3e9db;
|
||||||
font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
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: 1px solid rgba(124, 255, 178, 0.3);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: #7cffb2;
|
color: #7cffb2;
|
||||||
font-size: 0.62rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.05rem 0.35rem;
|
padding: 0.1rem 0.4rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
transition: box-shadow 140ms ease;
|
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 {
|
.tx-replay:hover {
|
||||||
@@ -1223,7 +1248,11 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #d3e9db;
|
color: #d3e9db;
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
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;
|
padding: 0.6rem 0.85rem;
|
||||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
.shop-page {
|
.shop-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* iOS: 100vh ignores the collapsing toolbar */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||||
@@ -330,7 +331,9 @@
|
|||||||
.waitlist-input,
|
.waitlist-input,
|
||||||
.waitlist-select {
|
.waitlist-select {
|
||||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
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;
|
color: #d8e6dc;
|
||||||
background: rgba(7, 7, 13, 0.8);
|
background: rgba(7, 7, 13, 0.8);
|
||||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
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
|
// as a lie. The reveal stays visible — struck through, not
|
||||||
// erased — because "you were told this and it was false" is the
|
// erased — because "you were told this and it was false" is the
|
||||||
// information, not "you were told nothing".
|
// information, not "you were told nothing".
|
||||||
if (entry.layer === frame.unreliable_layer) {
|
//
|
||||||
return { ...entry, unreliable: true }
|
// This is a FLAG, not a branch: a collapse frame can also carry
|
||||||
}
|
// `unreliable_layer` (app/passage.py computes it before the
|
||||||
if (entry.layer !== frame.layer) {
|
// 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
|
// A collapse re-seals the whole track — the rite genuinely
|
||||||
// restarts from `listen`. What the seeker already learned
|
// restarts from `listen`. What the seeker already learned
|
||||||
// stays on the layer that taught it (true or usefully false
|
// stays on the layer that taught it (true or usefully false
|
||||||
// either way); only the progress is undone, never the
|
// either way); only the progress is undone, never the
|
||||||
// knowledge and never the essence.
|
// knowledge and never the essence.
|
||||||
return collapsed ? { ...entry, status: 'sealed' as const } : entry
|
return collapsed ? { ...flagged, status: 'sealed' as const } : flagged
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...entry,
|
...flagged,
|
||||||
status: opened ? ('open' as const) : ('failed' as const),
|
status: opened ? ('open' as const) : ('failed' as const),
|
||||||
reveal: frame.reveal ?? entry.reveal,
|
reveal: frame.reveal ?? flagged.reveal,
|
||||||
essence: entry.essence + frame.essence,
|
essence: flagged.essence + frame.essence,
|
||||||
unreliable: false,
|
unreliable: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user