/* sheet.css -- The sheet stack: .app-page, sheet layers, sticky header, backdrop
   Part of the site stylesheet, split out of the former single
   site.css. Load order is significant and is fixed in
   src/view/layout.php -- theme first (tokens), then base, then
   layout, then components. Rules were moved verbatim, in their
   original order, so the cascade is unchanged. */

.app-page {
    /* Single source of truth for the whole header-to-sheet gap system
       below (.app-page's own top padding, .sheet's min-height, and
       .sheet-backdrop-wrap's peek height/offset all derive from this
       ONE value) -- keeps them proportionally in sync by construction,
       the same guarantee --spacing-20 alone used to provide, just at a
       tighter overall budget. 0.7 * --spacing-20 (was the full token,
       read as too much air above the sheet) -- a moderate trim, not a
       drastic one; revisit the factor directly here if it still needs
       adjusting rather than touching the three places that read it. */
    --sheet-top-gap: calc(var(--spacing-20) * 0.7);
    /* Shared, ABSOLUTE (not %) rise distance for .sheet-shadow-wrap AND
       .sheet-content during a "new sheet rises from below" entrance --
       matches prepareSheetForTransition's own clamp height exactly
       (sheet-navigation.js), so both elements travel the identical
       pixel distance regardless of their own (very different, 32px vs
       ~1 viewport) heights. translateY(100%) -- % of EACH element's
       OWN height -- was the actual bug: the tiny cap and the tall
       clamped content travelled wildly different distances despite
       sharing one transition, visibly detaching mid-rise ("von unten
       kommt sheet aber ohne oberen Teil, oben ein Schnipsel"). A single
       shared distance keeps them rigidly glued, same principle as the
       stack-morph case's own absolute dy/sx (never had this bug, since
       it was never expressed as a %). */
    --sheet-rise-distance: calc(100dvh - var(--header-height) - var(--sheet-top-gap));
    /* .sheet-wrapper's own width -- .sheet-sticky-header (the sticky
       "chrome" layer, see site.css) matches it indirectly instead,
       via width: 100% (its containing block IS .sheet-wrapper) plus a
       deliberate symmetric overhang, rather than reading this variable
       directly -- see its own comment for why. */
    --sheet-width: min(clamp(var(--container-lg), 65vw, 1600px), 100%);
    /* Shared between .sheet-sticky-header's own top (where it locks
       once stuck) and .sheet-sticky-header-cap's height (the small
       permanent block covering the true viewport top down to that
       same point, see its own comment) -- keeps them in sync by
       construction. */
    --sheet-sticky-header-top: 20px;
    /* Height of a sheet's rounded "cap" (.sheet-shadow-wrap/.sheet) --
       the short piece that carries the top corners and shadow, with
       .sheet-content continuing directly beneath it. Named because the
       stack morph needs it as a transform-origin offset (see
       .sheet-content.stack-morph): cap and content are two elements
       making up ONE sheet, so they have to scale around the SAME point
       in page space or they come apart mid-animation. */
    --sheet-cap-height: 32px;
    /* .sheet-backdrop's own height inside .sheet-sticky-header -- a
       short, dedicated peek band directly below the container's own
       top, with .sheet (the real sheet, also inside the same sticky
       container) starting right after it. --spacing-10 (doubled), then
       2 * --spacing-3, then 2 * --spacing-1, then a single --spacing-2
       each went through a round of "etwas mehr/kann weg" feedback --
       single --spacing-3 (not doubled) is where it landed. */
    --sheet-backdrop-peek-height: calc(var(--spacing-3) + 1.4em);
    /* Positioned: assets/js/sheet-navigation.js briefly overlays the
       incoming/outgoing page as a position:absolute child anchored to
       this element while the two are stacked mid-transition. */
    position: relative;
    max-width: none;
    flex-direction: row;
    justify-content: center;
    /* flex-end, not flex-start -- a sheet anchors to the bottom edge
       (like an actual bottom-sheet/drawer panel), growing upward with
       its content rather than floating from the top of the canvas. */
    align-items: flex-end;
    /* No bottom padding -- the sheet's own bottom edge sits flush
       against the viewport's bottom edge (see .sheet's border-radius
       below), not floating with a gap beneath it. */
    padding: var(--sheet-top-gap) var(--spacing-5) 0;
    /* No background of its own -- body.app-layout carries the muted
       wash now (see above), so it covers the full viewport rather than
       just this element's own (min-height-dependent) box. */
}

/* While a sheet-navigation.js transition is in flight, both the
   outgoing and incoming page briefly coexist (one of them
   position:absolute, see below) -- clip that transient overlap so it
   can never grow the page's own scrollable area, the same class of bug
   .sheet-shadow-wrap's overflow-y: clip already guards against for the
   shadow SVG specifically. Removed again the instant the transition
   settles, so normal scrolling is completely unaffected outside of it. */
.app-page.sheet-transitioning {
    /* overflow: CLIP, not hidden -- `hidden` makes this element a
       scroll container, and position: sticky always resolves against
       its nearest SCROLLPORT, so .sheet-sticky-header (sticky, a
       descendant) silently re-anchored from the viewport to this box
       the instant a transition started, and back again when it ended:
       a hard 20px jump at both ends of every transition (measured
       live -- header top 163 -> 183 -> 163, exactly
       --sheet-sticky-header-top; "du kannst auch erkennen wie der
       sheet-header (roter Rahmen) springt"). `clip` clips identically
       but is NOT a scroll container, so sticky keeps resolving against
       the viewport and nothing moves (verified live: 163 stays 163). */
    overflow: clip;
}

/* .sheet-wrapper carries the width cap/centering that used to live on
   .sheet itself -- .sheet no longer can, now that .sheet-backdrop-wrap
   is positioned relative to .sheet-wrapper (a sibling, see below)
   instead of being .sheet's own direct sibling. */
.sheet-wrapper {
    position: relative;
    width: var(--sheet-width);
}

