/* App shell: fixed header, center track, and one slide-in side panel — #panel,
   at the end, holding notifications. Base mechanics adapted from submissio's
   layout; the panel is CSS-only, toggled by a checkbox via :has(). It is named
   "panel", not "dock" — "dock" is reserved for project tools.

   There was a matching #sidebar at the start edge until 2026-08-11, mirroring
   every rule here. No view ever rendered one: the grid kept a third column for
   it that measured 0px in every layout probe. Removed rather than kept warm —
   the shape is three commits back if a start panel is ever wanted, and until
   then this file describes what the app actually builds.

   THE SHELL BREAKPOINT IS 35rem, WRITTEN AS A RANGE. It used to be a 70ch pair,
   which was wrong twice. First, `min-width: 70ch` and `max-width: 70ch` BOTH
   match at exactly 70ch, so at that one width the desktop grid and the mobile
   panel rules applied together: opening notifications squeezed #main to 176px
   and then covered it with a full-screen overlay (measured). Fizzy sidesteps
   this by pairing 640px with 639px; `width < 35rem` / `width >= 35rem` says the
   same thing without the off-by-one. Second, `ch` in a media query resolves
   against the INITIAL font, not ours — 70ch was 560px here while the very same
   70ch in a @container query is ~705px. Two numbers wearing one name. 35rem is
   the 560px this shell has always used, said once and honestly. */
