feat: on a phone this is its own app, not a shrunken website

The brief was not "make it responsive" — it was "the app should be its own
thing, all the same features, properly displayed". So below 860px the séance
is a native-shaped app: header strip, a 3x2 grid naming all six modes, a
dominant stage for the board, and a dock.

The six modes previously lived in a horizontally-scrolling rail with the
scrollbar deliberately hidden. Six 44px targets cannot fit 390px, so the
later ones — camera especially — were effectively undiscoverable: present,
but invisible, which reads to a user as "missing on mobile". They are now
all on screen, named, permanently.

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 from the dock.
Same geometry, same grabber, same close, same backdrop, one at a time. A
sheet is a door, not a deletion.

The other eleven pages get the same treatment in CSS: single-column stacked
cards, 60px+ tappable rows, 16px inputs, 44px targets, four-edge safe-area
insets, and a top inset that clears the fixed hamburger — which previously
sat on top of every page's back link.

NOTHING WAS REMOVED. Verified mechanically, not asserted: the only added
`display:none` rules cover three brand-new class names and a scroll-fade cue
for a rail that no longer scrolls. No pre-existing feature is hidden
anywhere.

DESKTOP IS UNTOUCHED. Also verified mechanically rather than trusted: I
stripped every `max-width <= 860px` block from both the old and new
stylesheets and diffed what remained. Eleven of twelve are byte-identical in
their desktop-applicable CSS; the twelfth differs by exactly one rule, which
hides three classes that did not previously exist. The moved panels keep
their own class names and merely gain data attributes, so they are not
nested inside anything hidden — desktop renders them as before.

