/* Shared chrome for Portal's own pages (login portal, hub doors, alerts). Lives
   in a static file rather than an inline <style> block so the credential pages
   can run under CSP style-src 'self' with no 'unsafe-inline' — see the header
   middleware in Program.cs. */

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
    background: #070b16;
    color: #e2e8f0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    min-height: 100vh;
    /* Without this iOS inflates body text on rotation to landscape, which on
       the Hub reflows the door captions and on the login overflows the fields. */
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
}

/* The grey box Safari flashes over anything tappable. The doors and buttons
   have their own press states; this one just muddies them. */
a, button, .door { -webkit-tap-highlight-color: transparent; }

/* The brand lockup lives in brand-lockup.css — shared with the Admin and
   Account shells, which cannot load this file. Layouts link both. */

@keyframes cp-spin { to { transform: rotate(360deg); } }
@keyframes cp-shimmer { 0% { background-position: 0% 50%; } 100% { background-position: 200% 50%; } }
@keyframes cp-drift { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(26px,-14px); } }
@keyframes cp-drift-2 { 0%, 100% { transform: translate(0,0); } 50% { transform: translate(-20px,10px); } }
@keyframes cp-portal-pulse {
    0%, 100% { transform: scale(1); filter: brightness(.9); }
    50% { transform: scale(1.018); filter: brightness(1.15); }
}
@keyframes cp-glow { 0%, 100% { opacity: .72; } 50% { opacity: 1; } }
@keyframes cp-door-in { from { opacity: .45; transform: scale(.955); } to { opacity: 1; transform: scale(1); } }
@keyframes cp-thumb-mobile { from { transform: translateY(0); } to { transform: translateY(566px); } }

/* ── Auth surface (login, signup, reset, passkey) ───────────────────────────
   The sign-in floats inside a shimmering portal ring — no inner card. */

.auth-stage {
    /* svh, not vh: on iOS 100vh is the viewport with the toolbars RETRACTED,
       so a vh-tall centred stage hangs below the visible area and pushes its
       own content under the toolbar. svh is the viewport with them shown —
       the smallest it ever gets — so nothing hides, and unlike dvh it doesn't
       resize mid-scroll. The vh line above stays as the fallback for browsers
       that predate the unit. */
    min-height: 100vh;
    min-height: 100svh;
    position: relative;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
    /* The layout ships viewport-fit=cover so the shell background runs edge to
       edge and up into the notch nooks, which is what the theme colour matches
       against. That means the browser stops reserving the safe areas, so every
       full-bleed surface has to give them back as padding — here, and on .hub
       below. env() is 0px on hardware with nothing to avoid, so this costs
       nothing off an iPhone and needs no device sniffing to say so. */
    padding-block: calc(48px + env(safe-area-inset-top)) calc(48px + env(safe-area-inset-bottom));
    padding-inline: calc(24px + env(safe-area-inset-left)) calc(24px + env(safe-area-inset-right));
}

/* The stage paints to the physical edges (viewport-fit=cover), so the safe
   areas — under the iPhone status strip, behind Safari's bottom bar — are page
   pixels. Left bare they read as dead letterbox even though nothing is
   letterboxed. These two washes carry the portal's own light up into the top
   edge and down toward the bottom one, so the whole screen is one field with
   the ring at its centre rather than a card floating between voids. Painted
   on the stage, not the body: the stage owns the full viewport and clips at
   its own edges, so the washes can reach exactly to them. */
.auth-stage::before {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    background:
        radial-gradient(130% 46% at 50% -12%, rgba(99,102,241,.16), transparent 62%),
        radial-gradient(130% 42% at 50% 112%, rgba(56,189,248,.10), transparent 60%);
}

.aurora-a, .aurora-b { position: absolute; border-radius: 50%; pointer-events: none; }
.aurora-a {
    top: -160px; left: -120px; width: 420px; height: 420px;
    background: radial-gradient(circle, rgba(56,189,248,.18), transparent 65%);
    animation: cp-drift 11s ease-in-out infinite;
}
.aurora-b {
    bottom: -180px; right: -140px; width: 460px; height: 460px;
    background: radial-gradient(circle, rgba(167,139,250,.15), transparent 65%);
    animation: cp-drift-2 13s ease-in-out infinite;
}

.portal { position: relative; width: 620px; height: 620px; flex: none; display: flex; align-items: center; justify-content: center; }

