diff --git a/frontend/src/pages/CodexEntityPage.css b/frontend/src/pages/CodexEntityPage.css index ca40111..c3e4ee5 100644 --- a/frontend/src/pages/CodexEntityPage.css +++ b/frontend/src/pages/CodexEntityPage.css @@ -587,3 +587,153 @@ transition: none; } } + +/* ---- phone: the dossier as a stack of app sections ---- + Inside the phone breakpoint only; desktop above 860px is unchanged, and + nothing is hidden — the stat grid and roster are restructured, not cut. */ + +@media (max-width: 860px) { + .codex-detail-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 5rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .codex-detail-nav { + margin-bottom: 1.2rem; + } + + .codex-detail-back { + display: inline-flex; + align-items: center; + min-height: 44px; + } + + .codex-detail-state { + font-size: 0.95rem; + line-height: 1.6; + padding: 3.5rem 0; + } + + .codex-dossier { + padding: 1.3rem 1rem 1.6rem; + border-radius: 10px; + } + + .codex-dossier::before { + inset: 6px; + } + + .codex-dossier-header { + padding-bottom: 1.3rem; + margin-bottom: 1.4rem; + } + + .codex-dossier-glyph { + width: 118px; + height: 118px; + } + + .codex-dossier-epithet { + font-size: 0.95rem; + } + + .codex-dossier-section { + margin-bottom: 1.6rem; + } + + .codex-dossier-persona, + .codex-dossier-quote { + font-size: 1rem; + line-height: 1.7; + } + + .codex-dossier-none { + font-size: 0.875rem; + line-height: 1.6; + } + + /* Stats: one stacked card per fact instead of a squeezed auto-fit row. */ + .codex-dossier-stats { + grid-template-columns: 1fr; + } + + .codex-dossier-stat { + padding: 0.85rem 1rem; + min-height: 60px; + justify-content: center; + } + + .codex-dossier-stat dd { + font-size: 1rem; + } + + /* Roster rows: tall, tappable, secondary detail on its own line. */ + .codex-roster-row { + align-items: flex-start; + min-height: 60px; + padding: 0.85rem 0.9rem; + gap: 0.75rem; + } + + .codex-roster-who { + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + } + + .codex-roster-name { + font-size: 1rem; + line-height: 1.4; + } + + .codex-roster-link { + display: inline-flex; + align-items: center; + min-height: 44px; + } + + /* Its own line rather than crushed against the right edge. */ + .codex-roster-times { + margin-left: 0; + flex: 1 0 100%; + font-size: 0.875rem; + letter-spacing: 0.06em; + white-space: normal; + } + + .codex-roster-more { + font-size: 0.875rem; + line-height: 1.5; + } + + /* The one genuinely wide element: it keeps panning inside its own box. */ + .codex-dossier-voice { + font-size: 0.9rem; + } + + .codex-dossier-voice th { + font-size: 0.8rem; + } + + .codex-dossier-voice th, + .codex-dossier-voice td { + padding: 0.75rem 0.9rem; + } + + .codex-dossier-footer { + padding-top: 0.4rem; + } + + .codex-detail-cta { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 48px; + padding: 0.85rem 1rem; + text-align: center; + } +} diff --git a/frontend/src/pages/CodexPage.css b/frontend/src/pages/CodexPage.css index 31cc1a3..65c36e0 100644 --- a/frontend/src/pages/CodexPage.css +++ b/frontend/src/pages/CodexPage.css @@ -411,3 +411,109 @@ transition: none; } } + +/* ---- phone: the app, not a shrunken desktop grid ---- + Everything below is inside the phone breakpoint (MobileNav's own + MOBILE_MAX). Desktop rendering above 860px is untouched. Nothing here + hides content — the registry cards are re-laid-out as tall rows. */ + +@media (max-width: 860px) { + .codex-page { + /* Safe-area on all four edges. The extra top space clears the fixed + 48px hamburger parked at top-left (env(top) + 0.6rem). */ + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 1rem) + calc(env(safe-area-inset-bottom, 0px) + 5rem) + calc(env(safe-area-inset-left, 0px) + 1rem); + } + + .codex-header { + margin-bottom: 1.8rem; + } + + .codex-back { + display: inline-flex; + align-items: center; + min-height: 44px; + margin-bottom: 0.6rem; + } + + .codex-subtitle { + font-size: 0.9rem; + line-height: 1.55; + } + + /* Filters stay a scan-able block of pills; every one a real tap target. */ + .codex-controls { + gap: 0.9rem 1rem; + margin-bottom: 1.8rem; + } + + .codex-pill { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 0 1rem; + } + + .codex-state { + font-size: 0.95rem; + line-height: 1.6; + padding: 3rem 0; + } + + /* One column of substantial rows rather than squeezed columns. */ + .codex-grid { + grid-template-columns: 1fr; + gap: 0.9rem; + } + + .codex-card { + flex-direction: row; + align-items: flex-start; + text-align: left; + gap: 1rem; + min-height: 60px; + padding: 1rem 1rem 1.1rem; + } + + .codex-glyph-wrap { + flex: 0 0 auto; + margin-bottom: 0; + gap: 0.3rem; + } + + .codex-glyph { + width: 60px; + height: 60px; + } + + .codex-card-body { + align-items: flex-start; + text-align: left; + min-width: 0; + flex: 1 1 auto; + gap: 0.35rem; + } + + .codex-card-name { + font-size: 1.15rem; + overflow-wrap: anywhere; + } + + .codex-card-epithet { + font-size: 0.9rem; + } + + .codex-card-quote { + font-size: 0.95rem; + line-height: 1.6; + } + + .codex-card-meta { + font-size: 0.875rem; + letter-spacing: 0.04em; + line-height: 1.5; + } +} diff --git a/frontend/src/pages/DevicesPage.css b/frontend/src/pages/DevicesPage.css index c80e541..55fdb54 100644 --- a/frontend/src/pages/DevicesPage.css +++ b/frontend/src/pages/DevicesPage.css @@ -448,3 +448,159 @@ grid-template-columns: 1fr; } } + +/* ---- phone: the instrument panel, one card at a time ---- + Phone breakpoint only; desktop above 860px is unchanged. The device grid + collapses to a single column and the token reveal stacks — nothing is + removed, least of all the one-time token or its warning. */ + +@media (max-width: 860px) { + .devices-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 5rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .devices-loading { + padding: 0 calc(env(safe-area-inset-left, 0px) + 1rem); + font-size: 1rem; + } + + .devices-header { + margin-bottom: 1.6rem; + } + + .devices-back { + display: inline-flex; + align-items: center; + min-height: 44px; + margin-bottom: 0.6rem; + } + + .devices-subtitle { + font-size: 0.95rem; + line-height: 1.55; + } + + .devices-pair, + .devices-dashboard { + margin-bottom: 1.6rem; + padding: 1.2rem 1rem; + border-radius: 10px; + } + + .devices-section-title { + font-size: 0.85rem; + } + + .devices-state { + font-size: 0.95rem; + line-height: 1.6; + } + + /* Pairing: label above, field and button full width. */ + .pair-form { + flex-direction: column; + align-items: stretch; + gap: 0.8rem; + } + + .pair-input { + flex: 1 1 auto; + /* 16px exactly — iOS zooms the page on focus below this. */ + font-size: 16px; + min-height: 48px; + } + + .pair-submit { + width: 100%; + min-height: 48px; + font-size: 0.85rem; + } + + .pair-message { + font-size: 0.875rem; + line-height: 1.55; + } + + .token-reveal { + padding: 1rem; + } + + .token-reveal-warning { + align-items: flex-start; + font-size: 0.875rem; + line-height: 1.55; + } + + .token-reveal-box { + flex-direction: column; + align-items: stretch; + } + + .token-reveal-value { + font-size: 0.95rem; + line-height: 1.5; + } + + .token-copy, + .token-confirm { + width: 100%; + min-height: 48px; + font-size: 0.8rem; + } + + .token-confirm { + align-self: stretch; + } + + /* One device per row — a real card, not a squeezed column. */ + .device-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .device-card { + padding: 1rem; + gap: 0.8rem; + } + + .device-name { + font-size: 1.1rem; + overflow-wrap: anywhere; + } + + .device-lastseen { + font-size: 0.8rem; + } + + .device-noreadings { + font-size: 0.9rem; + } + + /* Sensor readouts become tappable-height labelled rows. */ + .sensor-grid { + gap: 0; + } + + .sensor-row { + min-height: 44px; + align-items: center; + padding: 0.5rem 0; + } + + .sensor-type { + font-size: 0.9rem; + overflow-wrap: anywhere; + } + + .sensor-value { + font-size: 1.05rem; + } + + .sensor-unit { + font-size: 0.8rem; + } +} diff --git a/frontend/src/pages/DoctrinePage.css b/frontend/src/pages/DoctrinePage.css index cc38f00..537103b 100644 --- a/frontend/src/pages/DoctrinePage.css +++ b/frontend/src/pages/DoctrinePage.css @@ -218,3 +218,76 @@ transition: none; } } + +/* ---- phone: a reading screen ---- + Phone breakpoint only; the desktop gutter-numeral layout at 56rem and up + is untouched. Nothing is hidden — every article, margin note and source + line still renders. */ + +@media (max-width: 860px) { + .doctrine-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 1rem) + calc(env(safe-area-inset-bottom, 0px) + 4rem) + calc(env(safe-area-inset-left, 0px) + 1rem); + } + + .doctrine-header { + margin-bottom: 2rem; + } + + .doctrine-kicker { + font-size: 0.9rem; + } + + .doctrine-subtitle { + font-size: 1.05rem; + line-height: 1.6; + } + + .doctrine-creed { + font-size: 0.95rem; + line-height: 1.7; + padding: 1rem; + } + + .doctrine-article { + margin-bottom: 2.4rem; + padding-top: 1.5rem; + } + + .doctrine-body { + font-size: 1rem; + line-height: 1.75; + } + + .doctrine-margin { + padding: 1rem; + } + + .doctrine-margin-text { + font-size: 1rem; + line-height: 1.7; + } + + .doctrine-source { + font-size: 0.875rem; + line-height: 1.6; + } + + .doctrine-footer { + padding-top: 1.6rem; + } + + .doctrine-footer p { + font-size: 1rem; + line-height: 1.7; + } + + .doctrine-cta { + width: 100%; + min-height: 48px; + font-size: 0.95rem; + } +} diff --git a/frontend/src/pages/GhostLogPage.css b/frontend/src/pages/GhostLogPage.css index 34c13c4..0ffa1c7 100644 --- a/frontend/src/pages/GhostLogPage.css +++ b/frontend/src/pages/GhostLogPage.css @@ -227,3 +227,108 @@ a.ghostlog-glyph:focus-visible { font-size: 1.3rem; } } + +/* ---- phone: the log as tall, readable entries ---- + Phone breakpoint only; desktop above 860px is unchanged. Echoes, counts + and timestamps all still render — the entries just get room. */ + +@media (max-width: 860px) { + .ghostlog-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 4rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .ghostlog-back { + display: inline-flex; + align-items: center; + min-height: 44px; + } + + .ghostlog-title { + font-size: 1.6rem; + margin-top: 0.4rem; + } + + .ghostlog-subtitle { + font-size: 0.9rem; + line-height: 1.55; + } + + .ghostlog-note { + font-size: 0.95rem; + line-height: 1.65; + } + + .ghostlog-filters { + gap: 0.5rem; + margin-bottom: 1.2rem; + } + + .ghostlog-filter { + flex: 1 1 auto; + min-height: 48px; + padding: 0.5rem 0.9rem; + } + + .ghostlog-list { + gap: 0.8rem; + } + + .ghostlog-card { + min-height: 72px; + padding: 1rem; + gap: 0.85rem; + border-radius: 10px; + } + + a.ghostlog-glyph { + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + } + + .ghostlog-who-link { + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + padding: 0.15rem 0; + min-height: 44px; + justify-content: center; + } + + .ghostlog-name { + font-size: 1.1rem; + overflow-wrap: anywhere; + } + + .ghostlog-epithet { + font-size: 0.9rem; + } + + .ghostlog-meta { + margin-top: 0.45rem; + font-size: 0.875rem; + line-height: 1.5; + } + + .ghostlog-echoes { + margin-top: 0.7rem; + padding-left: 0.8rem; + } + + .ghostlog-echo { + margin: 0.35rem 0; + font-size: 0.95rem; + line-height: 1.6; + } + + .ghostlog-expand { + min-height: 48px; + font-size: 0.8rem; + } +} diff --git a/frontend/src/pages/HunterPage.css b/frontend/src/pages/HunterPage.css index c782410..ea43287 100644 --- a/frontend/src/pages/HunterPage.css +++ b/frontend/src/pages/HunterPage.css @@ -386,3 +386,147 @@ transition: none; } } + +/* ---- phone: one hunter, one screen ---- + Phone breakpoint only; desktop above 860px is unchanged. The stat list and + the entity grid are re-stacked, never trimmed. */ + +@media (max-width: 860px) { + .hunter-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 5rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .hunter-state { + font-size: 0.95rem; + line-height: 1.6; + } + + .hunter-missing { + margin: 2rem auto; + padding: 1.5rem 1.1rem; + } + + .hunter-missing-body { + font-size: 0.95rem; + } + + .hunter-missing-link { + justify-content: center; + width: 100%; + } + + .hunter-hero { + margin-bottom: 2rem; + padding: 1.5rem 1rem; + border-radius: 10px; + } + + .hunter-handle { + font-size: 0.875rem; + } + + .hunter-rank { + font-size: 0.85rem; + } + + .hunter-progress-note { + font-size: 0.875rem; + line-height: 1.5; + } + + /* Stats stop competing for a single line: one labelled row each. */ + .hunter-stats { + display: grid; + grid-template-columns: 1fr; + gap: 0; + width: 100%; + margin-top: 1.2rem; + border: 1px solid rgba(124, 255, 178, 0.14); + border-radius: 8px; + overflow: hidden; + } + + .hunter-stat { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + gap: 0.8rem; + min-height: 52px; + padding: 0.7rem 0.9rem; + text-align: left; + border-bottom: 1px solid rgba(124, 255, 178, 0.1); + } + + .hunter-stat:last-child { + border-bottom: none; + } + + .hunter-stat dd { + font-size: 1.1rem; + } + + .hunter-bio { + font-size: 1rem; + line-height: 1.7; + } + + .hunter-message-link { + width: 100%; + min-height: 48px; + } + + .hunter-section-title { + font-size: 0.95rem; + margin-bottom: 1rem; + } + + /* Recent spirits: one tall tappable row each. */ + .hunter-grid { + grid-template-columns: 1fr; + gap: 0.8rem; + } + + .hunter-card { + flex-direction: row; + align-items: center; + text-align: left; + gap: 0.9rem; + min-height: 72px; + padding: 0.9rem 1rem; + flex-wrap: wrap; + } + + .hunter-card-glyph-wrap { + flex: 0 0 auto; + margin-bottom: 0; + } + + .hunter-card-glyph { + width: 54px; + height: 54px; + } + + .hunter-card-name { + flex: 1 1 60%; + min-width: 0; + font-size: 1.1rem; + overflow-wrap: anywhere; + } + + /* Own line beneath the name — dimmed secondary detail, still present. */ + .hunter-card-epithet { + flex: 1 0 100%; + order: 3; + margin: 0; + font-size: 0.9rem; + } + + .hunter-rarity { + order: 4; + align-self: flex-start; + } +} diff --git a/frontend/src/pages/HuntersPage.css b/frontend/src/pages/HuntersPage.css index 541a79e..93b1124 100644 --- a/frontend/src/pages/HuntersPage.css +++ b/frontend/src/pages/HuntersPage.css @@ -178,3 +178,85 @@ transition: none; } } + +/* ---- phone: the roster as a list of tall rows ---- + Phone breakpoint only; desktop above 860px keeps its card grid, and every + piece of a card is still rendered — only the axis changes. */ + +@media (max-width: 860px) { + .hunters-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 5rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .hunters-header { + margin-bottom: 1.6rem; + } + + .hunters-subtitle { + font-size: 0.9rem; + line-height: 1.55; + } + + .hunters-state { + font-size: 0.95rem; + line-height: 1.6; + padding: 2.5rem 0; + } + + .hunters-grid { + grid-template-columns: 1fr; + gap: 0.8rem; + } + + /* Glyph in a fixed left column, everything else stacked beside it. + Two-column grid because the markup has no wrapper element to flex. */ + .hunters-card { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + column-gap: 0.9rem; + row-gap: 0.15rem; + align-items: center; + text-align: left; + /* Primary surface of this screen — give it real height. */ + min-height: 76px; + padding: 0.9rem 1rem; + } + + .hunters-card > .ghost-glyph { + grid-column: 1; + grid-row: 1 / span 4; + align-self: center; + } + + .hunters-card-name, + .hunters-card-rank, + .hunters-card-bar, + .hunters-card-meta { + grid-column: 2; + min-width: 0; + text-align: left; + } + + .hunters-card-name { + margin: 0; + font-size: 1.1rem; + overflow-wrap: anywhere; + } + + .hunters-card-rank { + margin-top: 0.15rem; + } + + .hunters-card-bar { + margin: 0.45rem 0 0.3rem; + } + + .hunters-card-meta { + font-size: 0.875rem; + letter-spacing: 0.04em; + } +} diff --git a/frontend/src/pages/InventoryPage.css b/frontend/src/pages/InventoryPage.css index 50f73fe..ef8657f 100644 --- a/frontend/src/pages/InventoryPage.css +++ b/frontend/src/pages/InventoryPage.css @@ -80,3 +80,88 @@ flex-direction: column; gap: 2.4rem; } + +/* ---- phone: the shelf as a single stacked column ---- + Phone breakpoint only; desktop above 860px is unchanged. The offer/item + grids and the sigil designer are re-stacked here, never hidden. */ + +@media (max-width: 860px) { + .inventory-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 5rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .inventory-loading { + padding: 0 calc(env(safe-area-inset-left, 0px) + 1rem); + font-size: 1rem; + } + + .inventory-header { + margin-bottom: 1.6rem; + } + + .inventory-back { + display: inline-flex; + align-items: center; + min-height: 44px; + font-size: 0.875rem; + } + + .inventory-subtitle { + font-size: 0.95rem; + line-height: 1.55; + } + + .inventory-main { + gap: 1.8rem; + } + + /* Panels owned by components/, restacked for the phone from here so their + desktop stylesheets stay untouched. */ + .inventory-page .inv-offer-grid, + .inventory-page .inv-item-grid { + grid-template-columns: 1fr; + } + + .inventory-page .inv-offer-card, + .inventory-page .inv-item-card, + .inventory-page .inv-term-row { + min-height: 60px; + } + + .inventory-page .inv-buy-btn { + width: 100%; + min-height: 48px; + } + + .inventory-page .inv-key { + font-size: 1rem; + overflow-wrap: anywhere; + } + + .inventory-page .inv-state, + .inventory-page .inv-price, + .inventory-page .inv-item-type, + .inventory-page .inv-offer-error { + font-size: 0.875rem; + line-height: 1.5; + } + + .inventory-page .sigil-designer { + grid-template-columns: 1fr; + } + + .inventory-page .sigil-rune-btn, + .inventory-page .sigil-save-btn { + min-height: 48px; + } + + .inventory-page .sigil-name-input { + /* 16px exactly — iOS zooms the page on focus below this. */ + font-size: 16px; + min-height: 48px; + } +} diff --git a/frontend/src/pages/MessagesPage.css b/frontend/src/pages/MessagesPage.css index bf67326..2aa408c 100644 --- a/frontend/src/pages/MessagesPage.css +++ b/frontend/src/pages/MessagesPage.css @@ -153,3 +153,124 @@ .msg-compose-row button { min-height: 44px; flex: 1 1 auto; } .msg-bubble { max-width: 92%; } } + +/* ---- phone: inbox and thread as native-feeling screens ---- + Phone breakpoint only; desktop above 860px is unchanged. Nothing here + hides a message, a name or a timestamp. */ + +@media (max-width: 860px) { + .msg-page { + min-height: 100vh; + min-height: 100dvh; + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 2rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .msg-title { + font-size: 1.45rem; + } + + .msg-back { + display: inline-flex; + align-items: center; + min-height: 44px; + font-size: 0.85rem; + } + + .msg-empty { + font-size: 0.95rem; + line-height: 1.7; + } + + /* Conversation rows are this screen's main surface. */ + .msg-row { + min-height: 68px; + padding: 0.9rem 0.55rem; + gap: 0.85rem; + } + + .msg-row-body { + gap: 0.25rem; + } + + .msg-row-top { + align-items: baseline; + } + + .msg-row-name { + font-size: 1rem; + } + + .msg-row-when { + font-size: 0.8rem; + } + + .msg-row-last { + font-size: 0.875rem; + line-height: 1.45; + } + + .msg-unread { + align-self: center; + min-width: 26px; + min-height: 26px; + padding: 0.2rem 0.45rem; + font-size: 0.8rem; + } + + /* ---- thread ---- */ + + .msg-thread-name { + display: inline-flex; + align-items: center; + min-height: 44px; + font-size: 1rem; + } + + .msg-thread { + gap: 0.6rem; + padding: 0.6rem 0 1rem; + } + + .msg-bubble { + max-width: 88%; + padding: 0.7rem 0.85rem; + border-radius: 10px; + } + + .msg-bubble-body { + font-size: 0.95rem; + line-height: 1.6; + } + + .msg-bubble-when { + font-size: 0.75rem; + } + + .msg-input { + /* 16px exactly, again — iOS zooms on focus below this and stays zoomed. */ + font-size: 16px; + padding: 0.75rem; + min-height: 64px; + } + + .msg-compose-row { + gap: 0.8rem; + } + + .msg-compose-row button { + min-height: 48px; + flex: 1 1 auto; + } + + .msg-count { + font-size: 0.8rem; + } + + .msg-error { + font-size: 0.875rem; + } +} diff --git a/frontend/src/pages/ProfilePage.css b/frontend/src/pages/ProfilePage.css index 47a8f2c..d79bd15 100644 --- a/frontend/src/pages/ProfilePage.css +++ b/frontend/src/pages/ProfilePage.css @@ -393,3 +393,145 @@ transition: none; } } + +/* ---- phone: the editor as a stack of full-width panels ---- + Phone breakpoint only; desktop above 860px is unchanged. Every field, + hint, chip and action still renders — they are simply given room. */ + +@media (max-width: 860px) { + .profile-page { + padding: + calc(env(safe-area-inset-top, 0px) + 4.4rem) + calc(env(safe-area-inset-right, 0px) + 0.9rem) + calc(env(safe-area-inset-bottom, 0px) + 5rem) + calc(env(safe-area-inset-left, 0px) + 0.9rem); + } + + .profile-header { + margin-bottom: 1.6rem; + } + + .profile-subtitle { + font-size: 0.9rem; + line-height: 1.55; + } + + .profile-wanderer { + padding: 1.1rem 1rem; + } + + .profile-wanderer-line { + font-size: 0.95rem; + } + + .profile-wanderer-claim { + justify-content: center; + width: 100%; + } + + .profile-state { + font-size: 0.95rem; + line-height: 1.6; + } + + .profile-form { + gap: 1.1rem; + } + + .profile-panel { + padding: 1.2rem 1rem; + gap: 1.1rem; + } + + .profile-panel-title { + font-size: 1rem; + } + + .profile-note { + font-size: 0.875rem; + line-height: 1.65; + } + + .profile-preview-name { + font-size: 1.25rem; + } + + /* Chips get their own generous rows rather than a cramped wrap. */ + .profile-chips { + gap: 0.6rem; + } + + .profile-chip { + flex: 1 1 auto; + min-height: 48px; + font-size: 0.8rem; + } + + .profile-field { + gap: 0.5rem; + } + + .profile-label { + font-size: 0.8rem; + } + + .profile-input, + .profile-textarea { + /* 16px exactly — iOS zooms the page on focus below this. */ + font-size: 16px; + min-height: 48px; + padding: 0.8rem 0.85rem; + } + + .profile-hint, + .profile-count { + font-size: 0.875rem; + line-height: 1.5; + } + + .profile-error { + font-size: 0.875rem; + line-height: 1.5; + } + + .profile-hue-slider { + min-height: 48px; + } + + .profile-toggle { + min-height: 48px; + } + + .profile-toggle input { + width: 26px; + height: 26px; + } + + .profile-toggle-text { + font-size: 0.85rem; + } + + /* Actions stack: full-width primary, then the secondary links beneath. */ + .profile-actions { + flex-direction: column; + align-items: stretch; + gap: 0.8rem; + } + + .profile-save { + width: 100%; + min-height: 48px; + font-size: 0.85rem; + } + + .profile-view-link { + justify-content: center; + min-height: 48px; + font-size: 0.8rem; + } + + .profile-saved { + text-align: center; + font-size: 0.875rem; + } +} diff --git a/frontend/src/pages/SeancePage.css b/frontend/src/pages/SeancePage.css index fc325c3..7b2d849 100644 --- a/frontend/src/pages/SeancePage.css +++ b/frontend/src/pages/SeancePage.css @@ -1648,3 +1648,641 @@ padding: 0.3rem 0.55rem; } } + +/* ======================================================================== + THE PHONE APP (<= 860px) + ======================================================================== + Everything below this line is inert above 860px. Not one selector in this + section is reachable at desktop widths, and nothing above this line was + changed, so the desktop séance renders exactly as it did before. + 860px is deliberately the same threshold MobileNav uses (it is + display:none from 861px up), so the two mobile shells appear together. + + The brief was not "make it responsive" — it was "the app should be its own + thing". So on a phone this is a native-shaped app, not a squeezed page: + + [ header strip ] brand, the 'more' handle + [ mode grid ] all six modes, named, 3x2, always visible + [ STAGE ] the board / matrix / graphs — the dominant surface + [ dock ] two sheet handles + the ask row + + ONE pattern carries every secondary surface: a bottom sheet. The links + rail, the whole instrument column (dossier, veil, devices, ritual, + passage, judgment, mode panel) and the transmission log each slide up over + the stage from the dock. Same geometry, same grabber, same close, same + backdrop, one at a time. Nothing is removed and no markup is hidden that a + handle does not open — a sheet is a door, not a deletion. + + Type floor 14px for anything read, inputs exactly 16px (iOS zooms below + that and never zooms back), every target >= 44px, all four safe-area + insets honoured, and the page itself never scrolls sideways. */ + +/* Mobile-only furniture: no box at all until the breakpoint claims it. */ +.seance-dock, +.seance-sheet-close, +.seance-sheet-backdrop { + display: none; +} + +@media (max-width: 860px) { + /* ---------- shell ---------- */ + + .seance-page { + /* Fallback first for browsers without dynamic viewport units; the dvh + line then wins where it is understood, so the dock is not left under + a retracting URL bar. */ + height: 100vh; + height: 100dvh; + overflow-x: hidden; + /* The page's own padding already carries every safe-area inset (see the + shell rules at the top of this file), so in-flow children below use + plain lengths — only the position:fixed sheets re-apply env(). */ + } + + /* ---------- header strip ---------- */ + + .seance-topbar { + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + min-height: 56px; + padding: 8px 12px 10px; + /* The app's fixed 48px hamburger sits at env(safe-area-inset-left) + + 0.6rem. The page has already inset us by the safe area, so clearing it + costs 0.6rem + 48px + a little breathing room. Nothing of ours is ever + placed under that corner. */ + padding-left: 3.9rem; + /* An opaque strip instead of a blur: backdrop-filter would make this + header the containing block for the position:fixed sheet inside it. */ + backdrop-filter: none; + background: rgba(12, 12, 20, 0.97); + overflow-x: hidden; + } + + .seance-brand { + flex: 1 1 auto; + min-width: 0; + font-size: 0.95rem; + letter-spacing: 0.14em; + overflow: hidden; + text-overflow: ellipsis; + } + + /* ---------- the mode control ---------- */ + /* A 3x2 segmented grid. Chosen over a mode sheet because switching modes + is the single most frequent act in a séance and a sheet would cost a tap + *and* hide which mode is live; a wrapped segmented control shows all six + names and the current selection permanently, for ~100px of height. */ + + .seance-modes-rail { + order: 3; + flex: 1 1 100%; + } + + .seance-modes { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + width: 100%; + /* Undo the horizontal-scroll rail: with a grid there is nothing behind + an edge, so there is nothing left to scroll to. */ + overflow: visible; + justify-content: stretch; + } + + /* The fade + arrow cues pointed at off-screen tabs. There are none now. */ + .seance-modes-rail::before, + .seance-modes-rail::after, + .seance-modes-cue { + display: none; + } + + .seance-modes .mode-tab { + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 auto; + min-height: 48px; + padding: 0.4rem 0.35rem; + font-size: 0.88rem; + letter-spacing: 0.04em; + line-height: 1.15; + text-align: center; + overflow-wrap: anywhere; + border-color: rgba(124, 255, 178, 0.16); + border-radius: 9px; + background: rgba(16, 16, 26, 0.72); + color: rgba(211, 233, 219, 0.72); + } + + .seance-modes .mode-tab.active { + color: #7cffb2; + border-color: rgba(124, 255, 178, 0.55); + background: rgba(124, 255, 178, 0.1); + } + + .seance-nav-toggle { + display: inline-flex; + order: 2; + flex: 0 0 auto; + min-height: 44px; + font-size: 0.88rem; + border-radius: 9px; + } + + /* ---------- status lines ---------- */ + /* Absolute banners pinned under a fixed-height desktop topbar cannot + survive a header that wraps. On a phone they simply become the app's + status row, in flow between the header and the stage — full width, + wrapping, no magic offsets. `bannerIn` is dropped with them because it + animates the translate(-50%) that no longer applies. */ + + .seance-conn-banner, + .seance-wanderer-note { + position: static; + transform: none; + width: auto; + margin: 0; + justify-content: center; + text-align: center; + white-space: normal; + border-radius: 0; + font-size: 0.9rem; + line-height: 1.45; + letter-spacing: 0.06em; + padding: 0.55rem 0.9rem; + } + + .seance-conn-banner { + animation: flickerAnim 4s linear infinite; + } + + .seance-wanderer-note { + flex-wrap: wrap; + } + + .wanderer-note-dismiss { + min-width: 44px; + min-height: 44px; + font-size: 1.2rem; + } + + /* ---------- the stage ---------- */ + + .seance-main { + flex-direction: column; + /* The stage is exactly the space left over; the sheets carry everything + that used to fight the board for it, so nothing here scrolls. */ + overflow: hidden; + gap: 8px; + padding: 10px 12px 0; + } + + .seance-board-wrap { + flex: 1 1 auto; + /* Deliberately generous: on a 390x844 phone this leaves the board ~55% + of the screen instead of the strip it used to get. */ + min-height: 240px; + } + + /* The BOARD / MATRIX / GRAPHS switcher overlays the top of the stage as a + proper segmented control. Specificity beats MatrixView.css's own + `.seance-view-tabs .mode-tab` regardless of stylesheet order. */ + .seance-board-wrap .seance-view-tabs { + left: 12px; + right: 12px; + transform: none; + display: flex; + gap: 4px; + border-radius: 10px; + } + + .seance-board-wrap .seance-view-tabs .mode-tab { + flex: 1 1 0; + min-height: 44px; + padding: 0.3rem 0.4rem; + font-size: 0.88rem; + border-radius: 8px; + } + + .seance-status-overlay { + top: 64px; + max-width: calc(100% - 24px); + padding: 0.5rem 0.9rem; + } + + .status-text { + font-size: 0.9rem; + } + + /* ---------- the dock ---------- */ + + .seance-bottom { + gap: 10px; + padding: 8px 12px 10px; + background: linear-gradient(0deg, rgba(7, 7, 13, 0.98) 62%, rgba(7, 7, 13, 0)); + } + + .seance-dock { + display: flex; + gap: 10px; + } + + .seance-dock-btn { + flex: 1 1 0; + min-width: 0; + min-height: 52px; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 0.2rem 0.5rem; + padding: 0.4rem 0.6rem; + background: rgba(16, 16, 26, 0.92); + border: 1px solid rgba(124, 255, 178, 0.28); + border-radius: 10px; + color: #7cffb2; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.88rem; + letter-spacing: 0.06em; + line-height: 1.2; + text-align: center; + cursor: pointer; + touch-action: manipulation; + } + + .seance-dock-btn[aria-expanded='true'] { + border-color: rgba(124, 255, 178, 0.6); + background: rgba(124, 255, 178, 0.1); + box-shadow: inset 0 0 12px rgba(124, 255, 178, 0.08); + } + + .seance-dock-btn .dock-glyph { + color: #b26bff; + font-size: 1rem; + } + + .seance-dock-btn .dock-label { + overflow-wrap: anywhere; + } + + .seance-dock-btn .anomaly-count { + flex: 1 1 100%; + font-size: 0.88rem; + } + + /* ---------- the ask row ---------- */ + + .seance-contact { + flex-wrap: wrap; + gap: 8px; + } + + .seance-contact input { + flex: 1 1 100%; + min-height: 48px; + /* Exactly 16px. Anything smaller and iOS Safari zooms the page on focus + and never zooms back out. */ + font-size: 16px; + } + + .ask-btn, + .summon-btn { + flex: 1 1 0; + min-height: 48px; + padding: 0 0.6rem; + } + + .ask-btn { + font-size: 0.92rem; + letter-spacing: 0.12em; + } + + .summon-btn { + font-size: 0.95rem; + letter-spacing: 0.18em; + } + + /* ---------- the one sheet pattern ---------- */ + + .seance-sheet-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 44; + width: 100%; + padding: 0; + border: none; + background: rgba(2, 2, 6, 0.66); + backdrop-filter: blur(2px); + cursor: pointer; + } + + .seance-top-right, + .seance-side, + .seance-transcript-wrap { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 45; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; + width: auto; + height: auto; + /* Tall enough to be the screen's subject, short enough that the stage + behind it still says which app you are in. */ + max-height: 84vh; + max-height: 84dvh; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + /* Fixed to the viewport, so unlike the in-flow rules above these must + carry the insets themselves — all four edges, because in landscape the + cutout moves to a side. */ + padding: 0.6rem calc(env(safe-area-inset-right, 0px) + 1rem) + calc(env(safe-area-inset-bottom, 0px) + 1.1rem) + calc(env(safe-area-inset-left, 0px) + 1rem); + background: rgba(10, 10, 17, 0.98); + border: 1px solid rgba(124, 255, 178, 0.22); + border-bottom: none; + border-radius: 16px 16px 0 0; + box-shadow: 0 -14px 44px rgba(0, 0, 0, 0.66); + transform: translateY(101%); + visibility: hidden; + transition: transform 220ms ease, visibility 220ms ease; + } + + /* The grabber. Sticky, so it stays put while a long sheet scrolls. */ + .seance-top-right::before, + .seance-side::before, + .seance-transcript-wrap::before { + content: ''; + position: sticky; + top: 0; + z-index: 1; + flex: 0 0 auto; + align-self: center; + width: 46px; + height: 4px; + border-radius: 999px; + background: rgba(124, 255, 178, 0.4); + } + + [data-sheet][data-open='true'] { + transform: none; + visibility: visible; + } + + @media (prefers-reduced-motion: reduce) { + .seance-top-right, + .seance-side, + .seance-transcript-wrap { + transition: none; + } + } + + .seance-sheet-close { + display: flex; + align-items: center; + justify-content: center; + align-self: flex-end; + flex: 0 0 auto; + width: 44px; + height: 44px; + margin-top: -0.2rem; + padding: 0; + background: transparent; + border: 1px solid rgba(124, 255, 178, 0.22); + border-radius: 10px; + color: rgba(211, 233, 219, 0.75); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + touch-action: manipulation; + } + + /* ---------- sheet: the ways (links, language, passive, departure) ---------- */ + + .seance-top-right .seance-codex-link, + .seance-top-right .seance-logout, + .seance-top-right .passive-toggle { + display: flex; + align-items: center; + min-height: 48px; + padding: 0 0.9rem; + border: 1px solid rgba(178, 107, 255, 0.28); + border-radius: 10px; + background: rgba(178, 107, 255, 0.05); + font-size: 0.95rem; + letter-spacing: 0.1em; + } + + .seance-top-right .seance-logout { + justify-content: flex-start; + } + + .passive-label { + font-size: 0.9rem; + } + + .lang-toggle { + display: flex; + border-radius: 10px; + } + + .lang-toggle button { + flex: 1 1 0; + min-height: 48px; + font-size: 0.95rem; + } + + .seance-user { + max-width: none; + padding: 0 0.3rem; + font-size: 0.9rem; + } + + .conn-dot { + width: 12px; + height: 12px; + align-self: flex-start; + margin-left: 0.3rem; + } + + /* ---------- sheet: the instruments ---------- */ + + .seance-side { + padding-top: 0.6rem; + } + + /* The live instrument comes first: opening this sheet from the dock handle + (which is labelled with the active mode) must land on that mode's panel, + not scroll past six readouts to reach it. DOM order is untouched, so the + desktop column keeps the dossier at the top where it belongs. */ + .seance-side .mode-panel { + order: -1; + } + + .mode-panel { + padding: 1rem; + } + + /* Readable body copy: 14px floor for anything that is prose or a value, + not the 11-12px the desktop column packs into 344px. */ + .dossier-persona, + .dossier-quotes li, + .panel-notice p, + .ouija-drift, + .wire-hint, + .dossier-nonehint { + font-size: 0.9rem; + line-height: 1.6; + } + + .panel-notice h4, + .dossier-title, + .panel-title { + font-size: 0.88rem; + } + + .tel-key, + .tel-val, + .evp-readout, + .radio-readout, + .emf-readout, + .wire-count, + .ouija-history, + .evp-listening, + .emf-listening, + .evp-level, + .tuned-freq, + .emf-baseline, + .troubleshoot, + .voice-params dt, + .voice-params dd { + font-size: 0.88rem; + } + + .voice-params { + grid-template-columns: 1fr; + } + + .voice-params dd { + max-width: 60%; + } + + .rarity-badge, + .dossier-badge { + font-size: 0.88rem; + padding: 0.3rem 0.65rem; + letter-spacing: 0.1em; + } + + /* Every action in a sheet is a thumb target. */ + .seance-side .panel-action, + .seance-side .panel-subaction, + .seance-side .dossier-header { + min-height: 48px; + font-size: 0.88rem; + } + + .seance-side .panel-action, + .seance-side .panel-subaction { + display: inline-flex; + align-items: center; + justify-content: center; + align-self: stretch; + padding: 0.6rem 1rem; + border-radius: 9px; + } + + .emf-actions { + flex-wrap: wrap; + } + + .emf-actions .panel-action, + .emf-actions .panel-subaction { + flex: 1 1 40%; + } + + /* The spectrum canvases are the one genuinely wide thing on the page. They + already lay out at width:100%, but this guarantees that if a scope ever + asks for more room it scrolls inside its own frame rather than dragging + the page sideways. */ + .seance-side .scope, + .seance-side .scope-canvases { + max-width: 100%; + overflow-x: auto; + } + + .emf-gauge { + height: 160px; + } + + /* ---------- sheet: the transmission log ---------- */ + + .seance-transcript-wrap { + /* Was a fixed 168px box wedged above the ask row; as a sheet it gets the + height a log actually needs. */ + height: auto; + min-height: 0; + overflow: hidden auto; + } + + .seance-transcript-title { + flex: 0 0 auto; + padding: 0 0.2rem 0.5rem; + font-size: 0.88rem; + letter-spacing: 0.18em; + } + + .seance-transcript-title .anomaly-count { + font-size: 0.88rem; + } + + .transcript { + flex: 1 1 auto; + min-height: 42vh; + padding: 0.2rem; + font-size: 0.95rem; + line-height: 1.6; + gap: 0.5rem; + } + + .tx-src, + .tx-utterance .tx-speaker, + .tx-system, + .tx-streaming .tx-stream-label { + font-size: 0.88rem; + } + + /* ---------- toasts ---------- */ + /* The header no longer has a fixed height to hang from, so alerts dock to + the bottom of the screen the way a phone's do. Above the sheets' z-index + backdrop? No — deliberately below, so an open sheet is never argued + with; the toast is still there when the sheet closes. */ + + .seance-toasts { + position: fixed; + top: auto; + right: calc(env(safe-area-inset-right, 0px) + 12px); + left: calc(env(safe-area-inset-left, 0px) + 12px); + bottom: calc(env(safe-area-inset-bottom, 0px) + 12px); + z-index: 43; + max-width: none; + } + + .seance-toast { + font-size: 0.88rem; + line-height: 1.5; + } + + .toast-dismiss { + min-width: 44px; + min-height: 44px; + font-size: 1.3rem; + } +} diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index d268c0c..fa7328f 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -153,11 +153,34 @@ function SeanceSession({ username }: { username: string }) { const [question, setQuestion] = useState('') const [dossierOpen, setDossierOpen] = useState(true) const [view, setView] = useState('board') - // The topbar's links/toggles don't fit a phone in one row. Rather than - // dropping any of them, they live behind this disclosure below 560px; at - // desktop widths the rail is always shown and the toggle is not rendered - // visible by CSS, so nothing changes there. - const [navOpen, setNavOpen] = useState(false) + // ---- mobile app shell: one sheet metaphor for every secondary surface ---- + // + // On a phone the séance is a single-purpose screen: a header strip, the + // board as the stage, and a dock. Everything else (the topbar's rail of + // links, the instrument column, the transmission log) is reachable — and + // *only* reachable — through the same bottom sheet, so a seeker learns one + // gesture instead of three metaphors. Nothing is dropped: the markup is + // identical at every width, and above 860px none of this CSS applies, so + // the desktop layout renders exactly as it always has (`sheet` can never + // leave null there because every control that sets it is display:none). + type Sheet = 'nav' | 'panels' | 'log' + const [sheet, setSheet] = useState(null) + const toggleSheet = useCallback( + (next: Sheet) => setSheet((cur) => (cur === next ? null : next)), + [], + ) + const closeSheet = useCallback(() => setSheet(null), []) + + // Escape closes the open sheet — a keyboard is rare on a phone but common + // on the small-window desktop widths this breakpoint also covers. + useEffect(() => { + if (!sheet || typeof window === 'undefined') return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setSheet(null) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [sheet]) // Six mode tabs never fit a 390px viewport, so the rail scrolls. A hidden // scrollbar with no other affordance made the later modes (camera above // all) look like they simply didn't exist on a phone — these flags drive @@ -296,10 +319,12 @@ function SeanceSession({ username }: { username: string }) { {t('common.appName')} - {/* The rail keeps the six modes on one line and scrolls them; the - wrapper carries the fade + arrow cues so the off-screen ones stay - discoverable. Wrapping to two rows was the alternative, but that - steals ~44px of board height on the screen that has the least. */} + {/* Desktop keeps the six modes on one scrolling line, with fade + + arrow cues for whatever is off the end. At phone widths that rail + is the single worst control on the page — six 44px targets cannot + fit 390px, so LENS in particular reads as if it does not exist. + Below 860px the same nav becomes a 3x2 segmented grid (see the + mobile block in the CSS): every mode named, on screen, at once. */}
setNavOpen((o) => !o)} + onClick={() => toggleSheet('nav')} > - {navOpen + {sheet === 'nav' ? t('seance.nav.close', { defaultValue: 'close' }) : t('seance.nav.open', { defaultValue: 'more' })} -
+
+