/* ONE sticky container holding the REAL .sheet-backdrop-wrap and
   .sheet-shadow-wrap/.sheet as children -- "du musst das alles in
   einen Container backen und den sticky machen logischerweise". Two
   earlier attempts made .sheet and .sheet-backdrop EACH independently
   sticky in their own separate DOM branches, each needing its own
   duplicated margin-cancel + shadow-SVG-tracking fix -- twice as much
   to keep in sync, and it broke twice (duplicate kicker text, then an
   asymmetric broken shadow). With everything nested INSIDE one sticky
   ancestor instead, the children just need position: absolute (relative
   to whichever of THIS element's own descendants they sit in) --
   absolute positioning naturally follows its nearest positioned
   ancestor's CURRENT rendered position, sticky offset included, with
   no extra tracking needed anywhere. This element's own containing
   block is .sheet-wrapper, whose height is driven by the real (often
   much taller) .sheet-content -- genuine slack for a real stuck phase
   ("hält nicht an sondern scrollt durch" is the failure mode this
   avoids). */
/* PURELY decorative -- "sheet-sticky-header ist ein dekoratives Element,
   um beim Scrollen den Content zu verdecken". Everything real (the
   actual clickable "Back to X" link, the actual sheet, the actual
   content) lives OUTSIDE this element entirely now, as plain normal-
   flow siblings in .sheet-wrapper (see .sheet-backdrop-wrap/.sheet-
   shadow-wrap/.sheet-content below) -- always fully visible/functional
   on their own, completely independent of this element's existence.
   This container (and its two children, .sheet-sticky-header-backdrop/
   .sheet-sticky-header-sheet, further down) is an aria-hidden, entirely
   non-interactive VISUAL DUPLICATE that happens to be position: sticky,
   so it can appear to "hold" the look of the real elements at the top
   while they scroll normally underneath. Hiding this element (display:
   none, or just deleting it) must have ZERO visible effect on the page
   at rest -- that invariant is the whole point, and is how a real
   regression was caught here: an earlier "ein Container" rework baked
   the REAL backdrop link and REAL .sheet INTO this element as its
   children, which briefly made this true (functionally + visually
   merged, no separate decorative copies needed) but broke the
   invariant above -- hiding this element also hid the real, clickable
   backdrop. Reverted back to two genuinely independent systems. */
.sheet-sticky-header {
    pointer-events: none;
    /* --sheet-backdrop-peek-height's 1.4em term is read from TWO
       places below (.sheet-sticky-header-backdrop's own height and
       .sheet-sticky-header-sheet's :has() top offset) -- em resolves
       per USAGE SITE's font-size, not per definition site, so both
       need to agree on font-size or the same var() silently resolves
       to two different pixel values (confirmed live once already, a
       real 5.6px gap on the REAL elements' own equivalent of this
       exact bug -- see .sheet-backdrop-wrap's own comment). Setting it
       once here, inherited by both children, guarantees they agree. */
    font-size: var(--text-size-small);
    position: sticky;
    /* --sheet-sticky-header-top MINUS 32px, not that value directly --
       room for whichever decorative child renders topmost (backdrop
       copy if a backdrop exists, otherwise the sheet copy)'s own
       box-shadow to bleed above ITS OWN top edge without being clipped
       by the true viewport edge once genuinely stuck (confirmed live:
       without this extra room, that bleed showed cut off flush against
       the gray header's own top edge). The true viewport gap this
       leaves (0 to --sheet-sticky-header-top) is covered by
       .sheet-sticky-header-cap (below), which shares that same
       UNadjusted value -- the cap's job is unrelated to this element's
       own internal 32px offset. */
    top: calc(var(--sheet-sticky-header-top) - 32px);
    /* This element is the FIRST child of .sheet-wrapper (see
       layout.php), so its own NATURAL (pre-sticky) top would otherwise
       be 0 -- margin-top pulls that up by the same 32px the topmost
       decorative child's own top offset adds back (see that child's
       own top: 32px), so at scroll 0 (unstuck) the decorative copy
       lands EXACTLY on top of (indistinguishable from) the real
       elements right after it in the DOM, which also start at 0.
       height/margin-bottom reach all the way down to --header-height --
       this element ITSELF is never a stack-morph target (only its
       CHILD, .sheet-sticky-header-sheet, is -- see that rule's own
       comment on why IT specifically has to stay a flat 32px, matching
       the real .sheet it copies), so growing just the CONTAINER this
       far doesn't reopen the animation-desync problem that came from
       stretching the wrong (animated) element. This is what "sorge
       dafür dass das gesamte Sheet über den Header rutscht" actually
       derives from -- an earlier attempt solved the same goal with a
       SEPARATE, permanently position: fixed "cap-bottom" element
       instead, reasoning it had to be decoupled from whatever
       .sheet-sticky-header-sheet's own size was doing -- that turned
       out unnecessary (this element was never entangled in the first
       place) and just read as a disconnected, unexplained white stripe
       ("ergibt wenig Sinn... ein weißer Streifen mitten durch das Bild
       ohne Sinn") -- removed again in favor of this simpler, one-
       element fix. margin-bottom cancels margin-top + height back to 0
       net footprint, so the real .sheet-backdrop-wrap/.sheet-shadow-
       wrap right after this in the DOM start exactly where they would
       if this element weren't here at all -- the whole point of
       "hiding this changes nothing" still holds, verified algebraically
       (margin-top + height + margin-bottom always sums to exactly 0
       for any --header-height/--sheet-sticky-header-top pair). */
    margin-top: -32px;
    height: calc(var(--header-height) - var(--sheet-sticky-header-top) + 32px);
    margin-bottom: calc(var(--sheet-sticky-header-top) - var(--header-height));
    /* Wider than the decorative children, so this element's own
       background (above) can back the sheet copy's --shadow-md
       box-shadow bleed on the sides -- .sheet-sticky-header-sheet is
       width: 100% of THIS element, so without SOME overhang, this
       element's own background stopped at exactly the same left/right
       edge that bleed passes, leaving it backed by nothing (confirmed
       live: "verdeckt links und rechts den Drop-Shadow nicht, den
       sieht man durchscrollen"). 12px per side -- "deckungsgleich...
       über dem Schatten aber nicht endlos weit": --shadow-md's actual
       three layers are "0 0 1px 0" (~1px extent), "0 2px 4px -1px"
       (blur 4, spread -1, ~3px extent), "0 8px 16px -4px" (blur 16,
       spread -4, ~12px extent -- for a negative-spread shadow, the
       visible bleed past the box's own edge is roughly blur minus
       |spread|, since spread shrinks the shape the blur then radiates
       out from) -- the biggest layer's ~12px is the binding one, all
       three already fit within it. Not 32px (corner-shape.js's
       unrelated SHADOW_SVG_MARGIN, sized for a bigger JS-driven SVG
       blur, not this plain CSS box-shadow) or even 16px (an earlier,
       still-too-generous rounding of the same blur radius alone,
       ignoring the spread that shrinks it back down) -- 12px is the
       real, reasoned match for what --shadow-md actually paints, not
       a guessed-safe margin beyond it. Negative margin, not just extra
       width, so the overhang is centered (grows equally on both
       sides) rather than only pushing the right edge out. */
    width: calc(100% + 24px);
    margin-left: -12px;
    margin-right: -12px;
    /* Above .site-header's own z-index (100) -- with top: 20px this
       element can reach up near/into the real header's own space, and
       staying below 100 let the real header's own backdrop-filter:
       blur(2px) paint over (and blur) this element wherever they
       overlapped, confirmed live. Above 100, this element's own opaque
       background wins instead. */
    z-index: 101;
    /* Matches body.app-layout's own muted canvas -- backs whatever the
       decorative children's own rounded top corners (border-radius,
       which only rounds THEIR box's own paint, doesn't cut a hole
       through to fully transparent) leave unpainted, with a constant,
       correct color instead of letting the real page scrolling
       underneath show through inconsistently. */
    background: var(--bg-neutral-section);
    /* .sheet-sticky-header-sheet is now a FULL-HEIGHT copy of the real
       .sheet + .sheet-content (see that rule's own comment) -- without
       this, it would render its entire real height right here, not
       just a short "peek" band. This element's own box (top: -32px
       margin buffer, 12px side overhang, height reaching
       --header-height) was already sized generously enough to contain
       the decorative children's shadow bleed on those three sides (see
       their own clip-path comments), so clipping flush at THIS
       element's own edges doesn't cut into that bleed either -- the
       buffers and the clip boundary were designed together. */
    overflow: clip;
}

