diff --git a/README.md b/README.md index eeaaf9e..00b6650 100644 --- a/README.md +++ b/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 `_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 diff --git a/frontend/index.html b/frontend/index.html index 8abf195..87bda01 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,20 @@ - + + Quantumancy + + + + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest index 17f131e..cdbf3d0 100644 --- a/frontend/public/manifest.webmanifest +++ b/frontend/public/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": [ diff --git a/frontend/src/App.css b/frontend/src/App.css index a553b06..5e40820 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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, diff --git a/frontend/src/components/GhostLog.css b/frontend/src/components/GhostLog.css index b2f165a..0f07d1b 100644 --- a/frontend/src/components/GhostLog.css +++ b/frontend/src/components/GhostLog.css @@ -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; + } +} diff --git a/frontend/src/components/GhostLog.tsx b/frontend/src/components/GhostLog.tsx index 9cc5788..bf010a6 100644 --- a/frontend/src/components/GhostLog.tsx +++ b/frontend/src/components/GhostLog.tsx @@ -38,6 +38,10 @@ export function GhostLog() { const [entityName, setEntityName] = useState(null) const [tells, setTells] = useState([]) const [ritualReveal, setRitualReveal] = useState(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(null) useEffect(() => { @@ -86,7 +90,9 @@ export function GhostLog() { return ( ) diff --git a/frontend/src/components/SigilDesigner.css b/frontend/src/components/SigilDesigner.css index f648581..13191f4 100644 --- a/frontend/src/components/SigilDesigner.css +++ b/frontend/src/components/SigilDesigner.css @@ -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 { diff --git a/frontend/src/components/Transcript.tsx b/frontend/src/components/Transcript.tsx index 69ce091..d39029d 100644 --- a/frontend/src/components/Transcript.tsx +++ b/frontend/src/components/Transcript.tsx @@ -84,7 +84,11 @@ export function Transcript({ entries, streaming, speakingId, onReplay }: Transcr {e.audioUrl && (