/* ============================================================================
   CONSOLIDATED APP SHELL (/app/)

   One PWA frame for the whole platform: the global header across the top, the
   ws_sidenav rail down the left, and a paper main that scrolls independently.

     ┌──────────────────────────────────────────┐
     │ ws_header()                              │  fixed height, full width
     ├──────────┬───────────────────────────────┤
     │ sidenav  │ main — paper + drafting grid  │  fills the rest, scrolls
     └──────────┴───────────────────────────────┘

   The shell owns the viewport: body does not scroll, main does. That is what
   keeps the header and rail put while a long agenda or document moves.
   ============================================================================ */

/* ── scale ──────────────────────────────────────────────────────────────────
   The shell renders at the platform's own scale. It spent one version at 0.67,
   meant to correct a frame drawn too large, but what it produced read as a
   permanently zoomed-out page: 8.6px body copy, a 43px header, and rail labels
   smaller than anything else on the platform. The size tokens are inherited
   from platform-tokens.css again rather than redeclared here, so every
   `var(--space-4, 16px)` below, and every ws_* component rendered inside the
   app, paints at the size it does everywhere else.

   The frame then renders at 80% via `zoom`, by request. Worth being precise
   about why this is not the 0.67 experiment again, because the two look alike
   from the outside and are not the same mechanism:

   - `zoom` scales the rendered result. Every proportion inside the frame is the
     one the design system specifies; the whole thing is simply drawn smaller.
     Redeclaring tokens at a fraction changes the proportions themselves — it
     rounds each token independently, so a 2px hairline and a 48px gutter shrink
     by different ratios and the relationships drift. That drift is what made
     0.67 read as broken rather than small.
   - `zoom` composes with the reader's own zoom rather than replacing it. A
     reader at 125% sees this frame at 100%, which is the escape hatch the token
     approach denied them.
   - One property reverses it. The token pass took two stylesheets, 128 var()
     fallbacks and 45 literals to put back.

   The cost is the height unit below, and it is the only one. */
body.app-shell {
    zoom: 0.8;
    display: flex;
    flex-direction: column;
    /* 125dvh, not 100. `zoom` scales this box after layout, so 100dvh would be
       drawn at 80% of the viewport and leave a fifth of the screen blank under
       the frame. 100 / 0.8 = 125 fills it exactly. Any other viewport unit in
       this file needs the same treatment for the same reason. */
    height: 125dvh;
    margin: 0;
    overflow: hidden;
    /* The paper lives on main, not here — see the note on .app-shell__main. */
    background-color: var(--bg-surface, #FFFFFF);
    background-image: none;
}

/* The header is a flex row of its own; stop it being squeezed by the body. */
body.app-shell > .wsh-header {
    flex-shrink: 0;
}

/* The header's section tag and nav come out inside the shell. Two reasons, and
   the second is the important one: the rail already names the section, so the
   tag is a label for something you can see; and every one of those nav links
   points at the standalone app (/strategist/, /planner/…), so following one
   silently drops you out of the frame. display:none rather than a visual hide
   on purpose — a link that would break the shell should not be reachable by
   keyboard or announced to a screen reader either. */
body.app-shell .wsh-header__tag,
body.app-shell .wsh-header__nav {
    display: none;
}

/* The header's app-switcher goes with them, and for a sharper reason: it and
   the rail's More launcher answer the same question — take me to another app —
   with opposite destinations. The switcher lists standalone paths and would
   silently drop the frame; the launcher lists shell routes and marks the links
   that leave. Two switchers disagreeing is worse than either alone, so inside
   the shell exactly one owns the job. Hiding the button also strands its
   dropdown panel, which only opens from it. */
body.app-shell .wsh-header__switcher {
    display: none;
}

/* ---- the header bar --------------------------------------------------------
   60px rather than the component's 64px, matching the global-header frame in
   Figma. The shell's header carries a brand, a search button and an avatar —
   the nav, section tag and switcher are hidden above — so it is a title bar on
   an app frame rather than the site's navigation band, and it sits a little
   under the site's own height to say so.

   This is the per-region layout decision the note on scale describes: one bar
   is set to its own height and its controls sized to sit in it, while
   everything else in the frame stays at platform scale.

   Restated here rather than in the component because ws_header draws itself in
   literal px in its own <style> block: there is no height token to reach for,
   and the site's own header keeps its 64px band.
   ---------------------------------------------------------------------------- */
body.app-shell > .wsh-header {
    height: 60px;
    padding: 0 20px;
}

/* 32px boxes in a 60px bar, which is the proportion the Figma frame draws: 14px
   of air above and below, against the 6px that 36px boxes would leave. The
   glyphs inside them are untouched — it is the target that tightens, not the
   icon. */
body.app-shell .wsh-header__mark,
body.app-shell .wsh-header__icon-btn,
body.app-shell .wsh-header__avatar {
    width: 32px;
    height: 32px;
}

body.app-shell .wsh-header__mark,
body.app-shell .wsh-header__icon-btn {
    border-radius: 8px;
}

body.app-shell .wsh-header__mark .material-symbols-outlined {
    font-size: 20px;
}

/* The wordmark is display type at 18px, set to sit against the site's 64px
   band. At 16px it keeps its weight in the shell's shorter one without becoming
   a label. */
body.app-shell .wsh-header__word {
    font-size: 16px;
}

/* includes/header.php opens <main id="main-content"> around everything the
   page emits, so the shell's flex chain has to run through it rather than
   around it. It carries no styling of its own here — it just passes the
   height down and stays the page's single main landmark. */
body.app-shell > #main-content {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    min-height: 0;
    min-width: 0;
    margin: 0;
    padding: 0;
}

.app-shell__body {
    flex: 1;
    display: flex;
    min-height: 0;   /* lets the children own the overflow, not the body */
    min-width: 0;
}