.portal-ring { position: absolute; inset: 0; border-radius: 50%; animation: cp-portal-pulse 3.6s ease-in-out infinite; }
.portal-ring::before, .portal-ring::after {
    content: "";
    position: absolute;
    border-radius: 50%;
    background: conic-gradient(from 0deg, #38bdf8, #7dd3fc, #a78bfa, #6366f1, #38bdf8, #a78bfa, #38bdf8);
    animation: cp-spin 9s linear infinite;
}
.portal-ring::before { inset: -6px; filter: blur(14px); opacity: .55; }
.portal-ring::after { inset: 0; }

.portal-core { position: absolute; inset: 8px; border-radius: 50%; background: #070b16; z-index: 1; }
.portal-core::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: radial-gradient(circle, transparent 55%, rgba(56,189,248,.12) 82%, rgba(167,139,250,.18) 100%);
}

.portal-orbit {
    position: absolute;
    inset: -22px;
    border-radius: 50%;
    border: 1px dashed rgba(56,189,248,.22);
    animation: cp-spin 50s linear infinite;
}

.portal-content { position: relative; z-index: 2; width: 300px; display: flex; flex-direction: column; }
.portal-content .brand-lockup { align-self: center; }

.tagline { text-align: center; color: #64748b; font-size: 13px; margin: 12px 0 30px; }

.field { margin-bottom: 14px; }

input[type=text], input[type=email], input[type=password] {
    width: 100%;
    background: rgba(255,255,255,.05);
    border: 1px solid rgba(255,255,255,.1);
    border-radius: 12px;
    color: #e2e8f0;
    font-family: inherit;
    font-size: 14.5px;
    padding: 14px 16px;
    outline: none;
    text-align: center;
    transition: border-color .15s, box-shadow .15s;
}
input[type=text]:focus, input[type=email]:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56,189,248,.15); }
input[type=password]:focus { border-color: #a78bfa; box-shadow: 0 0 0 3px rgba(167,139,250,.15); }

label {
    display: block;
    font-size: 11px;
    font-weight: 600;
    color: #64748b;
    text-transform: uppercase;
    letter-spacing: .08em;
    margin-bottom: 6px;
    text-align: center;
}

.field-error { display: block; color: #f87171; font-size: 12px; margin-top: 6px; text-align: center; }
/* Says what the one front-door field takes, and keeps saying it after the
   placeholder is gone — which is the moment it is actually needed, since
   nothing else on the page tells you a guest code belongs here. Email step
   only; the later steps ask for one thing each. Its height is part of the
   mobile --stack budget below, as --stack-hint. */
.field-hint { display: block; color: #64748b; font-size: 12px; margin: -6px 0 14px; text-align: center; }

button {
    width: 100%;
    border: none;
    border-radius: 12px;
    color: #0a0e1a;
    cursor: pointer;
    font-family: inherit;
    font-size: 15px;
    font-weight: 700;
    padding: 14px;
    background: linear-gradient(90deg, #38bdf8, #a78bfa, #38bdf8);
    background-size: 200% 100%;
    box-shadow: 0 8px 24px rgba(99,150,250,.25);
    animation: cp-shimmer 5s linear infinite;
    transition: transform .15s, box-shadow .15s;
}
button:hover { transform: translateY(-1px); box-shadow: 0 12px 30px rgba(99,150,250,.4); }

.btn-secondary {
    background: transparent;
    border: 1px solid rgba(255,255,255,.14);
    color: #e2e8f0;
    font-size: 14px;
    font-weight: 600;
    padding: 12px;
    box-shadow: none;
    animation: none;
    transition: border-color .15s;
}
.btn-secondary:hover { border-color: #a78bfa; transform: none; box-shadow: none; }

.btn-danger { background: #ef4444; color: #fff; animation: none; box-shadow: none; }

.divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: #475569; font-size: 11.5px; }
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: rgba(255,255,255,.08); }

.alert { border-radius: 12px; font-size: 13px; padding: 10px 14px; margin-bottom: 16px; text-align: center; }
.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #f87171; }
.alert-success { background: rgba(52,211,153,.12); border: 1px solid rgba(52,211,153,.3); color: #34d399; }

.links { text-align: center; margin-top: 20px; font-size: 12.5px; color: #64748b; }
a { color: #38bdf8; text-decoration: none; }
a:hover { color: #7dd3fc; }
.links span { margin: 0 8px; }

h1 { font-size: 20px; text-align: center; margin-bottom: 20px; color: #f1f5f9; }

/* Hidden until passkey-login.js has an error to show (it toggles
   element.style.display, which CSP permits — only markup-borne inline
   styles are barred). */
#passkey-login-error { display: none; margin-top: 14px; }

/* Passkeys-only sign-in (Login.cshtml's PasskeyRequired branch): the route to
   /PasskeySetup sits directly under the primary passkey button, for someone
   who has an account and no passkey yet. */
.passkey-setup-link { text-align: center; font-size: 12.5px; margin: 12px 0 0; }
.passkey-demo { margin-top: 12px; }

/* ── Hub: the doors ────────────────────────────────────────────────────────
   Each app is a door; hovering widens it within its row. Rows snap-scroll one
   at a time, with slim gradient rails on both edges showing position. */

.hub {
    /* Fills the viewport rather than sitting in the middle of it. The doors
       were a fixed 460px, which on anything taller than a laptop left the Hub
       floating in a field of empty background. One custom property sizes the
       scroller, the rails and every row together — they have to agree or the
       snap points stop lining up with the rail.
       The subtrahend is the chrome above and below: 26+24 padding, the head
       row and its margin, the foot and its margin. max() keeps a row usable
       on a short window instead of collapsing it. */
    --door-h: max(360px, calc(100svh - 150px));

    height: 100vh;
    height: 100svh; /* see .auth-stage — same reason, and --door-h above matches */
    position: relative;
    overflow: hidden;
    /* Safe-area insets as above. Kept on the base rule, not just the mobile
       block, because a landscape iPhone is wider than 720px and still has ears
       to clear — there they are left/right rather than top. */
    padding-block: calc(26px + env(safe-area-inset-top)) calc(24px + env(safe-area-inset-bottom));
    padding-inline: calc(36px + env(safe-area-inset-left)) calc(36px + env(safe-area-inset-right));
    display: flex;
    flex-direction: column;
}
.hub::before {
    content: "";
    position: absolute;
    top: -220px; left: -140px;
    width: 520px; height: 520px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(56,189,248,.14), transparent 65%);
    pointer-events: none;
}

.hub-head { position: relative; display: flex; align-items: center; gap: 24px; margin-bottom: 20px; }
.hub-head .brand-lockup-sm { flex: none; }

.hub-search { position: relative; flex: 1; max-width: 520px; margin: 0 auto; }
.hub-search input {
    border-radius: 999px;
    background: rgba(15,21,36,.85);
    border-color: rgba(255,255,255,.12);
    font-size: 13px;
    padding: 7px 42px 7px 16px;
    text-align: left;
}
.hub-search input:focus { border-color: #38bdf8; box-shadow: 0 0 0 4px rgba(56,189,248,.12); }
.hub-search kbd {
    position: absolute;
    right: 12px;
    top: 50%;
    margin-top: -9px;
    font-size: 10px;
    font-family: ui-monospace, Menlo, monospace;
    color: #475569;
    background: rgba(255,255,255,.06);
    border: 1px solid rgba(255,255,255,.1);
    border-radius: 6px;
    padding: 1px 6px;
}

/* The right end of the header: the notification bell, then the account menu.
   Their own group rather than two children of .hub-head, because that row's
   24px gap is the spacing between the brand, the search and this — 24px
   between a bell and the avatar it belongs beside reads as two unrelated
   controls. The slot is empty in the markup; wwwroot/_portal/notify.js fills
   it when somebody is signed in and leaves it at zero width otherwise. */
.hub-actions { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
.hub-notify { display: inline-flex; }

.user-menu { position: relative; flex: 0 0 auto; }
.user-menu-trigger {
    display: flex;
    align-items: center;
    gap: 7px;
    width: auto;
    padding: 4px 10px 4px 5px;
    background: rgba(255,255,255,.06);
    border: 1px solid rgba(255,255,255,.1);
    border-radius: 999px;
    color: #e2e8f0;
    font-size: 12px;
    font-weight: 400;
    box-shadow: none;
    animation: none;
    transition: background .14s;
}
.user-menu-trigger:hover { background: rgba(255,255,255,.1); transform: none; box-shadow: none; }
.user-avatar-wrap { position: relative; display: inline-flex; flex: 0 0 auto; }
.user-avatar {
    width: 22px; height: 22px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    background: linear-gradient(135deg, #38bdf8, #a78bfa);
    color: #0a0e1a; font-weight: 700; font-size: 10.5px;
}
.user-menu-dot {
    position: absolute; top: -2px; right: -2px;
    width: 8px; height: 8px; border-radius: 50%;
    background: #ffb347; box-shadow: 0 0 0 2px #070b16;
}
.user-menu-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-menu-caret { color: #64748b; flex: 0 0 auto; transition: transform .14s; }
.user-menu-trigger[aria-expanded="true"] .user-menu-caret { transform: rotate(180deg); }
.user-menu-panel {
    position: absolute;
    top: calc(100% + 8px);
    right: 0;
    width: 200px;
    background: #131c2f;
    border: 1px solid rgba(255,255,255,.1);
    border-radius: 14px;
    padding: 6px;
    z-index: 100;
    box-shadow: 0 20px 40px rgba(0,0,0,.5);
    display: none;
}
.user-menu-panel.open { display: block; }
.user-menu-item {
    display: flex; align-items: center; gap: 8px;
    width: 100%; padding: 8px 10px;
    border: none; border-radius: 8px;
    background: transparent; color: #e2e8f0;
    font-size: 13px; font-weight: 400; font-family: inherit;
    text-align: left; text-decoration: none; cursor: pointer;
    box-shadow: none; animation: none;
}
.user-menu-item:hover { background: rgba(255,255,255,.06); transform: none; box-shadow: none; }
.user-menu-item.danger { color: #f87171; }
.user-menu-item.danger:hover { background: rgba(239,68,68,.08); }
.user-menu-item .user-menu-badge { margin-left: auto; }
.user-menu-divider { height: 1px; background: rgba(255,255,255,.1); margin: 6px 4px; }
.user-menu-panel .logout-form { margin: 0; width: 100%; }
.badge {
    min-width: 26px;
    text-align: center;
    background: linear-gradient(90deg, #38bdf8, #a78bfa);
    color: #0a0e1a;
    font-weight: 800;
    font-size: 12px;
    border-radius: 999px;
    padding: 3px 9px;
}
.badge.zero { background: rgba(148,163,184,.25); color: #94a3b8; }

/* Snap viewport + rails */
.door-scroll {
    position: relative;
    height: var(--door-h);
    flex: none;
    overflow-y: auto;
    scroll-snap-type: y mandatory;
    overscroll-behavior: contain;
    scrollbar-width: none;
}
.door-scroll::-webkit-scrollbar { display: none; }

.rails { position: sticky; top: 0; height: 0; z-index: 10; }
.rail {
    position: absolute;
    top: 0;
    width: 4px;
    height: var(--door-h);
    border-radius: 999px;
    background: rgba(255,255,255,.06);
}
.rail.left { left: 0; }
.rail.right { right: 0; }
.rail span {
    display: block;
    width: 4px;
    border-radius: 999px;
    background: linear-gradient(180deg, #38bdf8, #a78bfa);
    box-shadow: 0 0 8px rgba(99,150,250,.55);
    animation-duration: 1s;
    animation-timing-function: linear;
    animation-fill-mode: both;
    animation-timeline: scroll(nearest);
}

/* The thumb is one row tall and travels the rest of the rail, so it reads as
   which row you're on. Both numbers depend on how many rows there are — ten
   apps make three, a guest pass can make one — so the count arrives as
   data-rows instead of being baked in. translateY is a percentage of the
   THUMB, hence travel of (rows - 1) x 100%. */
.door-scroll[data-rows="1"] .rail span { height: 100%; animation-name: none; }
.door-scroll[data-rows="2"] .rail span { height: 50%;     animation-name: cp-thumb-2; }
.door-scroll[data-rows="3"] .rail span { height: 33.333%; animation-name: cp-thumb-3; }
.door-scroll[data-rows="4"] .rail span { height: 25%;     animation-name: cp-thumb-4; }
/* Five rows: an admin's full board (seventeen apps plus Towns) since
   ChargeWatch and RenewWatch joined 2026-09-03. Without this rule the thumb
   would fall back to the rail's full height and never move. */
.door-scroll[data-rows="5"] .rail span { height: 20%;     animation-name: cp-thumb-5; }

@keyframes cp-thumb-2 { to { transform: translateY(100%); } }
@keyframes cp-thumb-3 { to { transform: translateY(200%); } }
@keyframes cp-thumb-4 { to { transform: translateY(300%); } }
@keyframes cp-thumb-5 { to { transform: translateY(400%); } }

.door-row {
    display: flex;
    gap: 10px;
    height: var(--door-h);
    padding: 0 14px;
    scroll-snap-align: start;
    scroll-snap-stop: always;
}
.door-row + .door-row { margin-top: 10px; }

.door {
    position: relative;
    flex: 1;
    height: 100%;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    background: #0c1322;
    border: 1px solid rgba(255,255,255,.08);
    border-radius: 18px;
    padding: 18px;
    text-decoration: none;
    transition: flex-grow .35s cubic-bezier(.2,.7,.2,1), border-color .2s, filter .25s;
}
.door:hover { flex-grow: 2.4; }

/* Preview zone — drop a screenshot or app logo in as .door-tint's
   background-image; the fade below keeps the name legible over it. */
.door-tint { position: absolute; inset: 0; background-size: cover; background-position: top center; }
.door-tint::after {
    content: "";
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(45deg, rgba(148,163,184,.05) 0 10px, transparent 10px 20px);
}
.door-placeholder {
    position: absolute;
    top: 0; left: 0; right: 0;
    height: 65%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 9.5px;
    letter-spacing: .1em;
    text-transform: uppercase;
    color: #3b465c;
    font-family: ui-monospace, Menlo, monospace;
}
.door-fade {
    position: absolute;
    left: 0; right: 0; bottom: 0;
    height: 55%;
    background: linear-gradient(180deg, transparent, #0b1120 80%);
}
.door-ms {
    position: absolute;
    top: 14px; left: 16px;
    font-size: 11px;
    font-family: ui-monospace, Menlo, monospace;
    color: #34d399;
    white-space: nowrap;
}
.door.down { opacity: .6; }

/* Doors carrying a real screenshot (see Hub.cshtml's shot roster) hide the
   "preview / logo" placeholder — it exists to mark the ones still missing art. */
.door.has-shot .door-placeholder { display: none; }
.door.down .door-ms { color: #f87171; }
.door-name { position: relative; font-size: 16px; font-weight: 700; color: #f1f5f9; white-space: nowrap; }
.door-desc {
    position: relative;
    font-size: 11.5px;
    color: #94a3b8;
    margin-top: 3px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Per-app hue */
.door.h-launches:hover { border-color: oklch(75% 0.12 230); }
.door.h-launches .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 230 / .5), rgba(12,19,34,.2)),
        url("../img/doors/launches.png");
}
.door.h-stock:hover { border-color: oklch(75% 0.12 160); }
.door.h-stock .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 160 / .5), rgba(12,19,34,.2)),
        url("../img/doors/stock.png");
}
.door.h-fuel:hover { border-color: oklch(75% 0.12 85); }
.door.h-fuel .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 85 / .5), rgba(12,19,34,.2)),
        url("../img/doors/fuel.png");
}
.door.h-news:hover { border-color: oklch(75% 0.12 300); }
.door.h-news .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 300 / .5), rgba(12,19,34,.2)),
        url("../img/doors/news.jpg");
}
.door.h-mortgage:hover { border-color: oklch(75% 0.12 20); }
.door.h-mortgage .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 20 / .5), rgba(12,19,34,.2)),
        url("../img/doors/mortgage.png");
}
.door.h-records:hover { border-color: oklch(75% 0.12 200); }
.door.h-records .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 200 / .5), rgba(12,19,34,.2)),
        url("../img/doors/records.png");
}
/* Unlock Abroad: 40, the coral its own wordmark and buttons wear (sampled off
   the capture), rather than the 260 it carried while the door was an empty
   blue tint — a cool wash over a warm page read as a mismatch. */
.door.h-homes:hover { border-color: oklch(75% 0.12 40); }
.door.h-homes .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 40 / .5), rgba(12,19,34,.2)),
        url("../img/doors/homes.png");
}