/* Debug aid, toggled by adding class="debug" to .app-page (or any
   ancestor -- layout.php currently has it on directly, left on
   deliberately so the actual box bounds are visible without needing
   devtools). outline, not border -- doesn't affect box-sizing/layout,
   just draws on top. Add more `.debug <selector> { outline: ... }`
   rules here as needed for other elements later; not meant to be a
   single hardcoded target forever. */
.debug .sheet-sticky-header {
    outline: 2px solid red;
}

/* Slows the stack-navigation transition down 10x (0.3s/0.1s -> 3s/1s)
   for visual verification -- sheet-navigation.js reads
   --sheet-transition-duration/--sheet-stagger-delay back via
   getComputedStyle for its own setTimeout cleanup timing, so JS and
   CSS stay in lockstep automatically, no separate JS-side debug flag
   needed. Toggle by adding/removing this class (layout.php currently
   has it on, alongside .debug, per the user's own request while the
   dual-layer sync animation is being verified). */
.debug-animation {
    --sheet-transition-duration: 3s;
    --sheet-stagger-delay: 1s;
}

/* (A .sheet-sticky-header-pending rule used to live here, hiding the
   whole decorative header for the duration of a transition. Removed:
   its children now run the IDENTICAL animation as the real elements
   they copy -- see sheetTargetsFor/backdropTargetsFor in
   sheet-navigation.js -- so there is nothing left to hide, and hiding
   it was itself the reason the mask was missing during the animation
   and then cut in hard at the end.) */

/* The two purely decorative children -- see .sheet-sticky-header's own
   comment for the invariant these exist to satisfy. Visual copies of
   .sheet-backdrop-wrap/.sheet-backdrop and .sheet-shadow-wrap/.sheet
   respectively, sized via the SAME shared custom properties
   (--sheet-backdrop-peek-height, the flat 32px sheet-cap height) so
   they always match pixel-for-pixel, but styled with plain CSS
   border-radius/box-shadow instead of corner-shape.js's real squircle
   treatment -- no data-corner-js, no JS involvement at
   all. Two reasons: (1) matches the simpler original decorative-copy
   design (plain CSS was always "good enough" for a duplicate that's
   only ever seen while genuinely stuck, not interacted with), and (2)
   plain CSS box-shadow naturally follows wherever THIS element renders
   (sticky or not) with no separate SVG-position-tracking logic needed
   -- entirely sidesteps the class of bug corner-shape.js's shadow SVG
   had when a target became sticky directly (see modules.js/corner-
   shape.js history), since there's no SVG here to track in the first
   place. */
.sheet-sticky-header-backdrop {
    position: absolute;
    top: 32px;
    /* margin-auto centering, NOT left: 50% + translateX(-50%) -- this
       element is a stack-morph target now (sheet-navigation.js gives the
       outgoing page's backdrop the same transform as its sheet), and an
       inline transform REPLACES the whole property, so a transform used
       for centering gets wiped and the element flies off sideways. Same
       bug, same fix, as .sheet-sticky-header-sheet already documents. */
    left: 0;
    right: 0;
    margin: 0 auto;
    width: min(1120px, calc(var(--sheet-width) - var(--spacing-10) * 2));
    /* Full sheet height, clipped to the peek band below -- mirrors the
       real .sheet-backdrop exactly (see its own comment on why the
       backdrop has to be a full sheet, not a strip). */
    height: calc(100dvh - var(--header-height) - var(--sheet-top-gap) + 32px);
    display: flex;
    align-items: flex-start;
    padding-top: calc((var(--sheet-backdrop-peek-height) - 1.4em) / 2);
    justify-content: center;
    gap: var(--spacing-3);
    box-sizing: border-box;
    background: var(--bg-raised);
    color: var(--fg-neutral-muted);
    font-weight: var(--weight-medium);
    border-radius: 12px 12px 0 0;
    box-shadow: var(--shadow-md);
    /* Deliberately NO clip-path of its own. A previous version cut this
       down to the peek band with inset(... calc(100% - peek) ...), which
       defeated the whole point of giving it full height: what you SAW was
       always a 29px fragment, and because a clip-path travels with the
       element, it stayed a fragment while it animated -- "ständig sieht
       man es in abgeschnittener Form". In a bottom sheet stack a deeper
       layer is hidden by OCCLUSION, not by self-clipping: the layer in
       front is wider (968 vs 888) and opaque and paints later (DOM
       order, see layout.php), so all that's left showing is the strip
       above its top edge. Verified live: removing the clip changes
       NOTHING at rest, and hit-testing stays correctly limited too,
       because occlusion governs that as well. */
}