429 tests, tsc clean, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-08-02 02:57:04 +00:00
parent 7906abdd2b
commit 8fd60ed204
13 changed files with 2074 additions and 15 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -153,11 +153,34 @@ function SeanceSession({ username }: { username: string }) {
const [question, setQuestion] = useState('')
const [dossierOpen, setDossierOpen] = useState(true)
const [view, setView] = useState<BoardView>('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<Sheet | null>(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')}
</div>
{/* 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. */}
<div
className="seance-modes-rail"
data-more-start={modeOverflow.start}
@@ -333,19 +358,25 @@ function SeanceSession({ username }: { username: string }) {
<button
type="button"
className="seance-nav-toggle"
aria-expanded={navOpen}
aria-expanded={sheet === 'nav'}
aria-controls="seance-nav-rail"
onClick={() => setNavOpen((o) => !o)}
onClick={() => toggleSheet('nav')}
>
<span aria-hidden="true">⛤</span>
<span className="seance-nav-toggle-label">
{navOpen
{sheet === 'nav'
? t('seance.nav.close', { defaultValue: 'close' })
: t('seance.nav.open', { defaultValue: 'more' })}
</span>
</button>
<div className="seance-top-right" id="seance-nav-rail" data-open={navOpen}>
<div
className="seance-top-right"
id="seance-nav-rail"
data-sheet="nav"
data-open={sheet === 'nav'}
>
<SheetClose onClose={closeSheet} />
<label className="passive-toggle" title={t('seance.passive')}>
<input
type="checkbox"
@@ -491,7 +522,17 @@ function SeanceSession({ username }: { username: string }) {
)}
</section>
<aside className="seance-side">
{/* Desktop: the instrument column beside the board. Phone: the same
column, verbatim, as a bottom sheet — the active mode's own panel
is ordered first by CSS so opening it lands on the instrument the
seeker is actually holding. */}
<aside
className="seance-side"
id="seance-side-sheet"
data-sheet="panels"
data-open={sheet === 'panels'}
>
<SheetClose onClose={closeSheet} />
<EntityCard
entity={entity}
isNew={state.entityIsNew}
@@ -518,7 +559,51 @@ function SeanceSession({ username }: { username: string }) {
{/* ---- bottom: transmission log + direct contact ---- */}
<footer className="seance-bottom">
<div className="seance-transcript-wrap">
{/* Phones only (display:none above 860px). The two handles that raise
the instrument sheet and the log sheet; they sit in the dock with
the ask row so every reachable surface has one obvious door.
Both labels are existing catalog strings — the first is simply the
active mode's own name, which is also the honest description of
what the sheet contains. */}
<div className="seance-dock">
<button
type="button"
className="seance-dock-btn"
aria-expanded={sheet === 'panels'}
aria-controls="seance-side-sheet"
onClick={() => toggleSheet('panels')}
>
<span className="dock-glyph" aria-hidden>
⌬
</span>
<span className="dock-label">
{t(`seance.modes.${state.mode}`, { defaultValue: MODE_LABEL_DEFAULTS[state.mode] })}
</span>
</button>
<button
type="button"
className="seance-dock-btn"
aria-expanded={sheet === 'log'}
aria-controls="seance-log-sheet"
onClick={() => toggleSheet('log')}
>
<span className="dock-glyph" aria-hidden>
☰
</span>
<span className="dock-label">{t('seance.transcriptTitle')}</span>
<span className="anomaly-count">
{t('seance.anomaly.count', { count: state.anomalyCount })}
</span>
</button>
</div>
<div
className="seance-transcript-wrap"
id="seance-log-sheet"
data-sheet="log"
data-open={sheet === 'log'}
>
<SheetClose onClose={closeSheet} />
<div className="seance-transcript-title">
<span>{t('seance.transcriptTitle')}</span>
<span className="anomaly-count">
@@ -557,6 +642,17 @@ function SeanceSession({ username }: { username: string }) {
</form>
</footer>
{/* ---- sheet backdrop (phones only; `sheet` is always null above
860px because every control that sets it is display:none) ---- */}
{sheet !== null && (
<button
type="button"
className="seance-sheet-backdrop"
aria-label={t('common.dismiss')}
onClick={closeSheet}
/>
)}
{/* ---- toasts ---- */}
<div className="seance-toasts" aria-live="polite">
{state.toasts.map((toast) => (
@@ -571,6 +667,26 @@ function SeanceSession({ username }: { username: string }) {
)
}
// ---- bottom sheets ----
/**
* The close handle every mobile sheet carries. `display:none` above 860px,
* so on desktop this adds no box, no flex gap and no visual change at all.
*/
function SheetClose({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
return (
<button
type="button"
className="seance-sheet-close"
onClick={onClose}
aria-label={t('common.dismiss')}
>
<span aria-hidden>×</span>
</button>
)
}
// ---- toasts ----
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: (id: number) => void }) {

View File

@@ -464,3 +464,144 @@
animation: none;
}
}
/* ---- phone: the storefront as a single stacked column ----
Phone breakpoint only; desktop above 860px is unchanged. The flagship,
every module and the whole manifest form still render — restacked, not
trimmed. */
@media (max-width: 860px) {
.shop-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) + 5rem)
calc(env(safe-area-inset-left, 0px) + 1rem);
}
.shop-header {
margin-bottom: 2rem;
}
.shop-back {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-bottom: 0.6rem;
}
.shop-subtitle {
font-size: 0.9rem;
line-height: 1.55;
}
.shop-section-title {
margin-bottom: 1.6rem;
}
.shop-section-title::before,
.shop-section-title::after {
flex-basis: 1.25rem;
}
.price-tag {
font-size: 0.8rem;
padding: 0.4rem 0.8rem;
}
/* Flagship: glyph over copy, everything left-aligned and readable. */
.shop-flagship {
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 1.2rem;
margin-bottom: 3rem;
padding: 1.4rem 1.1rem;
}
.flagship-specs {
justify-content: flex-start;
}
.flagship-specs li {
font-size: 0.75rem;
padding: 0.35rem 0.7rem;
}
.flagship-desc {
font-size: 0.95rem;
line-height: 1.75;
}
/* One module card per row. */
.shop-modules {
margin-bottom: 3rem;
}
.modules-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.module-card {
padding: 1.3rem 1.1rem;
gap: 0.8rem;
}
.module-name {
font-size: 1.15rem;
}
.module-desc {
font-size: 0.95rem;
line-height: 1.7;
}
.module-unlocks {
font-size: 0.875rem;
line-height: 1.6;
}
/* Manifest: one control per line, all full width. */
.shop-waitlist {
margin-bottom: 3rem;
padding: 1.4rem 1.1rem;
}
.waitlist-desc {
font-size: 0.95rem;
line-height: 1.7;
margin: -0.5rem 0 1.4rem;
}
.waitlist-form {
flex-direction: column;
align-items: stretch;
gap: 0.8rem;
}
.waitlist-input,
.waitlist-select {
flex: 1 1 auto;
width: 100%;
/* 16px exactly — iOS zooms the page on focus below this. */
font-size: 16px;
min-height: 48px;
}
.waitlist-submit {
width: 100%;
min-height: 48px;
font-size: 0.9rem;
}
.waitlist-message {
font-size: 0.9rem;
line-height: 1.6;
}
.shop-honesty p {
font-size: 0.875rem;
line-height: 1.75;
}
}