/* Wheels, Roles and Returns landed after the design was drawn — same oklch
   recipe, picking hues the existing seven leave free. */
.door.h-wheels:hover { border-color: oklch(75% 0.12 130); }
.door.h-wheels .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 130 / .5), rgba(12,19,34,.2)),
        url("../img/doors/wheels.png");
}
.door.h-roles:hover { border-color: oklch(75% 0.12 320); }
.door.h-roles .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 320 / .5), rgba(12,19,34,.2)),
        url("../img/doors/roles.png");
}
/* Returns: 80, the gold of its wordmark square and "moved today" pill, for the
   same reason as Unlock Abroad above — the 180 teal was a placeholder tint. */
.door.h-returns:hover { border-color: oklch(75% 0.12 80); }
.door.h-returns .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 80 / .5), rgba(12,19,34,.2)),
        url("../img/doors/returns.png");
}
.door.h-aerocover:hover { border-color: oklch(75% 0.12 50); }
.door.h-aerocover .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 50 / .5), rgba(12,19,34,.2)),
        url("../img/doors/aerocover.png");
}
.door.h-travel:hover { border-color: oklch(75% 0.12 350); }
.door.h-travel .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 350 / .5), rgba(12,19,34,.2)),
        url("../img/doors/travel.png");
}
.door.h-grid:hover { border-color: oklch(75% 0.12 240); }
.door.h-grid .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 240 / .5), rgba(12,19,34,.2)),
        url("../img/doors/grid.png");
}
/* Valor: 25 is the warm red-amber the other doors leave free. */
.door.h-valor:hover { border-color: oklch(75% 0.12 25); }
.door.h-valor .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 25 / .5), rgba(12,19,34,.2)),
        url("../img/doors/valor.png");
}