.sheet-sticky-header-sheet {
    position: absolute;
    /* Default (no backdrop copy present): the container's own natural
       top already sits 32px ABOVE .sheet-wrapper's top (see
       .sheet-sticky-header's own margin-top: -32px), specifically to
       leave room for whichever decorative child renders first to bleed
       its own shadow upward -- so THIS element's top: 32px here cancels
       that back out, landing it at effectively 0 (.sheet-wrapper's own
       top), exactly matching where the REAL .sheet-shadow-wrap (the
       first real element in that case) also naturally sits. */
    top: 32px;
    /* left: 0; right: 0; margin: 0 auto -- NOT left: 50%; transform:
       translateX(-50%) -- this element's own transform gets REPLACED
       (not merged) by sheet-navigation.js's stack-morph transform
       during a transition (el.style.transform = '...' overwrites
       the whole value, same lesson .sheet-shadow-wrap's own width
       comment already documents) -- confirmed live: with the old
       centering, applying a morph transform threw this element clean
       off to one side (left jumped from ~147 to 631) instead of
       narrowing in place like its real counterpart. margin-based
       centering leaves the transform property entirely free for JS to
       own during a transition. */
    left: 0;
    right: 0;
    margin: 0 auto;
    /* var(--sheet-width), NOT 100% -- this element's containing block
       is .sheet-sticky-header, which is now deliberately 64px WIDER
       than .sheet-wrapper (see that element's own width comment) so
       ITS background can back this element's own box-shadow bleed.
       100% would resolve against that wider box instead, growing this
       element right along with the container and defeating the whole
       point (confirmed live: rendered flush with the container's own
       widened edges, not .sheet-wrapper's). */
    width: var(--sheet-width);
    /* "diese Kopie hat genau die selbe Höhe wie das Original, wird nur
       eben geclipped" -- this element is a FULL-HEIGHT copy of the
       real .sheet + .sheet-content combined, not the short 32px box
       tried earlier (that broke the transform-sync -- two DIFFERENT
       sizes receiving the SAME stack-morph transform don't move
       identically, only two equal ones do). .sheet-sticky-header's
       own overflow: clip (see that rule) is what turns this tall box
       into a short "peek" window -- not a smaller height set here.
       A JS-measured clone of .sheet-content's own (highly variable,
       potentially thousands of px) real height was tried first and
       explicitly rejected ("es ist der falsche Ansatz") -- the real
       height that actually matters for the sync is the CLAMPED one:
       .sheet-content is never animated at its full natural height,
       only at prepareSheetForTransition's own clamp (sheet-
       navigation.js), which is this SAME fixed formula. Plain CSS,
       "sheet dürfte keine dynamische Höhe haben, es ist 100dvh hoch
       abzgl. Header" -- no JS, no ResizeObserver, no measurement. */
    height: calc(100dvh - var(--header-height) - var(--sheet-top-gap) + 32px);
    background: var(--bg-raised);
    border-radius: 12px 12px 0 0;
    box-shadow: var(--shadow-md);
    box-sizing: border-box;
    clip-path: inset(-9999px -9999px 0 -9999px);
}

/* Mirrors the REAL .sheet-shadow-wrap's own :has() split exactly (see
   its comment) -- with a backdrop copy present, the sheet copy has to
   start right after IT instead, or it would cover the backdrop copy's
   own dedicated peek band. Height is JS-driven either way (see the
   base rule's own comment) and unaffected by this split -- only the
   top offset differs. */
.sheet-sticky-header:has(.sheet-sticky-header-backdrop) .sheet-sticky-header-sheet {
    top: calc(32px + var(--sheet-backdrop-peek-height));
}

/* Once .sheet-sticky-header locks (position: sticky, top:
   --sheet-sticky-header-top), there's still a sliver between the true
   viewport top and that lock point it doesn't itself cover ("von
   viewport top 0 bis zu diesem top-Wert muss ein weiterer Block...
   der das Scrollen verdeckt"). This is a permanent, unchanging band --
   nothing to track -- so plain fixed positioning is enough, unlike
   .sheet-sticky-header itself. Full viewport width (not
   .sheet-sticky-header's own, narrower width) since it has to mask
   whatever of the real .site-header would otherwise still show there. */
.sheet-sticky-header-cap {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: var(--sheet-sticky-header-top);
    background: var(--bg-neutral-section);
    z-index: 101;
}

/* Workspace/dashboard (no sheet, layout.php's no_sheet flag): a plain
   card grid on the muted canvas, so it should read like ordinary page
   content -- starting at the top, not anchored to the bottom the way
   an actual sheet is. */
.app-page.no-sheet {
    align-items: flex-start;
    padding-bottom: var(--spacing-20);
}

.app-page-content {
    width: 100%;
    max-width: var(--container-xl);
}

/* "Bottom sheet" navigation animation (assets/js/sheet-navigation.js) --
   applies to whichever of the two is the page's own content root.
   JS-only states: with the script absent (or a fetch failing) neither
   class is ever added, so nothing here ever applies and every
   navigation is a plain, instant full page load -- the required no-JS
   fallback, not a separate code path.

   The two participants in a transition are never animated the same
   way -- one moves independently along the screen edge (.sheet-edge),
   the other simply recedes/returns in place (.sheet-recede):
   - Forward (going deeper): the NEW page is inserted as a
     position:absolute overlay (.sheet-edge, starting at
     .sheet-edge-hidden -- fully below the viewport) and rises into
     place on top, while the OLD page stays in normal flow underneath
     and recedes (.sheet-recede -> .sheet-recede-back).
   - Back: the mirror -- the OLD (departing) page becomes the
     .sheet-edge overlay and slides down into .sheet-edge-hidden, while
     the NEW (returning) page -- already sitting in normal flow,
     starting from .sheet-recede-back -- simply rises back to full
     prominence underneath as that class is removed.
   Deliberately no scale() anywhere here: corner-shape.js's
   ResizeObserver can fire its guaranteed initial callback mid-transition,
   and getBoundingClientRect() reports transformed size -- a scale() at
   that moment would hand it a slightly wrong width/height to draw the
   clip-path/shadow/border for. translateY/opacity never affect the
   reported width or height, so they're transition-safe regardless of
   timing. */