/* The component defaults to M3 Expressive's 4dp destination spacing. The shell
   wants the rows to read as separate targets rather than a stack, so it opens
   them up — expressed as the component's own property rather than a rule that
   fights it, which is what those properties exist for. */
.app-shell .ws-sidenav {
    --ws-sidenav-item-gap: var(--space-4, 16px);

    /* Sizes in px, not the rem tokens the component defaults to: the platform
       runs under an html font-size of 80%, which quietly shrank the expanded
       label's --text-small from 14px to 11.2px and the rail's --text-nano
       from 10px to 8px. The icon is M3's 24dp. */
    --ws-sidenav-icon-size: 24px;
    --ws-sidenav-label-size: 15px;

    /* ---- ink rail ----
       The shell inverts the component's light surface: ink behind the rail,
       paper in the main region. Every colour below is measured against ink
       (#2C2416, relative luminance 0.0186), not assumed.

       Inactive rows sit at 78% white — 9.8:1, comfortably past the 4.5 floor
       even at the rail's small label — rather than solid white, so the active
       row still reads as the brightest thing in the column.

       The active pill is light red carrying a dark red filled glyph, by
       request — the inverse of the solid brand pill it replaces, and better
       measured in the places that matter. In the rail, where the pill wraps
       the icon alone: the glyph is 4.06:1 on the pill, past the 3:1 a filled
       shape needs, and the label sits on ink at 12.6:1. That retires the
       4.01:1 the rail label used to run at.

       The expanded variant is the one to know about. There the pill wraps the
       whole row, so the 15px label sits on it at that same 4.06:1 — under the
       4.5 text floor, though well up from the 3.13:1 this scheme replaced. It
       is the direct consequence of pairing --phase-learn-dark with
       --phase-learn-light, so it is noted rather than quietly adjusted;
       --color-error-strong measures 5.35:1 here if it should pass outright. */
    --ws-sidenav-surface: var(--color-ink, #2C2416);
    --ws-sidenav-on-surface: rgba(255, 255, 255, 0.78);
    --ws-sidenav-indicator: var(--phase-learn-light, #FEE2E2);
    --ws-sidenav-on-indicator: var(--phase-learn-dark, #C73E3E);

    /* The component's hairline is --gray-200, drawn for a light rail against a
       light page. Ink against paper is already an edge; a light line on top of
       it reads as a seam. */
    border-right: 0;
}

/* Breathing room above Home: the component's own 8px top padding sat the first
   pill tight under the header seam. */
.app-shell .ws-sidenav__list {
    padding-top: var(--space-3, 12px);
}

/* Both are --gray-200 in the component: invisible against ink. Translucent
   white instead, so they stay hairlines rather than becoming stripes. */
.app-shell .ws-sidenav__divider {
    background: rgba(255, 255, 255, 0.14);
}

.app-shell .ws-sidenav__footer {
    border-top-color: rgba(255, 255, 255, 0.14);
}

/* M3's 38% disabled content, but the component computes it from --color-ink —
   ink at 38% on ink is nothing at all. */
.app-shell .ws-sidenav__item--disabled,
.app-shell .ws-sidenav__item:disabled,
.app-shell .ws-sidenav__item[aria-disabled="true"] {
    color: var(--text-inverse, #FFFFFF);
}

/* The tooltip is ink-on-ink here. It opens over the paper main, so only the
   sliver still above the rail needs separating — a hairline does it without
   turning the tip into a second surface. */
.app-shell .ws-sidenav__tip {
    outline: 1px solid rgba(255, 255, 255, 0.18);
}

/* ---- the 108px icon rail ---------------------------------------------------
   Wider than M3's 80px and M3 Expressive's 96px, which buys two things: every
   destination label fits on one line, including "Rescue Cards", and the 56px
   indicator sits in generous gutters rather than tight against the edge. The
   label is shown rather than clipped here, so the tooltip goes back to being a
   fallback rather than the only affordance. The toggle still expands to the
   240px rail where icon and label sit side by side.
   ---------------------------------------------------------------------------- */
.app-shell .ws-sidenav--rail {
    --ws-sidenav-width: 108px;
    --ws-sidenav-indicator-w: 56px;        /* M3 spec */
    --ws-sidenav-indicator-h: 32px;
    --ws-sidenav-rail-item-height: 64px;   /* M3 Expressive destination height */
    padding-left: var(--space-2, 8px);
    padding-right: var(--space-2, 8px);
}

/* The label fits again at this width, so it is shown rather than clipped and
   the tooltip goes back to being a fallback for names too long for 96px. */
.app-shell .ws-sidenav--rail .ws-sidenav__label {
    font-size: 12px;
    line-height: 1.3;
    /* Caps set wider than the mixed case this width was chosen for, and
       RESCUE CARDS no longer fits on one line. Wrapping beats shrinking:
       M3 allows two-line rail labels, and a 10px cap label is not a label. */
    white-space: normal;
    text-align: center;
}

.app-shell .ws-sidenav--rail .ws-sidenav__divider {
    width: 56px;
}

/* In the rail the pill wraps the icon alone, so the active label sits on ink,
   not on the pill — and the component paints both from one --on-indicator.
   Split them: the icon keeps the dark red the token carries, and the label
   takes the same light red as the pill, so the two halves of the active state
   read as one thing. 12.6:1 on ink, which also retires the 4.01:1 the old
   --phase-learn label was running at, under the 4.5 floor for a 12px label. */
.app-shell .ws-sidenav--rail .ws-sidenav__item--active,
.app-shell .ws-sidenav--rail .ws-sidenav__item[aria-current="page"] {
    color: var(--phase-learn-light, #FEE2E2);
}

.app-shell .ws-sidenav--rail .ws-sidenav__item--active .ws-sidenav__indicator,
.app-shell .ws-sidenav--rail .ws-sidenav__item[aria-current="page"] .ws-sidenav__indicator {
    color: var(--ws-sidenav-on-indicator);
}

/* Hover on anything not already active: label and icon lift to light red.
   #FCA5A5 (the red ramp's 300 step, reached through --dna-light because that
   is the token carrying the value) measures 8.07:1 on ink. The M3 state layer
   underneath stays; this rides on top of it. */
.app-shell .ws-sidenav__item:hover:not(.ws-sidenav__item--active):not([aria-current="page"]) {
    color: var(--dna-light, #FCA5A5);
}

/* Every label in caps, by request. Tracking opens up with the case: caps set
   solid at label sizes read as a block, and the M3 rail label is tiny. */
.app-shell .ws-sidenav__label {
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.app-shell__main {
    flex: 1;
    min-width: 0;
    overflow-y: auto;
    overflow-x: hidden;

    /* The signature drafting paper. Longhand on purpose: the shorthand
       `background:` wipes background-image, which is how five pages have
       silently lost the grid before. */
    background-color: var(--bg-paper, #FAF7F2);
    background-image: var(--paper-grid-image);
    background-size: var(--paper-grid-size, 24px 24px);
    background-attachment: fixed;
}

/* A section that draws its own canvas takes the region whole: no content
   column, no padding, and no paper, since it paints its own background. The
   region stops scrolling too — the embedded app owns its scrolling. */
.app-shell__main--bleed {
    overflow: hidden;
    display: flex;
}

/* The paper stays under ported markup — the console shows it through the gap
   below its hero, exactly as the standalone page does, where it comes from the
   body. Only the framed builder gets it stripped: it paints its own canvas
   edge to edge, so the grid behind it is never seen and only costs a repaint. */
.app-shell__main--bleed:has(> .app-shell__frame) {
    background-image: none;
    background-color: var(--bg-surface, #FFFFFF);
}

/* Whatever a full-bleed section puts here fills the region. Without this the
   region is a flex row and its child is sized to content: the ported console
   came in at 518px of a 1440px frame, hugging its widest line. The iframe
   below sets the same thing on itself; this covers ported markup, which has no
   reason to know it is a flex item. */
.app-shell__main--bleed > * {
    flex: 1 1 auto;
    min-width: 0;
    max-width: 100%;
}

.app-shell__frame {
    flex: 1;
    width: 100%;
    height: 100%;
    border: 0;
    display: block;
}

/* The live console sizes itself to the viewport, which was right when it owned
   the whole window. Here the region is the viewport minus the header, so 100vh
   pushes the transport bar exactly one header-height below the fold — and that
   bar is where notes and polls are captured mid-session, so losing it is not
   cosmetic. Fill the region instead. */
.app-shell .facilitator-app {
    height: 100%;
    min-height: 0;
}

/* ---- synthesize, ported ----------------------------------------------------
   Two corrections for the frame's zoom (see the scale note at the top).

   Scale: the app was designed at 1:1 and sizes its text in literal px (times
   its own --tscale, base 1.1) plus rem-token ws_* components. Under the
   frame's 0.8 zoom all of it painted at 80% of what /synthesize/ shows —
   and compensating through --tscale alone would leave every rem-sized ws_*
   label (the Start synthesis CTA, alerts, progress) behind at 80%. Zoom
   composes and one property reverses it, exactly the argument the scale
   note at the top makes: 0.8 × 1.25 = 1, so the whole app — type, spacing,
   components, and its vh-sized modals — paints at its native size while the
   shell chrome around it keeps the frame's 80%.

   Alignment: the wrap is a shrink-to-fit flex item whose `margin: 0 auto`
   centres it, so its gap from the rail would grow with the viewport — the
   same reason .app-shell__content is left-aligned (see its note). The left
   margin goes; the app's own wrap padding stays as the inset. */
.app-shell .synth-shell { zoom: 1.25; }

.app-shell .synth .wrap { margin-left: 0; }

/* 48px clear of the rail, 24px below the header. Left-aligned rather than
   centred on purpose: with `margin: 0 auto` the gap from the rail grows with
   the viewport, so "48 from the sidenav" would only be true at one width.
   The column keeps its max-width, it just starts at a fixed offset. */
.app-shell__content {
    max-width: calc(1131px + var(--space-12, 48px) * 2);   /* 1131 column + gutters */
    margin: 0;
    padding: var(--space-6, 24px) var(--space-12, 48px) var(--space-10, 40px);
}

/* The cap above is a reading measure: it keeps prose, forms and the ported
   tool pages off a 1400px line length. A dashboard is not prose. Its columns
   are cards and lists that get better with room, and the cap left 105px of
   dead paper down the right edge on a 1440px screen. Uncapped, the column
   fills the region and the 48px gutters are the only inset left. */
.app-shell__content--wide {
    max-width: none;
}

/* Sections sit on the paper as raised sheets, so the grid reads as the surface
   underneath rather than as texture on the card. */
.app-shell__sheet {
    background: var(--bg-surface, #FFFFFF);
    border: 1px solid var(--gray-200, #E7E5E4);
    border-radius: var(--radius-lg, 8px);
    padding: var(--space-6, 24px);
    box-shadow: var(--shadow-xs, 0 0.7px 1px rgba(0, 0, 0, 0.04));
}

.app-shell__sheet + .app-shell__sheet {
    margin-top: var(--space-5, 20px);
}

.app-shell__eyebrow {
    margin: 0 0 var(--space-2, 8px);
    font-size: var(--text-nano, 0.625rem);
    font-weight: var(--font-bold, 700);
    letter-spacing: 1.2px;
    text-transform: uppercase;
    color: var(--phase-learn-dark, #C73E3E);
}

.app-shell__title {
    margin: 0 0 var(--space-3, 12px);
    font-family: var(--font-heading, 'Fraunces', Georgia, serif);
    font-weight: var(--font-bold, 700);
    font-size: var(--text-h2, 1.5rem);
    letter-spacing: -0.9px;
    color: var(--color-ink, #2C2416);
}

.app-shell__lede {
    margin: 0 0 var(--space-5, 20px);
    max-width: 62ch;
    color: var(--color-ink-secondary, #57534E);
}

.app-shell__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3, 12px);
}

/* ---- the section hero ------------------------------------------------------
   Pill, two-line title with the second line in red italic, lede. Lifted from
   the Strategist's .st-hero, which predates the shell and still styles the
   Strategize view because strategist.css loads there. This is the frame's own
   copy, for ported pages that bring no hero of their own; the two are kept in
   step by both painting from the same --mainsite-* tokens rather than by one
   importing the other. When Strategize is next touched, it should converge
   here and .st-hero can retire.
   ---------------------------------------------------------------------------- */

.app-hero {
    margin-bottom: var(--space-8, 32px);
}

/* Every section opens on its pill, and it was sitting on the content padding
   alone — 16px under the header, which read as the page starting mid-scroll.
   48px, as measured on screen rather than taken from the spacing scale: the
   shell's tokens are declared at 0.67, so var(--space-12) would have landed at
   32. .st-hero is Strategize's own copy of this hero and takes the same rule
   until it converges on .app-hero (see the note above). */
.app-hero,
.app-shell .st-hero {
    padding-top: 48px;
}

.app-hero__eyebrow {
    display: inline-flex;
    align-items: center;
    margin: 0 0 var(--space-3, 12px);
    padding: var(--space-1, 4px) var(--space-5, 20px);
    border: 1px solid var(--mainsite-primary, #E54D4D);
    border-radius: var(--radius-full, 999px);
    background: var(--mainsite-very-light, #FEF2F2);
    color: var(--mainsite-dark, #C73E3E);
    font-size: 12px;
    font-weight: var(--font-bold, 700);
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

/* BETA, riding inside the eyebrow pill rather than beside it. As a sibling pill
   it read as a second eyebrow — same outline, same fill, so the eye had to read
   both to learn one was a status. Inverted and tucked into the pill it is
   unmistakably a marker on the section name. Solid --phase-learn-dark carries
   white at 4.98:1, where the eyebrow's own light fill would have put it at
   3.49:1 on text this small. */
.app-hero__beta {
    display: inline-block;
    margin-left: var(--space-2, 8px);
    padding: 1px var(--space-2, 8px);
    border-radius: var(--radius-full, 999px);
    background: var(--phase-learn-dark, #C73E3E);
    color: var(--text-inverse, #FFFFFF);
    font-size: 10px;
    letter-spacing: 0.08em;
    vertical-align: 1px;
}

.app-hero__title {
    margin: 0 0 var(--space-3, 12px);
    font-family: var(--font-heading, 'Fraunces', Georgia, serif);
    font-weight: var(--font-bold, 700);
    font-size: var(--text-display-lg, 3.5rem);
    line-height: 1.12;
    letter-spacing: -0.02em;
    color: var(--color-ink, #2C2416);
    /* The <br> sets the two lines; balancing would fight it. */
    text-wrap: normal;
}

/* The second line: red italic over a pale underline drawn as an inset shadow,
   so it hugs the text rather than the line box. */
.app-hero__title em {
    font-style: italic;
    color: var(--mainsite-dark, #C73E3E);
    box-shadow: inset 0 -12px 0 var(--mainsite-light, #FEE2E2);
}

/* The lede runs on one line. No max-width, because a measure would break it
   before the column does, and 15.5px rather than 16px because the sentence
   needs 1139px at 16 and the column offers 1131 — eight pixels short, so the
   type gives them back. Deliberately not `white-space: nowrap`: below roughly
   1400px there is no line long enough, and forcing one there would push the
   whole page sideways. It wraps when it must. */
.app-hero__sub {
    margin: 0;
    font-size: 15.5px;
    line-height: 1.6;
    color: var(--color-ink-secondary, #5C5242);
    text-wrap: pretty;
}

/* A workshop card wears a #A8A29E border rather than --gray-200. This rule once
   named five selectors, one per section that drew its own copy of the card.
   .dash__row, .lp2-card, .fach-card, .synh-card and .synh-pickup have all since
   been replaced by ws_workshop_list(), which paints .wl-card from
   app/css/workshop-list.css and sets its own border there. .prvh-card is the
   last hand-rolled one: Prove has no listing behind it yet.

   #A8A29E is --gray-400, four steps up from the --gray-200 the cards had. That
   is a deliberate outline rather than a hairline: on the drafting paper the
   card reads as a drawn edge, which is what separates a workshop from the
   surface it sits on. */
.app-shell .prvh-card {
    border-color: var(--gray-400, #A8A29E);
}

/* ---- objects lifted out of view files --------------------------------------
   These four blocks were <style> tags inside app/views/*.php. Nothing about
   them was page-specific; they were inline only because each view was hand-cut
   from a standalone page that carried its own styles. Inline CSS in a PHP file
   is also invisible to design-system/check-drift.php, which filters on the .css
   extension, so a card written there escapes the card-geometry rule entirely.
   ---------------------------------------------------------------------------- */

/* Modals ship closed. planner/includes/modal-planner.php is included by Plan's
   home view and its overlay has no closed state of its own, so without this the
   modal paints once before the planner CSS arrives. It sits here rather than in
   the view because a rule in <head> beats a rule in <body> to the first paint,
   which is the entire point of a FOUC guard. */
.app-shell .modal-overlay { display: none; }
.app-shell .modal-overlay.open { display: flex; }

/* Plan's Strategist nudge: icon tile, eyebrow, description, CTA. The same shape
   as ws_suggestion_card, and it should become one the next time Plan's home is
   opened; kept as its own rule for now because swapping the component changes
   the markup, not just where the CSS lives. */
.lp2-nudge {
    display: flex;
    align-items: center;
    gap: var(--space-5, 20px);
    margin-top: var(--space-7, 28px);
    padding: var(--space-5, 20px) var(--space-6, 24px);
    background: var(--bg-surface, #fff);
    border: 1px solid var(--gray-200, #E7E5E4);
    border-radius: var(--card-radius, 12px);
}

.lp2-nudge__icon {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 40px;
    height: 40px;
    border-radius: var(--radius-lg, 10px);
    background: var(--planner-very-light, #FEE2E2);
    color: var(--planner-primary, #E54D4D);
}

.lp2-nudge__icon .icon { width: 22px; height: 22px; }
.lp2-nudge__body { flex: 1 1 auto; min-width: 0; }
.lp2-nudge__cta { flex: 0 0 auto; }

.lp2-nudge__eyebrow {
    margin: 0 0 var(--space-1, 4px);
    font-size: 15px;
    font-weight: 650;
    color: var(--gray-900, #1C1917);
}

.lp2-nudge__desc {
    margin: 0;
    font-size: 13.5px;
    line-height: 1.5;
    color: var(--gray-600, #57534E);
}

/* Plan's choice screen centres its intro; inside the shell the hero above it is
   left-aligned, so the two headings would disagree. */
.lp2 .launchpad-choice-screen__header { text-align: left; }

.lp2 .launchpad-choice-screen__subtitle {
    margin-left: 0;
    margin-right: 0;
    max-width: none;
    white-space: nowrap;
}

/* Prove's picker grid. Its cards are the one hand-rolled workshop card left
   (see the .prvh-card border rule above); they become ws_workshop_list() rows
   when Prove gets a listing. Padding and radius come off the card tokens rather
   than the 12px/18px literals the inline block used. */
.prvh-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--space-4, 16px);
}

.prvh-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-3, 12px);
    padding: var(--card-padding, 20px);
    background: var(--bg-surface, #fff);
    border: 1px solid var(--gray-200, #E7E5E4);
    border-radius: var(--card-radius, 12px);
    transition: box-shadow .18s cubic-bezier(.25, .46, .45, .94),
                transform .18s cubic-bezier(.25, .46, .45, .94);
}

.prvh-card:hover {
    transform: translateY(-2px);
    box-shadow: var(--card-shadow-hover, 0 4px 6px rgba(0, 0, 0, 0.07));
}

.prvh-card__name {
    margin: 0;
    font-size: 15.5px;
    font-weight: 650;
    line-height: 1.35;
    color: var(--gray-900, #1C1917);
}

.prvh-card__meta {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1, 4px) var(--space-3-5, 14px);
    font-size: 12.5px;
    color: var(--gray-500, #78716C);
}

.prvh-card__foot {
    display: flex;
    justify-content: flex-end;
    margin-top: auto;
    padding-top: var(--space-2, 8px);
    border-top: 1px solid var(--gray-100, #F5F5F4);
}

/* The PWA install prompt. Shell-level, not Plan-level: /app/manifest.json and
   /app/sw.js are the shell's, and the banner is offering to install the shell.
   It renders only where a view emits #pwaInstallBanner, which today is Plan's
   home alone. */
.pwa-install-banner {
    position: fixed;
    bottom: var(--space-6, 24px);
    left: 50%;
    z-index: 900;
    width: calc(100% - 48px);
    max-width: 560px;
    transform: translateX(-50%);
    animation: pwa-slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes pwa-slide-up {
    from { opacity: 0; transform: translateX(-50%) translateY(20px); }
    to   { opacity: 1; transform: translateX(-50%) translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
    .pwa-install-banner { animation: none; }
}

.pwa-install-banner__content {
    display: flex;
    align-items: center;
    gap: var(--space-3, 12px);
    padding: 14px var(--space-4-5, 18px);
    background: var(--bg-surface, #fff);
    border: 1px solid var(--planner-primary, #E54D4D);
    border-radius: 14px;
    box-shadow: var(--shadow-xl, 0 20px 25px rgba(0, 0, 0, 0.10));
}

.pwa-install-banner__content > .icon {
    flex-shrink: 0;
    width: 28px;
    height: 28px;
    color: var(--planner-primary, #E54D4D);
}

.pwa-install-banner__text { flex: 1; min-width: 0; }

.pwa-install-banner__text strong {
    display: block;
    font-size: 14px;
    font-weight: var(--font-semibold, 600);
    color: var(--gray-900, #1C1917);
}

.pwa-install-banner__text span {
    font-size: 13px;
    color: var(--gray-500, #78716C);
}

.pwa-install-banner__dismiss {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    padding: var(--space-1, 4px);
    background: none;
    border: none;
    border-radius: var(--radius-sm, 4px);
    color: var(--gray-400, #A8A29E);
    cursor: pointer;
}

.pwa-install-banner__dismiss:hover {
    color: var(--gray-900, #1C1917);
    background: var(--gray-100, #F5F5F4);
}

.pwa-install-banner__dismiss .icon { width: 18px; height: 18px; }

@media (max-width: 760px) {
    .lp2 .launchpad-choice-screen__subtitle { white-space: normal; }
}

@media (max-width: 900px) {
    .prvh-grid { grid-template-columns: minmax(0, 1fr); }
}

@media (max-width: 640px) {
    .lp2-nudge { flex-direction: column; align-items: flex-start; gap: 14px; }
    .pwa-install-banner { bottom: var(--space-4, 16px); width: calc(100% - 32px); }
    .pwa-install-banner__content { padding: var(--space-3, 12px) 14px; }
}

@media (prefers-reduced-motion: reduce) {
    .prvh-card, .prvh-card:hover { transition: none; transform: none; }
}

/* ---- ported sections -------------------------------------------------------
   A section moved inside the frame brings its own stylesheet, which was written
   for a full-width page: .st-page centres itself at 1040px and, above 1000px,
   swaps that for a fixed 237px left inset so its content lines up with the
   STRATEGIST tag in the platform header. Inside the shell the rail occupies
   that space, so the inset would push everything off-centre. The shell's
   container already handles width and gutters — strip the page's own.
   ---------------------------------------------------------------------------- */
.app-shell .st-page,
.app-shell .st-land {
    max-width: none;
    margin: 0;
    padding: 0;
}

/* The sentence step used to need a wider cap of its own: at the shell's old
   0.67 scale the content column was 758px, which broke the sentence into short
   ragged lines mid-clause, so the step was pinned at the 992px card it renders
   at standalone. At platform scale the column is 1131px and the card clears
   that on its own, so the override is gone rather than left pinning the step
   narrower than the page it sits on. */

/* The planner's choice screen has the same habit, expressed differently: it
   caps itself at 1120px and centres with `margin: 0 auto` to line up with the
   global nav's inner width, then adds its own side padding on top. Inside the
   shell that put every block 30px right of the hero — 5.5px of auto margin
   plus 24px of padding — so the page had two left edges. The content column
   owns width, gutters and alignment here; the page keeps only its own
   vertical rhythm, and the hero's margin sets the gap above it. */
.app-shell .launchpad-choice-screen,
.app-shell .lp2,
.app-shell .fach,
.app-shell .prvh,
.app-shell .synh {
    max-width: none;
    margin: 0;
    padding-left: 0;
    padding-right: 0;
    padding-top: 0;
}

/* One size for every section heading across the ported sections, sitting under
   the hero's 44.8px display type. They arrived at three different sizes —
   25.6px, 28px and 19.2px — for no reason other than three pages having been
   written separately; a single rule is what stops that happening again.
   24px is the settled figure: clearly subordinate to the hero, clearly a
   heading over the group of cards beneath it.

   Only one selector left. The other four — .lp2-recent__title, .fach-title,
   .fach-section-title, .synh-section-title — were the three pages' own section
   headings, and all three now head their list with ws_workshop_list()'s
   .wl__title instead. That is deliberately not named here: the listing sets its
   own size once, for every page that uses it, which is the consistency this
   rule was hand-maintaining. */
.app-shell .launchpad-choice-screen__title {
    font-size: 24px;
}

/* The sentence deliberately owns the first viewport on the open web. In the
   shell the viewport is the main region, which already has the header and rail
   above and beside it, so the dvh minimum would only push the exit strip below
   a fold that no longer means anything. */
.app-shell .st-viewport {
    min-height: 0;
}

/* Every ported page leads with a breadcrumb, and it sits 24px below the header
   on all of them. On the open web that 24px is assembled from the page's own
   offsets; inside the shell those stack on top of the content padding instead
   (24 + 24 viewport + 16 crumb margin = 64 on the sentence, 40 on the tool
   pages). Zero the page's own top offsets and let the content padding be the
   single source of the gap. */
.app-shell .st-viewport,
.app-shell .st-kit,
.app-shell .st-kit__head,
.app-shell .st-subhead {
    padding-top: 0;
    margin-top: 0;
}

/* No breadcrumbs inside the shell. On the open web the crumb is the Strategist's
   only way back up; here the rail and the kit's own cards do that job, so the
   crumb was a second nav restating where the rail already says you are.
   display:none rather than removing the markup: the same view files serve the
   standalone app, where the crumb still earns its place, and .st-crumbs is a
   JS contract (bindNav stamps ?id= onto it) that stays harmless when hidden. */
.app-shell .st-crumbs,
.app-shell .st-page .ws-breadcrumb {
    display: none;
}

/* ---- the app launcher ------------------------------------------------------
   What More opens: every application on the platform, grouped by phase, the
   same shape as the platform header's app-switcher. In a chromeless window
   this is the only way to an app the rail does not list, so it covers the
   whole frame rather than hanging off the rail as a menu.
   ---------------------------------------------------------------------------- */

.app-launcher {
    position: fixed;
    inset: 0;
    z-index: 1200;   /* over the header, whose dropdown panels sit at 1000 */
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-6, 24px);
}

.app-launcher[hidden] { display: none; }

.app-launcher__scrim {
    position: absolute;
    inset: 0;
    background: rgba(44, 36, 22, 0.55);
    animation: app-launcher-fade 140ms ease;
}

/* Column, unpadded, clipped: the head is a full-bleed band like ws_modal's
   coloured header, so the panel cannot carry the padding and the corners have
   to cut the band rather than let it square them off. The list below it takes
   the scrolling that used to be on the panel, which keeps the header in place
   while the apps move under it — the same split ws_modal makes with a
   flex-shrink:0 header over a scrolling body. */
.app-launcher__panel {
    position: relative;
    display: flex;
    flex-direction: column;
    /* Wide enough that the four phase columns each hold a two-word app name and
       its line of description without breaking mid-phrase — at 880 every
       column wrapped, which made four short lists read as one dense block. */
    width: min(1040px, 100%);
    max-height: 100%;
    overflow: hidden;
    background: var(--bg-surface, #FFFFFF);
    border-radius: var(--radius-xl, 12px);
    box-shadow: var(--shadow-xl, 0 16px 32px rgba(0, 0, 0, 0.18));
    animation: app-launcher-rise 160ms cubic-bezier(0.2, 0, 0, 1);
}

@keyframes app-launcher-fade { from { opacity: 0; } }
@keyframes app-launcher-rise { from { opacity: 0; transform: translateY(8px); } }

@media (prefers-reduced-motion: reduce) {
    .app-launcher__scrim,
    .app-launcher__panel { animation: none; }
}

/* The brand band, per the coloured header ws_modal documents and the Strategist
   already uses — same --phase-learn, same white title and translucent close.
   Horizontal padding matches the list below so the title sits on the same left
   edge as the first column of apps. */
.app-launcher__head {
    display: flex;
    align-items: center;
    gap: var(--space-3, 12px);
    flex-shrink: 0;
    padding: var(--space-4, 16px) var(--space-6, 24px);
    background: var(--phase-learn, #E54D4D);
}

.app-launcher__title {
    margin: 0;
    font-family: var(--font-heading, 'Fraunces', Georgia, serif);
    font-weight: var(--font-bold, 700);
    font-size: 22px;
    letter-spacing: -0.6px;
    /* 3.8:1 on --phase-learn — AA for large text, which 22px bold is. */
    color: var(--text-inverse, #FFFFFF);
}

/* ws_icon_button brings the shape, the hover and the focus ring. All this adds
   is where it sits, and the white it has to wear on the band — the same
   treatment ws_modal gives a close button on a coloured header. */
/* Every close on the platform is ws_modal's: no border, no fill at rest, and on
   a coloured header a 70% white glyph over a 10% white wash on hover. The twist
   is ws_modal's too, and it turns the glyph rather than the button.
   ws_icon_button arrives wearing ghost chrome — a grey border and a near-white
   hover fill — which on the band read as a white box with a white glyph lost
   inside it, so both are cleared. `:not(:disabled)` is there to match the
   specificity of the `.ws-btn--ghost:hover:not(:disabled)` it has to beat. */
.app-launcher__close {
    margin-left: auto;
    flex-shrink: 0;
    background: none;
    border: none;
    color: var(--overlay-light, rgba(255, 255, 255, 0.7));
}

.app-launcher__close:hover:not(:disabled) {
    background: rgba(255, 255, 255, 0.1);
    color: var(--text-inverse, #FFFFFF);
}

.app-launcher__close .ws-btn__icon {
    transition: transform var(--duration-base, 200ms) var(--ease-out, ease-out);
}

.app-launcher__close:hover .ws-btn__icon {
    transform: rotate(90deg);
}

@media (prefers-reduced-motion: reduce) {
    .app-launcher__close .ws-btn__icon { transition: none; }
    .app-launcher__close:hover .ws-btn__icon { transform: none; }
}

/* Four phases across, one column each, so the launcher reads as the journey
   rather than as an alphabetical drawer. It carries the panel's padding now
   that the head is a full-bleed band, and does the scrolling the panel used
   to, so a long list moves under a header that stays put. */
.app-launcher__groups {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: var(--space-5, 20px);
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    padding: var(--space-6, 24px);
}

@media (max-width: 860px) {
    .app-launcher__groups { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 520px) {
    .app-launcher__groups { grid-template-columns: 1fr; }
    .app-launcher { padding: 0; }
    .app-launcher__panel {
        width: 100%;
        height: 100%;
        max-height: none;
        border-radius: 0;
    }
}

.app-launcher__phase {
    margin: 0 0 var(--space-2, 8px);
    padding: 0 var(--space-2, 8px);
    font-size: 10px;
    font-weight: var(--font-bold, 700);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--color-error-strong, #B91C1C);
}

.app-launcher__app {
    position: relative;
    display: flex;
    align-items: flex-start;
    gap: var(--space-2-5, 10px);
    padding: var(--space-2, 8px);
    border-radius: var(--radius-md, 6px);
    color: inherit;
    text-decoration: none;
    transition: background-color 140ms ease;
}

.app-launcher__app:hover { background: var(--phase-learn-very-light, #FEF2F2); }

.app-launcher__app:focus-visible {
    outline: var(--focus-ring, 2px solid #0284C7);
    outline-offset: -2px;
}

.app-launcher__app.is-current {
    background: var(--phase-learn-light, #FEE2E2);
}

.app-launcher__icon {
    flex: none;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border-radius: var(--radius-sm, 4px);
    background: var(--phase-learn-light, #FEE2E2);
    color: var(--color-error-strong, #B91C1C);
}

.app-launcher__icon .material-symbols-outlined,
.app-launcher__icon .icon { font-size: 20px; width: 20px; height: 20px; }

.app-launcher__meta { min-width: 0; }

.app-launcher__name {
    display: block;
    font-size: 14px;
    font-weight: var(--font-semibold, 600);
    color: var(--color-ink, #2C2416);
}

.app-launcher__desc {
    display: block;
    margin-top: 1px;
    font-size: 12px;
    line-height: 1.35;
    color: var(--color-ink-secondary, #5C5242);
}

/* The leaves-the-frame mark. Quiet on purpose: it is a caveat about where the
   link lands, not a second thing to read before choosing. */
.app-launcher__out {
    position: absolute;
    top: var(--space-2, 8px);
    right: var(--space-2, 8px);
    display: flex;
    color: var(--gray-400, #A8A29E);
}

.app-launcher__out .material-symbols-outlined,
.app-launcher__out .icon { font-size: 14px; width: 14px; height: 14px; }

/* Below the rail's breakpoint the sidenav becomes a bottom bar — the same
   navigation model in its phone form, which is what the participant view
   already proves. The rail is a poor fit for a thumb. */
@media (max-width: 900px) {
    /* Five destinations plus More. The secondary rows drop out, because eight
       tabs truncate every label and a tab bar wants three to five; More opens
       the launcher, which lists them along with everything else. */
    .app-shell__body > .ws-sidenav .ws-sidenav__row:has(> .app-shell__nav-secondary) {
        display: none;
    }

    .app-shell__body {
        flex-direction: column;
    }
    .app-shell__body > .ws-sidenav {
        order: 2;
        width: 100%;
        height: auto;
        flex-direction: row;
        border-right: 0;
        border-top: 1px solid rgba(255, 255, 255, 0.14);
        padding: var(--space-1, 4px) var(--space-2, 8px)
                 max(var(--space-1, 4px), env(safe-area-inset-bottom));
    }
    .app-shell__body > .ws-sidenav .ws-sidenav__brand,
    .app-shell__body > .ws-sidenav .ws-sidenav__divider,
    .app-shell__body > .ws-sidenav .ws-sidenav__toggle {
        display: none;
    }
    .app-shell__body > .ws-sidenav .ws-sidenav__list {
        flex-direction: row;
        justify-content: space-around;
        width: 100%;
    }
    .app-shell__body > .ws-sidenav .ws-sidenav__row {
        width: auto;
    }
    /* Tooltips would open off the bottom of the screen; the labels are visible
       here anyway. */
    .app-shell__body > .ws-sidenav .ws-sidenav__tip {
        display: none;
    }
    /* Six tabs share 390px, so the caps treatment has to give ground here:
       full-size tracked caps pushed even HOME into ellipsis. Smaller and
       tighter, and back to one line — a wrapped tab label reads as two tabs. */
    .app-shell__body > .ws-sidenav .ws-sidenav__label {
        font-size: 10px;
        letter-spacing: 0.02em;
        white-space: nowrap;
    }
    .app-shell__main {
        order: 1;
    }
}

@media (max-width: 600px) {
    .app-shell__content {
        padding: var(--space-5, 20px) var(--space-4, 16px) var(--space-8, 32px);
    }
}

/* ==========================================================================
   First-run onboarding (app/js/app-onboarding.js)
   Welcome overlay + two coach marks. Mirrors the Facilitator module's fac-ob /
   fac-tour structure so the two systems stay recognisably one pattern.
   ========================================================================== */

.app-ob {
    position: fixed;
    inset: 0;
    z-index: 1200;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-4, 16px);
    background: rgba(44, 36, 22, 0.45);
}

.app-ob__card {
    background: var(--bg-surface, #FFFFFF);
    border-radius: var(--radius-lg, 8px);
    box-shadow: var(--shadow-xl, 0 13px 34px rgba(0, 0, 0, 0.2));
    max-width: 440px;
    width: 100%;
    padding: var(--space-5, 20px);
    display: flex;
    flex-direction: column;
    gap: var(--space-3, 12px);
}

.app-ob__badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2, 8px);
    align-self: flex-start;
    font-size: var(--text-meta, 0.8125rem);
    font-weight: 600;
    color: var(--phase-learn-dark, #C73E3E);
    background: var(--phase-learn-very-light, #FDF0F0);
    border-radius: var(--radius-full, 999px);
    padding: var(--space-1, 4px) var(--space-3, 12px);
}

.app-ob__badge .material-symbols-outlined { font-size: var(--text-body, 1rem); }

.app-ob__title {
    font-family: var(--font-heading, 'Fraunces', serif);
    font-size: var(--text-h2, 1.5rem);
    color: var(--color-ink, #2C2416);
    margin: 0;
}

.app-ob__sub {
    font-size: var(--text-small, 0.875rem);
    color: var(--color-ink-secondary, #5C5242);
    margin: 0 0 var(--space-2, 8px);
}

.app-ob__feat {
    display: flex;
    gap: var(--space-3, 12px);
    align-items: flex-start;
    font-size: var(--text-small, 0.875rem);
}

.app-ob__feat > .material-symbols-outlined {
    color: var(--phase-learn-dark, #C73E3E);
    font-size: var(--text-h3, 1.25rem);
    margin-top: 1px;
}

.app-ob__feat b { color: var(--color-ink, #2C2416); }

.app-ob__feat > span:last-child {
    display: flex;
    flex-direction: column;
}

.app-ob__feat > span:last-child > span {
    color: var(--color-ink-secondary, #5C5242);
}

.app-ob__cta {
    margin-top: var(--space-3, 12px);
    display: inline-flex;
    justify-content: center;
    align-items: center;
    background: var(--phase-learn, #E54D4D);
    color: var(--bg-surface, #FFFFFF);
    border: none;
    border-radius: var(--radius-md, 6px);
    padding: var(--space-3, 12px) var(--space-5, 20px);
    font-weight: 600;
    font-size: var(--text-body, 1rem);
    text-decoration: none;
    cursor: pointer;
}

.app-ob__cta:hover { background: var(--phase-learn-dark, #C73E3E); }

.app-ob__skip {
    background: none;
    border: none;
    color: var(--gray-500, #78716C);
    font-size: var(--text-small, 0.875rem);
    cursor: pointer;
    padding: var(--space-1-5, 6px);
}

.app-ob__skip:hover { color: var(--color-ink, #2C2416); text-decoration: underline; }

.app-tour {
    position: fixed;
    inset: 0;
    z-index: 1200;
    pointer-events: none;
}

.app-tour__hole {
    position: fixed;
    border-radius: var(--radius-md, 6px);
    box-shadow: 0 0 0 9999px rgba(44, 36, 22, 0.45);
    transition: all 0.25s ease;
}

.app-tour__tip {
    position: fixed;
    width: 300px;
    background: var(--bg-surface, #FFFFFF);
    border-radius: var(--radius-md, 6px);
    box-shadow: var(--shadow-xl, 0 13px 34px rgba(0, 0, 0, 0.2));
    padding: var(--space-4, 16px);
    pointer-events: auto;
}

.app-tour__step {
    font-size: var(--text-meta, 0.8125rem);
    color: var(--gray-500, #78716C);
    margin-bottom: var(--space-1, 4px);
}

.app-tour__title {
    font-family: var(--font-heading, 'Fraunces', serif);
    font-size: var(--text-h4, 1.125rem);
    color: var(--color-ink, #2C2416);
    margin: 0 0 var(--space-1-5, 6px);
}

.app-tour__body {
    font-size: var(--text-small, 0.875rem);
    color: var(--color-ink-secondary, #5C5242);
    margin: 0 0 var(--space-3, 12px);
    line-height: 1.5;
}

.app-tour__row {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.app-tour__skip {
    background: none;
    border: none;
    color: var(--gray-500, #78716C);
    font-size: var(--text-meta, 0.8125rem);
    cursor: pointer;
    padding: var(--space-1, 4px);
}

.app-tour__next {
    background: var(--phase-learn, #E54D4D);
    color: var(--bg-surface, #FFFFFF);
    border: none;
    border-radius: var(--radius-sm, 4px);
    padding: var(--space-2, 8px) var(--space-4, 16px);
    font-weight: 600;
    font-size: var(--text-small, 0.875rem);
    cursor: pointer;
}

.app-tour__next:hover { background: var(--phase-learn-dark, #C73E3E); }