.door.h-taxes:hover { border-color: oklch(75% 0.12 65); }
.door.h-taxes .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 65 / .5), rgba(12,19,34,.2)),
        url("../img/doors/taxes.png");
}

/* ChargeWatch: 280, the electric violet the other doors leave free. */
.door.h-charge:hover { border-color: oklch(75% 0.12 280); }
.door.h-charge .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 280 / .5), rgba(12,19,34,.2)),
        url("../img/doors/charge.png");
}
/* RenewWatch: 110, the lime-green between Fuel's 85 and Wheels' 130. */
.door.h-trellis:hover { border-color: oklch(75% 0.12 150); }
.door.h-trellis .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 150 / .5), rgba(12,19,34,.2)),
        repeating-linear-gradient(55deg,  rgba(99,192,138,.08) 0 1px, transparent 1px 26px),
        repeating-linear-gradient(-55deg, rgba(99,192,138,.08) 0 1px, transparent 1px 26px);
}
.door.h-renew:hover { border-color: oklch(75% 0.12 110); }
.door.h-renew .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 110 / .5), rgba(12,19,34,.2)),
        url("../img/doors/renew.png");
}

/* 175 — the mint the Ring game's gate is actually drawn in, and one of the
   few hues the other nineteen doors leave free (160 is Stock, 200 Records). */