.sheet-wrapper.sheet-edge,
.app-page-content.sheet-edge {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    /* left/right:0 alone over-constrains a box with max-width --
       browsers resolve that by dropping 'right' and pinning flush left
       instead of centering. auto margins absorb the leftover space on
       both sides instead, centering it the same way .sheet-wrapper is
       centered in normal flow (via .app-page's justify-content, which
       doesn't apply to an absolutely positioned element). */
    margin-left: auto;
    margin-right: auto;
    z-index: 2;
    transition: transform var(--sheet-transition-duration) var(--sheet-ease);
}

.sheet-wrapper.sheet-edge.sheet-edge-hidden,
.app-page-content.sheet-edge.sheet-edge-hidden {
    transform: translateY(100%);
}

/* Forward's entrance, decoupled from the destination's OWN real
   backdrop -- .sheet-edge above still positions the OUTER
   .sheet-wrapper (so oldRoot, staying in normal flow, isn't disturbed),
   but that outer wrapper itself gets NO translateY anymore. Only
   .sheet-shadow-wrap (the front sheet + its shadow, a CHILD of
   .sheet-wrapper, sibling of .sheet-backdrop-wrap) rises -- otherwise a
   transform on the whole wrapper drags its own backdrop along for the
   ride too, and a backdrop that's rigidly glued to the sheet in front
   of it doesn't read as a separate, independent element no matter how
   it's dimmed or staggered (confirmed: "der 'backdrop' ist gar kein
   eigenständiges Sheet... sondern dran geklebtes visuelles Element").
   With this, the backdrop just fades in at its own fixed position
   (.backdrop-appearing below) while the front sheet rises past it --
   two genuinely independent motions, not one rigid unit. */
/* .sheet-content gets the SAME rise treatment as .sheet-shadow-wrap now
   -- without it, only the small 32-58px cap actually rose from below;
   the real, much taller text panel (now a separate element since this
   session's .sheet/.sheet-content split) just sat there fully visible
   from frame 1, so "the new sheet rises up" barely read as true for
   the part of the sheet that's actually visible/meaningful. */
/* .sheet-sticky-header-sheet listed alongside the two real elements
   here (and in every other animation selector in this block) -- "es
   finden stets zwei Animationen deckungsgleich statt, im sheet-header
   und außerhalb -- einfach die gleiche Animation". The decorative copy
   is never hidden during a transition anymore (that's what made it pop
   in at the very end); it just runs the identical animation. */
.sheet-shadow-wrap.sheet-rise,
.sheet-content.sheet-rise,
.sheet-sticky-header-sheet.sheet-rise {
    transition: transform var(--sheet-transition-duration) var(--sheet-ease);
}

/* Uniform scale needs a fixed top edge, same as the morph -- and the
   two elements that make up one sheet need a SHARED origin point or
   they come apart. Identical reasoning and identical values to the
   .stack-morph origins further down; see those comments. */
.sheet-shadow-wrap.sheet-rise,
.sheet-sticky-header-sheet.sheet-rise {
    transform-origin: 50% 0;
}

.sheet-content.sheet-rise {
    transform-origin: 50% calc(-1 * var(--sheet-cap-height));
}

/* (The per-layer stagger used to live here as .stack-delayed compound
   selectors. It is set INLINE from sheet-navigation.js now -- there are
   three distinct delays (0 / 1x / 2x) rather than one, and since the
   base .sheet-rise/.stack-morph rules use the `transition` shorthand
   -- which resets transition-delay -- every delay variant would have
   needed its own compound selector for every element type just to win
   on specificity. Inline sidesteps that entirely.) */

/* Deliberately does NOT also require .sheet-rise -- that's what broke
   the entrance animation entirely. sheet-navigation.js used to add
   .sheet-rise (which carries the transition, above) and
   .sheet-rise-hidden (this offset start position) in the SAME style
   change, so the browser TRANSITIONED TOWARD the offset over the full
   delay+duration instead of jumping there; two frames later the class
   came off again, so it animated back from ~0 to 0 -- literally no
   visible movement, the new sheet just appeared in place (confirmed
   frame-by-frame in a screen recording: zero motion across the whole
   transition). With the transform available WITHOUT the transition
   class, JS can set the start state instantly, then enable the
   transition one frame later and flip -- see runTransition's own
   three-phase comment. */
.sheet-shadow-wrap.sheet-rise-hidden,
.sheet-content.sheet-rise-hidden,
.sheet-sticky-header-sheet.sheet-rise-hidden {
    /* var(--sheet-rise-distance), NOT 100% -- see that variable's own
       comment (.app-page): a %, here, resolves against EACH element's
       OWN height, and .sheet-shadow-wrap (32px) and .sheet-content
       (~1 viewport, clamped) are wildly different sizes -- one shared,
       absolute distance is what actually keeps them moving together.
       The scale factor comes from JS (--sheet-rise-scale, set per
       transition from measured widths) and is > 1: the incoming sheet
       starts at depth -1, i.e. CLOSER than its resting depth 0, so it
       starts LARGER and settles down to 1. That direction matters --
       every layer in the stack recedes by exactly one level during a
       transition, so all three must scale DOWN. An earlier plan had
       this one start small and grow, which ran the opposite way through
       depth from the other two and read as asymmetric. */
    transform: translateY(var(--sheet-rise-distance)) scale(var(--sheet-rise-scale, 1));
}

.sheet-wrapper.sheet-recede,
.app-page-content.sheet-recede {
    transition: transform var(--sheet-transition-duration) var(--sheet-ease), opacity var(--sheet-transition-duration) var(--sheet-ease);
}

.sheet-wrapper.sheet-recede.sheet-recede-back,
.app-page-content.sheet-recede.sheet-recede-back {
    /* Negative, not +8px -- a receding sheet is meant to read as
       "becoming the backdrop", and the backdrop sits ABOVE the new
       sheet (see .sheet-backdrop-wrap's negative top offset), not
       below it. The old +8px nudged the receding sheet DOWNWARD, away
       from where it was supposedly heading -- spatially backwards for
       forward navigation (though harmless for back navigation's use of
       this same state, where the direction reads fine either way). */
    transform: translateY(-16px);
    opacity: 0.35;
}