@layer base {
  html:has(body.app) {
    /* Half of a pair with body's clip below: on its own, body's overflow
       would propagate to the viewport (html is visible) and turn into the
       root's — where Chrome ignores clip — leaving body itself unclipped.
       Any non-visible value here keeps body's clip its own. */
    overflow-x: hidden;

    /* An app shell, not a page: nothing above or below to rubber-band to,
       and the programmatic scrolls scroll-padding already serves — anchor
       jumps, scrollIntoView — glide rather than teleport (chalet). */
    overscroll-behavior: none;
    scroll-behavior: smooth;

    @media (prefers-reduced-motion: reduce) {
      scroll-behavior: auto;
    }

    /* The bar heights live here, on the scroll container, so scroll-padding
       can keep programmatic scrolls — scrollIntoView, anchor jumps — from
       tucking their target under a fixed bar; body inherits both for its own
       padding. Header: tall enough for the jump pill to sit in rather than
       fill (at 2.75rem a 28px control left 8px above and below and read as
       pinned to the top of the screen). Footer: just enough for a 28px avatar
       circle with the header's own breathing room.

       The notch and the home indicator are folded in here rather than added at
       each call site, because every place that reads these wants the same
       thing: how much room the bar actually takes. Fold once and body's
       padding, the panel's negative margin, scroll-padding, the flash offset
       and the tray's fallback are all right for free. */
    --header-height: calc(3.25rem + var(--safe-inset-top));
    --footer-height: calc(3rem + var(--safe-inset-bottom));

    /* A phone's bar gives back what it can: 3rem still seats the jump pill
       with air (the 2.75rem floor is what read as pinned), and every reader
       of the folded value follows for free. */
    @media (width < 35rem) {
      --header-height: calc(3rem + var(--safe-inset-top));
    }
    scroll-padding-block: calc(var(--header-height) + var(--block-space))
      calc(var(--footer-height) + var(--block-space));
  }

  body.app {
    /* The closed panel slides out via negative end margin, and its off-canvas
       box must not merely be hidden but unreachable: html's hidden only
       removes the scrollbar, and the root can still be scrolled
       programmatically — chromedriver centring a click target near the right
       edge was enough — which dragged the closed panel into view and pushed
       #main off the left edge. clip here removes the overhang from scrollable
       overflow entirely, so the root has nowhere to scroll to. */
    overflow-x: clip;

    --panel-width: 24rem;
    /* The margin the sheet keeps at either side, and the one place it is
       said: a to-do row's ⋯ waits this far outside the sheet (todos.css),
       and a sheet that reaches the viewport's edge puts it past the edge. */
    --main-gutter: 2.25rem;
    /* Width left for the center track once the open panel is subtracted. Any
       viewport-based width on #main must min() against this: a definite
       inline-size acts as a floor for the fr track, so an unaware 90vw page
       would push an open panel off-screen. */
    --main-available: 100vw;

    /* The fixed header and footer are out of flow; keep the grid between them. */
    padding-block-start: var(--header-height);
    padding-block-end: var(--footer-height);

    display: grid;
    grid-template-rows: auto 1fr;
    grid-template-columns: 1fr;
    grid-template-areas:
      "header"
      "main";

    @media (width >= 35rem) {
      grid-template-columns: 1fr auto;
      grid-template-areas:
        "header panel"
        "main panel";

      /* The panel PUSHES the page only on a wide shell; below --panel-push
         it overlays instead (the fixed rule at the end of this file), the way
         Basecamp's slideover does — squeezing a 900px page to 500px looked
         bad (user, 2026-08-23). */
      &:has(#panel-toggle:checked) {
        @media (width >= 64rem) {
          /* Less a gutter on both sides: between 64rem and where 95ch fits,
             the sheet filled the whole column and the ⋯ in its left gutter
             stood past the viewport's edge (user, 2026-08-23). */
          --main-available: calc(100vw - var(--panel-width) - 2 * var(--main-gutter));
        }
      }
    }

    /* Turbo-injected body children must not steal grid cells. (Cable stream
       sources are already boxless app-wide — base.css.) */
    > turbo-frame {
      display: contents;
    }
  }

  /* Keyboard users' first tab stop (fizzy): parked far off-screen, it slides
     in under the header only while focused. A left offset rather than
     display none, so focus can actually land on it. */
  .skip-navigation {
    --left-offset: -999em;

    position: absolute;
    inset-block-start: calc(var(--header-height) + var(--block-space-half));
    inset-inline-start: var(--left-offset);
    white-space: nowrap;
    z-index: var(--z-overlay);

    &:focus {
      --left-offset: var(--inline-space);
    }
  }

  /* While a soft keyboard is up the footer stands down: the bar rode above
     the keys mid-screen saying nothing (user, 2026-08-24). Every field in the
     app, not only a chat composer — writing is writing (user, 2026-08-30) —
     which a class on <html> gives for free. The folded height goes with it,
     so the sheet grows to the composer's rule.

     `any-hover: none` is fizzy's touch assertion (nav.css there): any-pointer
     reads like the better fit and is misreported on many devices.

     THE KEYBOARD IS NOT A CSS STATE, AND :focus-within IS NOT A STAND-IN.
     Focus flips synchronously with the tap; a keyboard retracts on its own
     animation afterwards. Hang the rule on focus and a tap on any button
     while writing brings the footer back at mousedown — over the button, or
     under it — and the click lands on nothing. The edit form's Save died this
     way (2026-08-24), and six system tests died again when the rule was tried
     as pure CSS (2026-08-30): the composer held its text and never submitted.
     initializers/keyboard.js reads the visual viewport instead, which moves
     with the keyboard rather than with the blur, and stamps `keyboard-up`. */
  @media (any-hover: none) {
    /* `:has(body.app)` is not decoration: --footer-height is declared on that
       same selector above, and `html.keyboard-up` alone is a class short of it.
       The fold silently lost the cascade — measured 2026-08-30, `calc(3rem +
       0px)` with the class on — so the bar vanished while its 48px stayed, and
       that band of canvas sat between the composer and the keys. */
    html:has(body.app).keyboard-up {
      /* Nothing, not even the home indicator's inset: the keyboard covers the
         indicator, so reserving for it leaves a band of canvas between the
         composer and the keys and nothing stands in it. */
      --footer-height: 0px;

      #footer {
        display: none;
      }

      /* And the chat sheet gives up its own foot: that padding holds the
         composer off the bar, and there is no bar. Chalet's composer sits
         against the keys with nothing between (user, 2026-08-30). */
      #main:has(.message-area) {
        padding-block-end: 0;
      }
    }
  }

  /* Basecamp's footer: avatar with the account menu at the start, the "my"
     links centred. Fixed like the header, and it steps aside for open panels
     the same way; canvas paint and no hairline — the sheet scrolling under is
     what draws the edge, exactly as at the top. */
  :where(#footer) {
    position: fixed;
    inset-block-end: 0;
    inset-inline: 0;
    block-size: var(--footer-height);
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    /* The bar is full-bleed, so it owns the insets its content must clear —
       the home indicator below, and a landscape notch at either side. */
    padding-block-end: var(--safe-inset-bottom);
    padding-inline: calc(var(--inline-space-double) + var(--safe-inset-left))
      calc(var(--inline-space-double) + var(--safe-inset-right));
    background-color: var(--color-canvas);
    view-transition-name: footer;
    z-index: var(--z-chrome);
    transition: inset-inline-end var(--speed) var(--ease-out);

    :has(#panel-toggle:checked) & {
      @media (width >= 64rem) {
        inset-inline-end: var(--panel-width);
      }
    }

    /* Three "My …" labels and the avatar want every pixel at 390px: with 2ch a
       side they wrapped to two lines inside a one-line bar, 49px of content in
       a 48px box, the last row sitting below the bottom edge. A tighter gutter
       is the whole fix — no shortened labels, no dropped control. */
    @media (width < 35rem) {
      padding-inline: calc(var(--inline-space) + var(--safe-inset-left))
        calc(var(--inline-space) + var(--safe-inset-right));
    }

    /* The permanent wrapper is plumbing, not a grid item — its children are. */
    #footer_frames {
      display: contents;
    }

    .footer__links {
      grid-column: 2;
      display: flex;
      gap: var(--inline-space);
    }

  }

  :where(#header) {
    /* Fixed, not sticky: as a grid item the header's containing block is its
       own single-row area, so sticky has no room to travel. The open panel owns
       its column to the viewport top; the header spans only the center. */
    position: fixed;
    inset-block-start: 0;
    inset-inline: 0;
    block-size: var(--header-height);
    /* The bar reaches the top edge under viewport-fit=cover; this is what
       keeps its contents out from under the notch. */
    padding-block-start: var(--safe-inset-top);
    background-color: var(--color-canvas);
    view-transition-name: header;
    z-index: var(--z-chrome);
    transition: inset-inline-end var(--speed) var(--ease-out);

    :has(#panel-toggle:checked) & {
      @media (width >= 64rem) {
        inset-inline-end: var(--panel-width);
      }
    }

    /* Three columns, not a flex row with a spacer: the jump control sits in
       the middle and must stay in the middle of the *header*, not wherever
       the trail on its left happens to end. Equal 1fr sides do that no matter
       how long the breadcrumbs get. */
    > nav {
      align-items: center;
      block-size: 100%;
      display: grid;
      grid-template-columns: 1fr auto 1fr;
      gap: var(--inline-space);
      padding-inline: calc(var(--inline-space-double) + var(--safe-inset-left))
        calc(var(--inline-space-double) + var(--safe-inset-right));
    }

    .header__start,
    .header__end {
      display: flex;
      align-items: center;
      gap: var(--inline-space);
      min-inline-size: 0;
    }

    .header__end {
      justify-content: end;
    }
  }

  /* Named, because content inside it must respond to ITS width and not the
     viewport's. #main narrows by --panel-width when the notification panel
     opens, so a viewport media query on a board or a record sheet asks the
     wrong question: between roughly 560px and 944px of viewport with the panel
     open, #main is already narrower than the breakpoint that never fired.
     Basecamp drives its whole responsive layout from one named container for
     exactly this reason. */
  /* The containers' text answers the containers' width. `body` sets the
     fluid unit, but body sits outside any container, so its `cqi` fell back
     to the viewport and everything that merely INHERITS its size — a chat
     bubble, a notification row — read at window width even inside the 24rem
     panel (user, 2026-08-24). Declared sizes never had this problem: the
     tokens live on every element, so a `.label` in the panel already asked
     the panel. Re-saying the fluid unit once on each container's children
     closes the gap: the panel reads at its floor, and the sheet's text
     narrows with #main when the panel pushes it — the promise in _global.css
     made true for inherited text too. */
  :where(#main, #panel) > :where(*) {
    font-size: var(--text-normal);
  }

  :where(#main) {
    container-type: inline-size;
    container-name: main;
    grid-area: main;
    inline-size: min(95ch, 92vw, var(--main-available, 100vw));
    margin-inline: auto;
    max-inline-size: 100%;
    padding-block: var(--block-space) var(--block-space-double);
    padding-inline: var(--inline-space-double);

    /* Full-bleed below the shell breakpoint, so the sheet owns the side insets
       too — in landscape the notch is on one of these edges. */
    @media (width < 35rem) {
      inline-size: 100vw;
      padding-inline: calc(var(--inline-space) + var(--safe-inset-left))
        calc(var(--inline-space) + var(--safe-inset-right));
    }
  }

  /* A chat is the one page whose sheet is a fixed box: the stream scrolls
     inside it and the composer holds the foot. Sized by the shell because the
     shell owns the bars it clears — the same reason the board's width lives
     here. */
  #main:has(.message-area) {
    display: flex;
    flex-direction: column;
    block-size: calc(100dvh - var(--header-height) - var(--footer-height));
    padding-block-end: var(--block-space);

    /* On a phone the room runs to the glass (chalet, user 2026-08-30): the
       sheet keeps only the notch insets and the room's own --room-inset
       (messages.css) is the whole gutter — one 1ch breath instead of two.
       The head and the toolbar don't read the room's inset, so they take
       the same breath here. */
    @media (width < 35rem) {
      padding-inline: var(--safe-inset-left) var(--safe-inset-right);

      .toolbar,
      .chat-head {
        padding-inline: var(--inline-space);
      }
    }
  }

  /* A board is the one page family that outgrows the prose measure: the shell
     asks what it holds rather than every board page setting a class on an
     element it does not render (user, 2026-08-06 — full available width).
     `.card-columns` is the card board, `.board__columns` the pipeline's;
     both want the same answer, so they join the selector rather than restate
     it. */
  #main:has(.board__columns),
  #main:has(.card-columns) {
    inline-size: min(97%, var(--main-available, 100vw));
  }

  /* A recording sheet is one column at one measure (user, 2026-08-29): the
     thread gives up its own max-width-prose and the sheet narrows to meet it,
     so the toolbar, the title, the body, the talk and the footer share one
     left edge and one right edge. Three edges stacked down a page is what the
     old pairing drew — the article at 95ch, the thread at 65ch, the
     subscribers strip back at 95ch.

     84ch is the middle ground: 72ch reads as a column imposed on the layout,
     95ch is a measure nobody wants to read a comment across.

     Asked of a class the sheet wears, never of what the page happens to hold.
     The rule read :has(.comments) first and that got both halves wrong. A
     drafted or a trashed message draws no thread at all — comments hang off an
     active recording, so projects/messages/show renders the section only for
     one — and the very page family this was written for therefore fell out of
     it: read a draft at 95ch, click Edit, and the words reflow into 84ch,
     which is the one thing the measure exists to prevent. In the other
     direction it swept in every page that merely contains a thread, the
     card-column page among them, which caps its own content at 65ch and took
     nothing from 84ch but a toolbar pulled in over content that never moved
     (user, 2026-08-29).

     So .sheet is a marker and has no styling of its own, the way
     .board__columns and .message-area above are read for what the page is.
     A page wears it whether or not there is anything to say yet.

     Above the shell breakpoint only. Below it every sheet is full-bleed, and
     the rule that says so lives at zero specificity inside :where(#main), so
     an unguarded rule here would beat it and take a phone's sheet off its
     safe insets. */
  @media (width >= 35rem) {
    #main:has(.sheet) {
      inline-size: min(84ch, 92vw, var(--main-available, 100vw));
    }
  }

  /* The panel: viewport-sticky, column-tall, slid closed by a negative inline
     margin. It overhangs the body's header padding by a negative block margin
     so its color reaches the viewport top edge-to-edge; its own padding
     re-seats the content below the header line. */
  :where(#panel) {
    background-color: var(--color-surface);
    block-size: 100dvh;
    container-type: inline-size;
    container-name: panel;
    grid-area: panel;
    inline-size: var(--panel-width);
    inset-block-start: 0;
    margin-block-start: calc(-1 * var(--header-height));
    /* And overhangs the body's footer padding by the same trick: without
       it the panel's row is 100dvh − header while #main is 100dvh − header
       − footer, the document runs 48px past the viewport, and the first
       thing that asks to be revealed — a focused editor inside the panel —
       scrolls the whole page under the header (user, 2026-08-23). */
    margin-block-end: calc(-1 * var(--footer-height));
    max-block-size: 100dvh;
    overflow: auto;
    position: sticky;
    transition:
      margin-inline-end var(--speed) var(--ease-out),
      inline-size var(--speed) var(--ease-out);
    padding-block-start: calc(var(--header-height) + var(--block-space-half));
    padding-inline: var(--inline-space-double);
    padding-block-end: var(--block-space);

    :has(#panel-toggle:checked) & {
      margin-inline-end: 0;
    }

    @media (width < 35rem) {
      display: none;
    }

    @media (width >= 35rem) {
      margin-inline-end: calc(-1 * var(--panel-width));
    }
  }

  :is(#header, #panel) {
    @media print {
      display: none;
    }
  }

  @media print {
    body.app {
      padding-block-start: 0;
    }
  }

  /* Below the push width the end panel becomes an overlay below the header —
     Basecamp's slideover — instead of a grid column: its own width down to the
     shell breakpoint, the full width under it.

     block-size has to be unsaid, not just re-inset: the base rule above pins
     100dvh, and a definite height beats the insets, so the overlay ran from the
     header line to 100dvh BELOW it — on a 390×844 screen that is y=52…896, with
     the last 100px behind the fixed footer and past the bottom edge, and no
     internal scroll to reach them because the box was exactly as tall as its
     content. Letting the insets size it is the fix; ending it at the footer
     rather than at 0 is what keeps the bar clickable while the panel is up. */
  @media (width < 64rem) {
    body.app:has(#panel-toggle:checked) #panel {
      display: block;
      position: fixed;
      /* The END edge only: `inset: 0` pins both inline edges, and with a set
         inline-size the over-constrained box resolves to the START edge in
         LTR — the end panel appeared on the left (user, 2026-08-24). */
      inset-block-start: var(--header-height);
      inset-block-end: var(--footer-height);
      inset-inline-end: 0;
      block-size: auto;
      max-block-size: none;
      inline-size: var(--panel-width);
      margin: 0;
      padding-block-start: var(--block-space);
      /* The surface tint is translucent ink; floating over the page it
         needs the canvas under it, the way a float does. */
      background: linear-gradient(var(--color-surface) 0 0) var(--color-canvas);
      box-shadow: var(--shadow);
      z-index: var(--z-chrome);

      @media (width < 35rem) {
        inline-size: 100vw;
        box-shadow: none;
        /* Full-screen there is no page to float over, so the float's tint
           says nothing — and it showed: the panel's room read a shade off the
           page's, and when iOS pans for the keyboard the strip it reveals
           below the document is body's canvas, which met the tint as a band
           of the wrong dark under the composer (user, 2026-08-30). On canvas
           the panel matches both. */
        background: var(--color-canvas);
      }
    }
  }
}