.door.h-arcade:hover { border-color: oklch(75% 0.12 175); }
.door.h-arcade .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 175 / .5), rgba(12,19,34,.2)),
        url("../img/doors/arcade.png");
}

/* SiteWatch: 260, the indigo between Grid's 240 and Charge's 280. No door art
   yet -- the placeholder tint shows until scripts/door_shots.py captures it. */
.door.h-sitewatch:hover { border-color: oklch(75% 0.12 260); }
.door.h-sitewatch .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 260 / .5), rgba(12,19,34,.2));
}

/* DroneWatch: 205, the sky blue between Grid's 240 and Towns' 110 that no
   other door carries. No door art yet -- the placeholder tint shows until
   scripts/door_shots.py captures it. */
.door.h-dronewatch:hover { border-color: oklch(75% 0.12 205); }
.door.h-dronewatch .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 205 / .5), rgba(12,19,34,.2));
}

/* Ascent: 30, the exhaust orange no other door carries (Travel is 350, Fuel
   and Aerocover sit at 50). Art from scripts/door_shots.py: the 3D globe
   with a launch three minutes up. */
.door.h-ascent:hover { border-color: oklch(75% 0.14 30); }
.door.h-ascent .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.06 30 / .5), rgba(12,19,34,.2)),
        url("../img/doors/ascent.png");
}

/* Towns is the one door that is not a proxied app — it opens /Admin/Towns and
   only renders for an admin. Same oklch recipe so it does not look bolted on;
   110 is the warm green the other thirteen leave free. */
/* Hue 150, the deep green the restyle template's masthead actually uses,
   rather than the 110 this carried while it was a gradient with nothing
   behind it. The art is a real town site, the same way every other door is a
   shot of the app behind it. */
.door.h-towns:hover { border-color: oklch(75% 0.12 150); }
.door.h-towns .door-tint {
    background-image:
        linear-gradient(180deg, oklch(28% 0.05 150 / .5), rgba(12,19,34,.2)),
        url("../img/doors/towns.png");
}

/* An app this account holds no flag for. Shown rather than hidden — you can
   see what exists without being able to open it — so it reads as a closed
   door, not a missing one. Guests never reach this state: their Hub lists
   only what the pass includes. */