/* The depth step of the BOTTOM SHEET STACK -- the "automatic scaling and
   vertical offset to show depth" half of the pattern (full definition at
   the top of assets/js/sheet-navigation.js, which computes the per-layer
   values). Used INSTEAD OF .sheet-recede/.sheet-recede-back above
   whenever a real backdrop panel exists on the other side of the
   transition to key off of. Forward: the OLD sheet narrows and shifts up
   toward where the real backdrop sits, and the old backdrop steps back
   one further level of its own.
   Back: the mirror -- newRoot starts already sized/positioned like the
   backdrop it currently plays behind oldRoot, then widens and settles
   down into its own natural resting box. transform: scale()+
   translateY(), NOT a real width transition -- width was tried first
   and read as janky ("holprig"), because it forces a genuine layout
   pass on every animation frame, and corner-shape.js's ResizeObserver
   correctly reacts to every one of those, re-running its full
   clip-path/shadow/border recomputation (SVG creation, ~80-point trig
   math) 18 times over a 300ms transition. transform is compositor-only
   -- no layout, no JS per frame, genuinely smooth. See
   sheet-navigation.js for why using scale() here (instead of width)
   requires briefly disconnecting corner-shape.js's ResizeObserver on
   whichever element persists past the transition.

   transform-origin: 50% 0 (top centre), and the scale is UNIFORM on
   both axes -- both deliberate, both corrected after studying
   Framework7's sheet-push effect frame by frame (measured there:
   ~0.9 on BOTH axes plus a small offset). The earlier version scaled X
   only, scale(sx, 1), which narrows a sheet without making it smaller
   -- that reads as squeezing, never as receding into depth, and was
   the reason the stack never looked like a stack. With a uniform scale
   the top edge would drift under a centre origin, so the origin moves
   to the top: the box shrinks inward/downward from its own top edge and
   translateY alone then places it at its depth. Horizontal centre stays
   put by itself, since every layer is centred on .app-page's own axis. */
/* All THREE morph targets must be listed here, exactly matching
   sheetTargetsFor() in sheet-navigation.js -- .sheet-content was
   missing, which meant the big, actually-visible panel got the
   stack-morph class AND the inline end transform from JS but no
   `transition` property at all, so it JUMPED to the end state in one
   frame while the (full-height, correctly-listed) decorative copy
   animated properly. Visible symptom: "warum animiert dann nur das
   Sheet im sheet-header (morph) aber nicht außerhalb". Any element JS
   puts stack-morph on has to appear in this selector list, or it
   silently teleports instead of animating. */
.sheet-wrapper.stack-morph,
.sheet-shadow-wrap.stack-morph,
.sheet-content.stack-morph,
.sheet-sticky-header-sheet.stack-morph {
    transition: transform var(--sheet-transition-duration) var(--sheet-ease);
    transform-origin: 50% 0;
}

/* .sheet-content is the SECOND element of a sheet that is made of two
   (the cap above it plus this), and a uniform scale around each
   element's OWN top would pull them apart -- the cap would shrink to
   its own top while this shrank to its own, opening a visible seam
   between them mid-morph (~3px at scale .917). Shifting this element's
   origin UP by exactly the cap's height puts both origins on the SAME
   point in page space -- the cap's top edge -- so the two scale as one
   rigid sheet, which is what they visually are. This is what a group
   transform on a shared parent would do for free; the sheet has no such
   parent (a wrapper around both would drag the backdrop along too, see
   .sheet-shadow-wrap's own comment), so the origin does it explicitly. */
.sheet-content.stack-morph {
    transform-origin: 50% calc(-1 * var(--sheet-cap-height));
}

.sheet-backdrop-wrap.stack-morph,
.sheet-sticky-header-backdrop.stack-morph {
    transform-origin: 50% 0;
}

/* The backdrop pair morphs AND fades at the same time (the outgoing
   stack's rearmost layer recedes and disappears), so it needs BOTH
   properties in one transition -- must come after the rule above,
   since a bare transform-only shorthand at equal specificity would
   otherwise win by source order and silently drop the opacity
   transition these two carry by default, turning the fade into a jump.
   Opacity timing is kept identical to .sheet-backdrop-wrap's own
   resting transition so a morphing backdrop and a merely-fading one
   stay in step. */
.sheet-backdrop-wrap.stack-morph,
.sheet-sticky-header-backdrop.stack-morph {
    transition: transform var(--sheet-transition-duration) var(--sheet-ease),
                opacity calc(var(--sheet-transition-duration) * 0.667) var(--sheet-ease) calc(var(--sheet-stagger-delay) * 1.5);
}

/* (The morph's stagger lived here as .stack-delayed too, and is now set
   inline per layer from sheet-navigation.js for the same reason -- see
   the note above the .sheet-rise origins.) */

/* Plain wrapper, no layout role of its own -- exists only so the
   corner-shape fallback (assets/js/corner-shape.js) has an ancestor to
   put filter: drop-shadow on. clip-path (the fallback's own squircle
   technique) clips away box-shadow on the SAME element it's applied
   to, and clips away filter:drop-shadow on that same element too --
   the shadow has to live one level up instead (confirmed against
   https://css-tricks.com/using-box-shadows-and-clip-path-together/,
   the established fix for this exact interaction). Native
   corner-shape browsers never touch this wrapper at all.
   REAL, normal-flow sibling of .sheet-backdrop-wrap/.sheet-content in
   .sheet-wrapper -- NOT a child of .sheet-sticky-header (see that
   element's own comment on why: it's purely decorative now, this is
   the genuine, always-visible thing it visually duplicates while
   stuck). width: 100%, not var(--sheet-width) explicitly -- its
   containing block (.sheet-wrapper) already IS exactly that width, so
   they're equivalent; kept as 100% since it's simpler and there's no
   wider ancestor to guard against anymore. No left/transform centering
   needed either -- normal block flow already spans the parent's full
   width. This also fixes a latent bug: the old left: 50%; transform:
   translateX(-50%) centering would have been silently clobbered by
   sheet-navigation.js's own transform-based rise/morph animations
   (plain inline style assignment replaces the whole transform value,
   it doesn't merge with an existing one) -- width: 100% sidesteps the
   conflict entirely by not needing a transform for centering at all. */