.door.locked { opacity: .38; cursor: default; }
.door.locked:hover { flex-grow: 1; border-color: rgba(255,255,255,.08); }
.door.locked .door-ms { color: #64748b; }

/* ── Where to ──────────────────────────────────────────────────────────────
   Typing in the search box lights the doors that match and lets the rest go
   dark, IN PLACE: the wall keeps its shape, so nothing jumps under the cursor
   and the eye finds the lit door where it always was. Brightness rather than
   opacity, because a phone's door owns its opacity already (cp-door-in on a
   view() timeline) and an animation outranks a class. The active match wears
   the sky border the hover state uses, so Enter's target is the door that
   already looks chosen; a locked one can be found but not opened, and says so
   with a shake instead of a navigation. hub.js drives all three classes. */
.door.dim { filter: brightness(.32) saturate(.35); }
.door.dim:hover { filter: brightness(.7) saturate(.7); }
.door.hit { border-color: #38bdf8; box-shadow: 0 0 0 1px rgba(56,189,248,.35), 0 0 28px rgba(56,189,248,.22); }
.door.locked.hit { border-color: rgba(56,189,248,.55); box-shadow: none; }
@keyframes cp-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
.door.shake { animation: cp-shake .28s ease; }

.hub-foot { position: relative; display: flex; align-items: center; justify-content: space-between; margin-top: 16px; }
.hub-approvals { font-size: 12.5px; font-weight: 600; color: #a78bfa; }
.hub-approvals:hover { color: #c4b5fd; }
.hub-updated {
    font-size: 11px;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: #3b465c;
    font-family: ui-monospace, Menlo, monospace;
}

/* ── Hub, mobile ───────────────────────────────────────────────────────────
   Doors stack full-width and wake as they scroll into view; rails carry over,
   the thumb is one tile tall so it maps to which door you're on. */

@media (max-width: 720px) {
    /* Nothing on a phone scrolls. Both surfaces that use this stylesheet are
       fixed shells that own the viewport — the Hub already was, the login is
       now — so the document itself is locked too. overflow alone leaves iOS
       free to rubber-band the whole page down off the notch and back; that
       drag is what overscroll-behavior refuses.
       Deliberately NOT position:fixed on the body, which would also stop iOS
       panning the visual viewport to lift a focused field above the keyboard.
       The pan back down is stage-viewport.js's job — iOS forgets it when the
       keyboard is dismissed, and a page with no scrollbar can't recover. */
    html, body {
        height: 100%;
        overflow: hidden;
        overscroll-behavior: none;
    }

    .hub {
        padding-block: calc(16px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-bottom));
        padding-inline: calc(14px + env(safe-area-inset-left)) calc(14px + env(safe-area-inset-right));
        justify-content: flex-start;
    }
    .hub::before {
        top: -70px; left: -80px; width: 260px; height: 260px;
        background: radial-gradient(circle, rgba(56,189,248,.22), transparent 65%);
        animation: cp-glow 4s ease-in-out infinite;
    }
    .hub-head { flex-wrap: wrap; gap: 12px; }
    .hub-search { order: 3; flex: 1 0 100%; max-width: none; margin: 0; }
    .hub-search kbd { display: none; }
    .user-menu-name { display: none; }

    .door-scroll { height: 660px; scroll-snap-type: y proximity; }
    .rail { width: 3px; height: 660px; }
    .door-scroll[data-rows] .rail span { width: 3px; height: 94px; animation-name: cp-thumb-mobile; }

    .door-row {
        flex-direction: column;
        gap: 10px;
        height: auto;
        padding: 0 12px;
        scroll-snap-align: none;
    }
    .door {
        height: 150px;
        flex: none;
        border-radius: 16px;
        padding: 14px 16px;
        scroll-snap-align: start;
        animation: cp-door-in linear both;
        animation-timeline: view();
        animation-range: entry 0% entry 100%;
    }
    .door:hover { flex-grow: 0; }
    .door-placeholder { height: 60%; font-size: 9px; }
    .door-fade { height: 60%; }
    .door-ms { top: 11px; left: 14px; font-size: 10.5px; }
    .door-name, .door-desc { white-space: normal; }

    .hub-foot { justify-content: center; padding-top: 10px; }
    .hub-approvals { display: none; }
    .hub-updated { font-size: 9.5px; }

    /* ── Login, mobile ─────────────────────────────────────────────────────
       From the design handoff (PortalLogin.png, 2026-08-27), then sized to
       hold on real hardware.

       The desktop composition puts the whole form inside the ring, which a
       phone cannot hold: shrink the ring until the form fits inside it and the
       ring is a bracelet, keep it wide enough for the form and it runs off
       both edges. So on a phone the ring stops being a container and becomes a
       portal you arrive through — it holds the lockup and the tagline, and the
       form flows out of the bottom of it. Same three layers, same conic
       stroke, same animations; only what they enclose changes.

       The stage is exactly one viewport and does not scroll, so the ring is
       the part that gives: it is whatever is left after the form, which is why
       --stack below is a measured number and not a guess. */

    .auth-stage {
        /* height, not min-height: this is one screen, not a page that happens
           to start full. svh is the viewport with Safari's bars showing — the
           smallest it ever gets — so the layout is sized for the worst case
           and never has to move when the bars retract. */
        height: 100svh;
        /* Centred rather than top-aligned. When the ring is capped by width
           instead of height there is slack left over, and splitting it reads
           better than pooling it all at the bottom. */
        align-items: center;
        padding-block: calc(24px + env(safe-area-inset-top)) calc(18px + env(safe-area-inset-bottom));
        padding-inline: calc(17px + env(safe-area-inset-left)) calc(17px + env(safe-area-inset-right));

        /* What the form below the ring actually measures, from rendering each
           step at 393px: email 272, password 340, create 347, and 53 more
           whenever an error alert is on screen. Rounded up a little, and split
           into parts so the two modifier classes can adjust it — Login.cshtml
           puts them on this element per step. Sizing the ring for the tallest
           step instead would shrink it on the step almost everyone sees. */
        --stack-field: 0px;
        --stack-alert: 0px;
        /* .field-hint, which only the email step renders — so the two steps
           that replace that step zero it again rather than reserving space
           for a line they don't draw. */
        --stack-hint: 22px;
        --stack: calc(276px + var(--stack-hint) + var(--stack-field) + var(--stack-alert));
    }
    .auth-stage--field  { --stack-field: 70px; --stack-hint: 0px; }  /* password: one more field + the address line */
    .auth-stage--field2 { --stack-field: 78px; --stack-hint: 0px; }  /* create: two more fields, no link row */
    .auth-stage--alert  { --stack-alert: 56px; }
    /* passkeys-only: a step label and the setup link on top of the email
       step's rows. Estimated from the parts (label ~27 + link ~30, less the
       email step's own divider slack), not yet measured at 393px the way the
       three above were — re-measure if the ring looks crowded there. */
    .auth-stage--passkeys { --stack-field: 44px; }

    /* More of the field on a phone: the desktop-sized glows sit mostly
       off-canvas at 393px, which left everything around the ring bare
       #070b16 — the dark void a fixed stage can never scroll away from.
       Pulled inward and up so the top one visibly reaches the edge the
       status bar sits on. Colour and alpha only; nothing here has layout. */
    .aurora-a {
        top: -110px; left: -70px; width: 380px; height: 380px;
        background: radial-gradient(circle, rgba(56,189,248,.24), transparent 66%);
    }
    .aurora-b {
        bottom: -130px; right: -80px; width: 420px; height: 420px;
        background: radial-gradient(circle, rgba(167,139,250,.20), transparent 66%);
    }

    /* One custom property drives the three ring layers and the padding that
       clears them, so the circle and the form below it cannot drift apart.
       Smallest of three terms wins:
         - width, so the circle never reaches the edges of the screen
         - the height left after the padding, the 14px gap and the form, so
           nothing is ever pushed off the bottom of a viewport that cannot
           scroll
         - 400px, so it does not dominate a large phone
       No floor under it on purpose: a floor would win an argument it must
       lose, and the overflow it caused would be clipped by a fixed stage
       rather than scrolled to. The lockup scales with it instead.

       Deliberately percentage-free. `min(100%, 400px)` is the obvious way to
       write the width term and it is a trap: a percentage resolves against the
       containing block's WIDTH in width, padding and margin but against its
       HEIGHT in top. One property read by four of them returned two different
       lengths, drew the ring as an ellipse and hung the lockup below centre. */
    .portal {
        --ring: min(
            100vw  - 60px - env(safe-area-inset-left) - env(safe-area-inset-right),
            100svh - 56px - var(--stack) - env(safe-area-inset-top) - env(safe-area-inset-bottom),
            400px);
        width: 100%;
        height: auto;
        display: block;
    }

    /* All three describe the same circle, anchored to the top of the stack
       instead of centred in a 620px box. `inset: auto` comes first on purpose:
       it is the shorthand the desktop rules set, and top/left only take effect
       once it has been cleared. The circle is centred on its own rather than
       filling .portal, so shrinking it does not narrow the form under it. */
    .portal-ring, .portal-core, .portal-orbit {
        inset: auto;
        top: 0;
        left: 50%;
        width: var(--ring);
        height: var(--ring);
        margin-left: calc(var(--ring) / -2);
    }
    /* Core: the desktop's 8px inside the stroke, which is what makes the
       conic gradient read as a ring rather than a disc. */
    .portal-core {
        top: 8px;
        width: calc(var(--ring) - 16px);
        height: calc(var(--ring) - 16px);
        margin-left: calc((var(--ring) - 16px) / -2);
    }
    /* Orbit: 12px outside the stroke, not the desktop's 22px — at this
       diameter 22px would put the dashed circle past both edges of the phone. */
    .portal-orbit {
        top: -12px;
        width: calc(var(--ring) + 24px);
        height: calc(var(--ring) + 24px);
        margin-left: calc((var(--ring) + 24px) / -2);
    }

    /* The lockup and tagline are pinned inside the circle rather than flowed
       into it, so the form starts one gap below the ring however the tagline
       wraps. */
    .portal-content {
        width: 100%;
        max-width: none;
        padding-top: calc(var(--ring) + 14px);
        /* The form is inset from the ring, as drawn. */
        padding-inline: 11px;
    }
    .portal-content .brand-lockup {
        position: absolute;
        top: calc(var(--ring) * .435);
        left: 50%;
        transform: translate(-50%, -50%);
    }
    .tagline {
        position: absolute;
        top: calc(var(--ring) * .51);
        left: 50%;
        margin: 0;
        color: #94a3b8;
        transform: translateX(-50%);
    }

    /* Both scale with the circle. The ring gives way first on a short screen
       or a taller step, and a fixed 31px mark inside a 150px ring hangs over
       its own edge; these keep the proportions the design drew at 356px. The
       clamps stop it going illegible at one end or outgrowing the desktop
       lockup at the other. Every ratio is measured off that 356px circle. */
    .portal-content .brand-lockup { padding-left: calc(var(--ring) * .107); }
    .portal-content .brand-word   { font-size: clamp(17px, var(--ring) * .088, 31px); }
    .portal-content .brand-cloud  { margin-top: calc(var(--ring) * -.087); }
    .portal-content .brand-cloud svg {
        width:  calc(var(--ring) * .191);
        height: calc(var(--ring) * .163);
    }
    .portal-content .brand-cloud-label {
        left: calc(var(--ring) * .031);
        width: calc(var(--ring) * .084);
        font-size: clamp(5px, var(--ring) * .0225, 8px);
    }
    .tagline {
        width: calc(var(--ring) * .506);
        font-size: clamp(11px, var(--ring) * .0365, 13px);
    }

    /* 16px is iOS's zoom threshold. Focus a field below it and Safari zooms
       the page in and does not zoom back out — which reads as the login
       lurching sideways the moment you tap Email, and as the Hub's search
       swallowing the header. 14.5px and 13px are deliberate sizes on a
       pointer device, so they are raised only here, where the zoom fires. */
    input[type=text], input[type=email], input[type=password] { font-size: 16px; }
    .hub-search input {
        font-size: 16px;
        /* The 42px right gutter held the ⌘K hint, hidden just above on a
           device with no ⌘ key. Reclaimed, and the block padding takes the
           field past the 44px touch minimum. */
        padding: 12px 16px;
    }

    /* 44px is Apple's minimum touch target. The primary button clears it on
       its own at 15px + 14px padding; everything below was under it. */
    .btn-secondary { padding: 14px; font-size: 15px; }
    /* The links are the one row where the 44px target has to be bought with
       padding rather than height — the design draws them as a compact line of
       text, so the box grows invisibly around it and the row's own top margin
       gives back what the padding added. */
    .links { margin-top: 12px; font-size: 15px; }
    .links a { display: inline-block; padding: 14px 4px; }

    /* The design's own metrics for the controls under the ring: a rounder
       14px pill, a little more height, and a placeholder carrying the label
       there is none of. All of it scoped to .auth-stage — an unscoped
       input[] or button rule here reads at the same specificity as
       .hub-search input and .btn-secondary but sits later in the file, so it
       would silently win and square off the Hub's 999px search pill and fill
       in the passkey button's transparent background. */
    .auth-stage input[type=text],
    .auth-stage input[type=email],
    .auth-stage input[type=password] {
        border-radius: 14px;
        padding: 15px 16px;
    }
    .auth-stage ::placeholder { color: #94a3b8; opacity: 1; } /* Firefox dims it without the opacity */

    .auth-stage button:not(.btn-secondary) {
        border-radius: 14px;
        padding: 15px;
        font-size: 16.5px;
        box-shadow: 0 10px 30px rgba(99,150,250,.32);
    }
    .auth-stage .btn-secondary {
        border-radius: 14px;
        padding: 15px;
        font-size: 16px;
        font-weight: 700;
        color: #f1f5f9;
    }
    .auth-stage .divider { margin: 20px 0; }
    .signed-as a { display: inline-block; padding: 7px 4px; }
    .user-menu-trigger { padding: 10px 14px 10px 10px; }
    .user-menu-item { padding: 14px 10px; font-size: 14px; }
}

/* ── Passkey management (/Account/Passkeys) ─────────────────────────────── */

.pk-hidden { display: none; }
.pk-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 22px; }
.pk-empty { color: #64748b; font-size: 13px; text-align: center; }

.pk-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    background: rgba(255,255,255,.05);
    border: 1px solid rgba(255,255,255,.08);
    border-radius: 12px;
    padding: 12px 14px;
}
.pk-row.pk-stale { border-color: rgba(239,68,68,.35); }
.pk-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pk-name { font-size: 14px; font-weight: 600; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pk-meta { font-size: 12px; color: #94a3b8; }
.pk-stale .pk-meta { color: #f87171; }

.pk-remove {
    width: auto;
    flex: 0 0 auto;
    margin: 0;
    padding: 8px 12px;
    font-size: 12.5px;
    background: transparent;
    border: 1px solid rgba(239,68,68,.4);
    color: #f87171;
    box-shadow: none;
    animation: none;
}
.pk-remove:hover { background: rgba(239,68,68,.1); transform: none; box-shadow: none; }

#pk-last-delete { margin-top: 6px; }
#pk-last-delete .btn-secondary { margin-top: 8px; }

/* Legacy wrapper — pages not yet moved onto .auth-stage still render sanely. */
.card { width: 100%; max-width: 420px; margin: 0 auto; }
.app-links { display: flex; flex-direction: column; gap: 12px; margin: 28px 0; }
.app-name { font-size: 16px; font-weight: 700; color: #e2e8f0; }
.app-desc { font-size: 12.5px; color: #94a3b8; }
.logout-form { margin-top: 8px; }

/* ── Email-first sign-in, step two ─────────────────────────────────────────
   The address is already settled by this point, so the step names itself and
   shows which account it's asking about. */

.stage-label {
    text-align: center;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: #64748b;
    margin-bottom: 14px;
}

.signed-as {
    text-align: center;
    font-size: 12.5px;
    color: #94a3b8;
    margin-top: 14px;
}
.signed-as a { margin-left: 8px; font-size: 12px; }