.sheet-shadow-wrap {
    position: relative;
    width: 100%;
    height: 32px;
    /* .sheet's shadow SVG (assets/js/corner-shape.js) bleeds a little
       past .sheet's own edges for the blur to render, and since that
       SVG is position: absolute (relative to THIS element), an
       ancestor's scrollable area could silently grow to fit it unless
       clipped. clip-path: inset(top right bottom left), not overflow:
       clip -- overflow clips all four edges uniformly, which would
       also cut off the top/side bleed that's supposed to stay visible.
       Negative insets on top/right/left let those sides bleed freely
       (effectively unclipped -- 9999px is just "never"), 0 on the
       bottom clips flush at .sheet's own bottom edge, the one
       direction that would otherwise grow the page's scrollable area. */
    clip-path: inset(-9999px -9999px 0 -9999px);
}

/* REAL, always-visible element (see .sheet-shadow-wrap's own comment --
   this whole thing is a normal-flow sibling of .sheet-backdrop-wrap/
   .sheet-content now, NOT living inside .sheet-sticky-header). A SHORT
   box (height: 100% of .sheet-shadow-wrap, a fixed 32px) rather than
   wrapping the real content directly -- its one job is to provide the
   rounded-top/shadow "cap" look right above .sheet-content (a separate
   element, own background/shadow, flush top -- see its own comment)
   which is what actually holds and grows with the real text. Splitting
   the rounded cap from the content this way keeps corner-shape.js's
   real shadow-SVG treatment (data-corner-js, layout.php)
   cheap regardless of how tall the real content grows -- it only ever
   measures this tiny, fixed box (the earlier "grau 2-3sec" problem). */
.sheet {
    position: relative;
    width: 100%;
    height: 100%;
    background: var(--bg-raised);
    /* 12px matches macOS Tahoe's own real window corner radius (up
       from Sequoia's 4px, confirmed via lapcatsoftware.com/
       mjtsai.com). */
    border-radius: 12px 12px 0 0;
    box-shadow: var(--shadow-md);
    box-sizing: border-box;
}

.sheet-content {
    position: relative;
    /* Carries its own background/shadow now -- .sheet (above, its own
       real normal-flow sibling) is only a short 32px cap, not a
       viewport-tall visual, so THIS is what actually provides the
       "big white card" look for the rest of the real content's height.
       No top border-radius (0 implied, not explicit) -- .sheet's own
       rounded top corners are directly above/flush with this element's
       own top edge, so this one only ever needs to look flush there,
       never rounded itself. */
    background: var(--bg-raised);
    box-shadow: var(--shadow-md);
    /* The "big card" has to look like a real dashboard panel even on a
       short-content page (this one included -- a couple of paragraphs)
       -- without a floor, .sheet-content would shrink to fit just that
       text, reading as a tiny stub rather than a sheet. Same
       --header-height + --sheet-top-gap pair .app-page's own top
       padding is built from (see its comment), so this stays in sync
       with the actual space consumed above .sheet-content by
       construction. Lost track of this during the .sheet-sticky-header
       "one container" rework -- restored. */
    min-height: calc(100dvh - var(--header-height) - var(--sheet-top-gap));
    /* Flush top, permissive elsewhere -- this element's own box-shadow
       bleeds on all sides by default; an unclipped top would bleed a
       visible shadow line up into .sheet's own short masking band
       right above it, the same "seam" .sheet-shadow-wrap's own
       flush-bottom clip-path already guards against for the opposite
       edge. */
    clip-path: inset(0 -9999px -9999px -9999px);
    /* Bottom padding matches .footer-inner's own (var(--spacing-8)),
       not the top's var(--spacing-10) -- the sheet's bottom edge sits
       flush with the viewport, so its internal bottom breathing room
       should read the same as the marketing footer's, not the sheet's
       own (larger) top padding. */
    padding: var(--spacing-10) var(--spacing-8) var(--spacing-8);
    box-sizing: border-box;
}

/* Used only for the brief window of a sheet-navigation.js transition
   (assets/js/sheet-navigation.js's prepareSheetForTransition) -- .sheet
   is temporarily bounded to one viewport's worth so the recede/morph
   animation never has to move/measure its full, potentially far taller
   real height. scrollbar hidden since this is never meant to be
   scrolled by the user directly, only set via JS to replicate whatever
   was already on screen. */
.sheet-transition-clamped {
    overflow: auto;
    scrollbar-width: none;
}

.sheet-transition-clamped::-webkit-scrollbar {
    display: none;
}

/* One receded layer of the BOTTOM SHEET STACK (see the definition at the
   top of assets/js/sheet-navigation.js -- that file owns the concept;
   this is just the layer's resting appearance). Matches Basecamp's own
   dashboard: a breadcrumb-style panel peeking out above/behind the main
   content sheet, like a sheet of paper lying underneath. Re-checked
   against the actual reference screenshot several times now:
   - The back panel is NARROWER than the front sheet, not wider -- the
     front sheet's greater width is exactly what hides the back
     panel's sides once the front one begins, leaving only a strip of
     it visible above the front sheet's own top edge.
   - No gap between the two -- the front sheet sits flush "over" the
     back panel's lower edge, touching directly (a gap made it look
     like a disconnected floating chip instead of one sheet layered
     over another).
   REAL, always-visible, normal-flow FIRST child of .sheet-wrapper (see
   .sheet-sticky-header's own comment for why: that element is purely
   decorative, this is the genuine thing it visually duplicates while
   stuck). No top/left/transform needed -- normal block flow already
   places it right at .sheet-wrapper's own top, and margin: 0 auto
   below centers it (narrower than .sheet-wrapper's own width, unlike
   .sheet-shadow-wrap right after it). */
.sheet-backdrop-wrap {
    position: relative;
    /* --sheet-backdrop-peek-height's 1.4em term resolves against THIS
       element's own font-size, not .sheet-backdrop's (the actual link
       text, --text-size-small/12px) -- without this, it silently
       inherits the ambient 16px body font-size instead, inflating the
       padding above/below the text (confirmed live via
       getBoundingClientRect: 26.4px instead of the intended 20.8px).
       .sheet-sticky-header sets this same fix for its own decorative
       backdrop-copy child -- the two used to share a single ancestor
       (back when the real backdrop-wrap lived inside the sticky
       header) and so shared one fix; now that they're independent
       trees again (see .sheet-sticky-header's own comment), each needs
       its own. */
    font-size: var(--text-size-small);
    /* Narrower than .sheet by a two-step formula, not just one flat
       subtraction -- 1120px caps how wide it can get on large
       viewports, and subtracting 2x --spacing-10 shrinks it further in
       on medium/narrow ones, so it reads as a deliberately smaller
       sheet lying behind rather than just ".sheet minus a sliver". */
    width: min(1120px, calc(var(--sheet-width) - var(--spacing-10) * 2));
    margin: 0 auto;
    /* Peek height is this element's FLOW footprint only -- it's what
       reserves the visible strip above .sheet-shadow-wrap without
       pushing it down by a whole sheet. The actual backdrop sheet is
       the absolutely-positioned .sheet-backdrop child, which is full
       height and overflows this box on purpose. */
    height: var(--sheet-backdrop-peek-height);
    /* Deliberately NO clip-path here either -- see .sheet-backdrop's own
       comment. Clipping this wrap flush at its own (peek-height) bottom
       is what cut the full-height child back down to a 29px fragment,
       both at rest and, worse, throughout the animation. The front
       sheet occludes it instead. Its box-shadow bleeding downward is
       moot for the same reason: the wider, opaque front sheet paints
       over that whole region afterwards. */
    /* NO opacity transition at rest -- deliberately. The only backdrop
       that ever FADES is an outgoing one, and it gets its transition
       from .stack-morph (transform + opacity together, further up).
       An incoming page's backdrop must appear as a HARD CUT at cleanup
       instead: it is hidden for the whole transition (it is not one of
       the three animating layers -- see sheet-navigation.js) and is
       swapped in exactly when the layer that stood in for it is
       removed. With a transition here, that swap would instead fade in
       over seconds AFTER the animation had already finished. */
}

/* sheet-navigation.js: kept invisible while a freshly-fetched sheet
   (which already includes its own real backdrop, rendered server-side)
   rises into place, so the backdrop doesn't just pop in already
   attached to it. Removed (class stripped) at the SAME moment the old
   sheet starts receding, but the 0.15s transition-delay above means the
   fade-in itself doesn't visually start until partway through that
   0.3s recede -- without the delay, both were fading in/out at once,
   which read as "the whole new sheet+backdrop package just appears
   together" rather than "the old sheet recedes, THEN this appears
   where it was heading" (matches .sheet-recede-back's negative
   translateY now moving the old sheet toward the backdrop's own
   position, not away from it). */
.sheet-backdrop-wrap.backdrop-appearing,
.sheet-sticky-header-backdrop.backdrop-appearing {
    opacity: 0;
}

/* (No resting opacity transition on the decorative copy either -- it
   mirrors its real counterpart exactly, and that one deliberately has
   none; see .sheet-backdrop-wrap's own comment. Fading only ever
   happens on an OUTGOING backdrop, which gets its transition from
   .stack-morph.) */

.sheet-backdrop {
    /* The REAL <a> (the page's own "back" link, see layout.php) --
       reset link defaults and lay out like a small toolbar row instead
       of an empty plate. position: absolute + inset: 0 fills its own
       .sheet-backdrop-wrap completely. Genuinely clickable at all
       times (no pointer-events override needed anymore -- this no
       longer lives inside .sheet-sticky-header's own pointer-events:
       none, see that element's comment). align-items: center (not the
       old flex-start + padding-top hack, tuned for a much taller box
       that no longer exists) -- .sheet-backdrop-wrap is just one text
       row tall (--sheet-backdrop-peek-height), so plain centering looks
       right without any extra offset math. */
    position: absolute;
    /* A FULL sheet, same height formula as .sheet-sticky-header-sheet
       and the real .sheet-content -- NOT a 29px strip. Its own parent
       (.sheet-backdrop-wrap, height: --sheet-backdrop-peek-height plus
       a flush-bottom clip-path) is what reduces it to the visible peek
       band. "Übrigens setzt das stets voraus, dass außerhalb von
       sheet-header auch ein volles Backdrop-Sheet existiert" -- the
       stack morph moves the outgoing sheet INTO this element's box, so
       the two have to be the same kind of object (equal-sized sheets,
       one clipped) for that to be an honest, size-preserving move
       rather than a full sheet collapsing into a strip.
       top/left/right instead of inset: 0 -- inset would tie the height
       to the (short) wrapper again, defeating exactly this. */
    top: 0;
    left: 0;
    right: 0;
    height: calc(100dvh - var(--header-height) - var(--sheet-top-gap) + 32px);
    display: flex;
    /* flex-start + derived padding, NOT align-items: center -- centering
       inside a now-full-height box would drop the label ~300px down,
       far outside the visible peek band. The padding keeps it optically
       centered within that band exactly as before, derived from the same
       --sheet-backdrop-peek-height so it tracks any change to it. */
    align-items: flex-start;
    padding-top: calc((var(--sheet-backdrop-peek-height) - 1.4em) / 2);
    justify-content: center;
    gap: var(--spacing-3);
    box-sizing: border-box;
    background: var(--bg-raised);
    color: var(--fg-neutral-muted);
    text-decoration: none;
    font-size: var(--text-size-small);
    font-weight: var(--weight-medium);
    /* Flat 12px, matching .sheet -- see its own comment on why not
       --rounded-xl*--brm. */
    border-radius: 12px 12px 0 0;
    /* Same --shadow-md as .sheet -- a direction-mirrored variant was
       tried here (shadow cast upward instead, reasoning that only the
       top strip is ever visible) but didn't read as intended, plain
       matching value looks right. */
    box-shadow: var(--shadow-md);
    transition: background 0.15s ease;
}

/* NOT a background change on hover -- .sheet-backdrop's resting
   background (--bg-raised) already equals body.app-layout's own canvas
   color one step further out (--bg-neutral-section) minus contrast, so
   hovering TO that canvas color made the whole panel visually blend in
   and disappear. Same problem .card sidesteps by never hover-tinting
   its own background either -- feedback lives on the arrow/text
   instead, mirroring .card-cta .arrow's translateX pattern (mirrored
   here since this arrow points left/back, not right/forward). */
.sheet-backdrop:hover {
    color: var(--fg-neutral-accent);
}

.sheet-backdrop .arrow {
    display: inline-block;
    transition: transform 0.15s ease;
}

.sheet-backdrop:hover .arrow {
    transform: translateX(-4px);
}
