/* ============================================================
   Denzl — app-shell.css
   ------------------------------------------------------------
   Shared shell for every signed-in surface (Digest, Discover,
   Dossier, Me, Passport, Sizes, Taste). Page-specific styles
   live inside each template's own <style>; this file owns:

     - Design tokens (CSS variables — fonts, colors, spacing)
     - Reset / body / typography defaults
     - The lockup, top nav, footer, and mobile tabbar
     - Common patterns reused across pages: .eye / .h2 / .sub /
       .reveal / .cta / .bn-stack / .bm / .bn

   Locked 2026-04-25. Typography: Plus Jakarta Sans + JetBrains
   Mono. See docs/decisions/2026-04-25-typography-plus-jakarta-pivot.md.
   Voice & posture: docs/brand/design-language.md.
   ============================================================ */

/* ----- DESIGN TOKENS ----- */

:root {
    /* Fonts */
    --sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
    --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;

    /* B&W chrome — the clothes do the color talking */
    --ink:     #1A1A1A;
    --body:    #333333;
    --graphite:#555555;
    --muted:   #888888;
    --silver:  #C0C0C0;
    --faint:   #767676;  /* DEN-137: was #AAAAAA (2.32:1, fails WCAG AA). #767676 = 4.54:1 — darkest grey that passes AA on white. Keeps faint footers/legal/strike legible; still monochrome. --muted #888888 (3.5:1) kept by design. */
    --border:  #E8E8E8;
    --divider: #F0F0F0;
    --surface: #FFFFFF;
    --elevated:#F5F5F5;
    --soft:    #F8F8F8;
    /* Dark surfaces — the section-drawer overlay inverts onto --ink. Neutral
       greys off the --ink base (still monochrome, no hue), tokenised so the
       dark chrome tunes centrally like the light surfaces. */
    --ink-tile:       #2A2A2A;
    --ink-tile-press: #333333;

    /* Functional state colors — small inline use only.
       NOT chrome accents. See design-language.md "state colors". */
    --state-go:   #1F8A4C;
    --state-stop: #A33;

    /* Image / silhouette background */
    --img-bg:  #F2F2F2;

    /* Spacing rhythm */
    --pad-page-x:   20px;
    --pad-page-x-d: 32px;
    --pad-section:  40px;  /* Mobile section gap — was 56px, tightened
                              2026-04-26 after Bharat flagged dense text
                              on mobile. Desktop stays generous. */
    --pad-section-d:88px;
    /* Breathing room between the sticky shell header (.app-top) and the
       content below it. Desktop runs more generous (see media override).
       Pages whose first child IS their own header (legal .legal-top) cancel
       this with a negative top margin so their header sits flush like the
       shell header, not pushed down by content padding. DEN-536. */
    --pad-main-top: 16px;

    /* Bottom safe-area inset — ONE definition for every bottom-pinned
       surface (DEN-1551). env() covers iOS and the installed PWA;
       --dz-inset-bottom is the Android edge-to-edge stand-in published
       natively by MainActivity, where env() reads 0. Both are 0 on desktop
       and on gesture-nav, so consuming this is harmless everywhere. Never
       hand-write the max() again: a bare `bottom:` / `padding-bottom:` on
       fixed or sticky chrome sits BEHIND a 3-button nav bar. See the
       safe-area block further down for the full rationale. */
    --dz-safe-bottom: max(env(safe-area-inset-bottom), var(--dz-inset-bottom, 0px));

    /* Top safe-area inset — the mirror of --dz-safe-bottom (DEN-1960). Same
       two sources: env() covers iOS and the installed PWA, --dz-inset-top is
       the Android edge-to-edge stand-in published natively by MainActivity
       (MainActivity.java) where env() reads 0. Both are 0 in a normal browser
       tab, so consuming this is harmless everywhere.

       Two rules, and the second is the one that bites:
       1. Top-pinned chrome (a fixed header, a notch-clearing masthead) pads
          itself by this token instead of hand-writing the max() again.
       2. Anything pinned BELOW `.app-top` does NOT add this token — the
          header already absorbed it, and its resulting height is measured and
          published as `--dz-top-h` by app_shell_metrics.js. Offset by
          `var(--dz-top-h, <literal>)`; a literal alone is right on web and
          wrong in the app. See mobile-shell.md Rule 9. */
    --dz-safe-top: max(env(safe-area-inset-top), var(--dz-inset-top, 0px));

    /* Side insets — the landscape notch and the gesture pill's side margins
       (DEN-1987). Same two sources again; `MainActivity` publishes all four
       of --dz-inset-top/right/bottom/left, so there is no reason for the
       horizontal pair to be the odd one out. Completing the set is what lets
       the guard say ONE thing about all four sides instead of three.

       `env(safe-area-inset-left/right)` are non-zero only in landscape on a
       notched phone, so a bare env() here is not merely wrong on Android — it
       is a rule almost nobody exercises, which is why these drifted longest. */
    --dz-safe-right: max(env(safe-area-inset-right), var(--dz-inset-right, 0px));
    --dz-safe-left: max(env(safe-area-inset-left), var(--dz-inset-left, 0px));

    /* ─────────────────────────────────────────────────────────────────
       Fluid type scale — 2026-04-26 round-10 (Bharat: "we need to work
       around with font sizing responsively"). One clamp() per role,
       smooth scaling between mobile-min and desktop-max. Replaces the
       3-tier @media-cascade we had for every headline.

       Anchor formula:  clamp(MIN, SLOPE_VW + BASE_PX, MAX)
       where SLOPE_VW + BASE_PX hits the MAX at ~1440px viewport.

       Roles:
         --t-display  page hero   (Digest cover-h, Dossier h1, etc.)
         --t-hero     section hero
         --t-h2       subhead in editorial blocks
         --t-h3       small subhead / brand-mark large
         --t-body     prose
         --t-sub      lede paragraph below a hero (slightly bigger)
         --t-eye      eyebrow / label / caption
         --t-stat     tabular numbers in stat cells

       Modular ratio ~1.25 (minor third) between adjacent steps.
       ───────────────────────────────────────────────────────────────── */
    --t-display: clamp(26px, 4.5vw + 10px, 84px);
    --t-hero:    clamp(22px, 3.5vw + 10px, 64px);
    --t-h2:      clamp(22px, 1vw + 15px, 32px);
    --t-h3:      clamp(16px, 1.2vw + 10px, 26px);
    --t-body:    clamp(14px, 0.4vw + 12px, 17px);
    --t-sub:     clamp(15px, 0.5vw + 13px, 19px);
    --t-eye:     clamp(11px, 0.15vw + 10px, 13px);
    --t-stat:    clamp(20px, 0.6vw + 15px, 26px);
}

/* ----- RESET + BODY ----- */

*, *::before, *::after { box-sizing: border-box; }
* { font-family: var(--sans); }

html, body {
    margin: 0;
    padding: 0;
    background: var(--surface);
    color: var(--body);
    -webkit-tap-highlight-color: transparent;
    overscroll-behavior: none;
}
body {
    font-family: var(--sans);
    font-feature-settings: "tnum" 1, "ss01" 1;
}

a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; padding: 0; margin: 0; }
button { font-family: inherit; }

/* ----- SCRIPTED FOCUS TARGETS — move the cursor, never paint a ring -----
   Several surfaces move focus to a heading, status line or panel after a step
   change so a screen reader announces the new content (read.html's `.r-q`
   question, product_detail's commerce shell, you.html's save receipt, the
   search turn's `.say`). Those elements carry tabindex="-1": they are NOT
   keyboard-reachable, so a focus ring on them tells a sighted user nothing.

   Chrome still paints one. Its :focus-visible heuristic treats a programmatic
   focus as keyboard-ish whenever the page has seen no pointer input yet — so
   the ring appears on FIRST LANDING and (mostly) not afterwards, which is why
   it reads as "random". Suppress it on the announcement targets only.

   Interactive controls are deliberately excluded: a <button tabindex="-1">
   that a script focuses (search history's close button, on panel open) IS
   where a keyboard user now stands, and must keep its ring. */
:is(h1, h2, h3, h4, h5, h6, p, div, section, article, li, span, form)[tabindex="-1"]:focus {
    outline: none;
}

/* ----- LOCKUP — D + DENZL + tag ----- */

.app-lockup {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    text-decoration: none;
    color: var(--ink);
}
.app-sq {
    width: 28px; height: 28px;
    border: 1.5px solid var(--ink);
    display: flex; align-items: center; justify-content: center;
    font-family: var(--sans);
    font-weight: 800;
    font-size: 18px;
    color: var(--ink);
    line-height: 1;
    flex-shrink: 0;
    /* Optically centre the cap "D": flex-centring a single capital sits it a
       touch low (the line box reserves descender space the letter never uses).
       padding-bottom lifts the flex-centred glyph; em-based so it scales across
       sm/md/lg, and it works on the bare "D" every render site uses (masthead
       lockup + read / check / waitlist / email_login / notification_prefs) —
       no markup wrapper. Value tuned on-device (Bharat/Tanzeel): 0.05em. */
    padding-bottom: 0.05em;
}

/* ── House loader — the "Trace" framed-D (DEN-760) ─────────────────────────
   The standard in-flight loader everywhere a spinner is needed (Bharat,
   2026-07): the frame draws + retreats around a solid, bright mark. Pure
   SVG/CSS, no runtime, honours reduced-motion. Render via partials/loader.html
   or drop a .dz-loader span. Color follows currentColor; size the outer span.
   The D uses an alphabetic baseline at y=73 — measured cap centre for
   Plus Jakarta Sans ExtraBold at this 100-unit viewBox (matches .app-sq). */
.dz-loader { display: inline-flex; color: var(--ink); line-height: 0; }
.dz-loader svg { width: 100%; height: 100%; overflow: visible; }
.dz-loader .dzl-frame { fill: none; stroke: currentColor; stroke-width: 5;
    stroke-dasharray: 356; animation: dz-trace 2s cubic-bezier(.6,0,.4,1) infinite; }
.dz-loader .dzl-d { fill: currentColor; font-family: var(--sans); font-weight: 800;
    font-size: 62px; text-anchor: middle; }
@keyframes dz-trace {
    0% { stroke-dashoffset: 356; } 45% { stroke-dashoffset: 0; }
    55% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: -356; }
}
@media (prefers-reduced-motion: reduce) {
    .dz-loader .dzl-frame { animation: none; stroke-dashoffset: 0; }
}

/* Turbo top progress bar — the "Hairline" (DEN-851). Turbo ships a 3px bright
   BLUE bar on every client-side navigation, jarringly off-brand for a
   monochrome product; restyle it to an ink hairline. Turbo owns width / fade
   timing; we own the look — and the POSITION. Turbo pins it position:fixed
   top:0, which on the edge-to-edge mobile shell tucks it UNDER the status
   bar/notch (invisible — the "slow nav shows no loader" report). Offset it
   below the safe-area inset (the same max(env, --dz-inset) pattern used
   elsewhere) so it reads at the top of the content, and give the ink bar a
   subtle sheen so a slow navigation clearly signals "working". */
.turbo-progress-bar {
    top: var(--dz-safe-top);
    height: 3px;
    /* Round the leading (right) tip for a smoother comet look; the left edge
       stays flush to the screen edge. */
    border-radius: 0 999px 999px 0;
    background: linear-gradient(90deg, var(--ink) 0%, var(--ink) 42%, #8a8a8a 50%, var(--ink) 58%, var(--ink) 100%);
    background-size: 300% 100%;
    animation: turboHairlineSheen 2s linear infinite;
}
@keyframes turboHairlineSheen { 0% { background-position: 150% 0; } 100% { background-position: -150% 0; } }
@media (prefers-reduced-motion: reduce) {
    .turbo-progress-bar { animation: none; background: var(--ink); }
}

/* Branded empty state — the mark + a house line + the one action that fills the
   space, never a bare "0"/"Pending" wall (motion polish, DEN-851). Rendered by
   partials/empty_state.html; reusable across surfaces (feed / saves / search …). */
.dz-empty { display: flex; flex-direction: column; align-items: center;
    text-align: center; gap: 12px; padding: 48px 24px; }
.dz-empty-mark { width: 46px; height: 46px; color: var(--muted); opacity: .55; }
.dz-empty-mark svg { width: 100%; height: 100%; overflow: visible; }
.dz-empty-title { font-family: var(--sans); font-weight: 800; font-size: 19px;
    letter-spacing: -.01em; color: var(--ink); margin: 2px 0 0; }
.dz-empty-body { font-size: 14px; line-height: 1.5; color: var(--muted);
    max-width: 42ch; margin: 0; }
.dz-empty-cta { font-family: var(--mono); font-size: 11px; font-weight: 600;
    letter-spacing: .12em; text-transform: uppercase; color: var(--ink);
    border: 1px solid var(--ink); padding: 10px 18px; margin-top: 6px;
    text-decoration: none; transition: background .15s, color .15s; }
.dz-empty-cta:hover { background: var(--ink); color: var(--surface); }

/* Image load-in — a framed-D monogram holds the 3:4 frame, then the image
   fades + sharpens in on decode: no pop-in, one behaviour across every catalog
   source (mixed-source is the norm). The <img> starts hidden (.dz-img) and its
   own onload adds .is-in; a turbo:load sweep (base.html) catches cache-complete
   images. Reduced-motion shows it immediately. DEN-851 motion polish. */
.dz-img-ph { position: absolute; inset: 0; display: flex; align-items: center;
    justify-content: center; color: var(--muted); opacity: .16; pointer-events: none; }
.dz-img-ph svg { width: 34%; height: 34%; overflow: visible; }
.dz-img { opacity: 0; }
.dz-img.is-in { animation: dz-img-in .55s cubic-bezier(.4, 0, .2, 1) forwards; }
@keyframes dz-img-in { from { opacity: 0; filter: blur(14px); } to { opacity: 1; filter: blur(0); } }
@media (prefers-reduced-motion: reduce) {
    .dz-img { opacity: 1; } .dz-img.is-in { animation: none; }
}
/* No-JS: the fade-in reveals via the image's onload, so without scripting the
   image would stay at opacity:0. Show it immediately — the app requires JS, but
   degrade cleanly rather than to a blank frame (review nit). */
@media (scripting: none) { .dz-img { opacity: 1; } }

.app-wm {
    font-family: var(--sans);
    font-weight: 800;
    font-size: 18px;
    letter-spacing: 3px;
    text-transform: uppercase;
    color: var(--ink);
    line-height: 1;
}
.app-tag {
    font-family: var(--sans);
    font-weight: 600;
    font-size: 10px;
    letter-spacing: 1.8px;
    text-transform: uppercase;
    color: var(--muted);
    border: 1px solid var(--border);
    padding: 4px 8px;
    line-height: 1;
    margin-left: 4px;
}

/* ----- TOP NAV ----- */

.app-top {
    position: sticky; top: 0; z-index: 50;
    background: rgba(255, 255, 255, 0.94);
    backdrop-filter: saturate(140%) blur(8px);
    -webkit-backdrop-filter: saturate(140%) blur(8px);
    border-bottom: 1px solid var(--divider);
}
.app-top-inner {
    max-width: 1280px;
    margin: 0 auto;
    padding: 14px var(--pad-page-x);
    display: flex; align-items: center; justify-content: space-between;
    gap: 18px;
}
@media (min-width: 900px) {
    .app-top-inner { padding: 16px var(--pad-page-x-d); }
}

/* Detail-page header — a left back arrow + the constant lockup, with a small
   right-aligned section tag (.app-top-tag). DEN-536.

   The lead never shrinks. It used to (`min-width: 0`), and when the row ran
   out of room it was the LOCKUP that gave: the lead box shrank, the lockup
   overflowed it, and the section tag beside it was painted straight over
   DENZL. Measured 2026-09-05 at 375px on /commerce-assistant: DENZL ended at
   x=166 and "SHOPPING ASSISTANT" began at x=159. The masthead is canonical
   (docs/brand/design-language.md § The lockup) — it is the constant, so the
   tag is the thing that gives way; see .app-top-tag. */
.app-top-lead { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.app-back {
    display: inline-flex; align-items: center; justify-content: center;
    width: 36px; height: 36px; margin-left: -8px; flex-shrink: 0;
    color: var(--ink); border-radius: 8px; text-decoration: none;
}
.app-back svg {
    width: 22px; height: 22px; fill: none; stroke: currentColor;
    stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}
.app-back:active { background: var(--elevated); }
/* Back arrow + section tag are mobile-only, like the tabbar — desktop has the
   top nav + browser back. */
@media (min-width: 900px) { .app-back { display: none; } }
.app-top-tag {
    font-family: var(--sans); font-weight: 600;
    font-size: 11px; letter-spacing: 1.8px; text-transform: uppercase;
    color: var(--muted);
    /* The tag is the one thing in the row that gives way. At 375px a detail
       page leaves it ~124px beside the back arrow, the lockup and the docked
       Ask orb; "SHOPPING ASSISTANT" needs 152, and as a single nowrap line it
       walked under DENZL. So it may take a second line, right-aligned — two
       11px lines still sit inside the 36px control row, so the header stays
       65px on every page — and a title too long even for two is cut with an
       ellipsis rather than laid over the masthead. `min-width: 0` is what
       lets a flex item shrink below its longest word at all; the lead's
       `flex: 0 0 auto` is what makes this the item that shrinks.
       Proven in a browser by tests/render/test_app_shell_header_no_overlap.py. */
    text-align: right; line-height: 1.2;
    min-width: 0; flex: 0 1 auto;
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
    overflow: hidden;
    /* Chromium hangs the clamp's ellipsis AFTER a right-aligned line's edge,
       where overflow:hidden clips it, so a cut title read as a complete one
       ("THE BOMBAY SHIRT COMPANY" for a brand whose name went on). A 12px
       pocket of padding on the clipped side gives the "…" somewhere to paint;
       the matching negative margin keeps the text's right edge — and the gap
       to the orb — exactly where it was. Whole titles are pixel-identical. */
    padding-right: 12px; margin-right: -12px;
}
@media (min-width: 900px) { .app-top-tag { display: none; } }

.app-nav {
    display: none;
    align-items: center;
    gap: 4px;
}
@media (min-width: 900px) {
    .app-nav { display: flex; }
}
.app-nav a {
    font-family: var(--sans);
    font-weight: 600;
    font-size: 12px;
    letter-spacing: 1.6px;
    text-transform: uppercase;
    color: var(--muted);
    padding: 8px 14px;
    border: 1px solid transparent;
    transition: color 0.15s, border-color 0.15s;
}
.app-nav a:hover { color: var(--ink); }
.app-nav a.active {
    color: var(--ink);
    border: 1px solid var(--ink);
}

/* Hamburger — opens the full-screen section drawer (Pattern C, 2026-06-29).
   Mobile-only; desktop uses .app-nav. A 2×2 dot grid matching the demo. */
/* Right-aligned header group: nav (desktop) + section tag + hamburger (DEN-633).
   `min-width: 0` so the group can be narrower than its tag's longest word;
   without it a flex item's floor is its content and the group would push
   back into the lead instead of the tag wrapping (see .app-top-tag). */
.app-top-actions { display: flex; align-items: center; gap: 14px; min-width: 0; }

.app-burger {
    width: 36px; height: 36px; margin-right: -8px; flex-shrink: 0;
    display: grid; grid-template-columns: 1fr 1fr; gap: 5px;
    place-content: center;
    padding: 9px; background: none; border: 0; cursor: pointer;
    border-radius: 8px;
}
.app-burger span {
    display: block; width: 6px; height: 6px;
    background: var(--ink); border-radius: 50%;
}
.app-burger:active { background: var(--elevated); }
@media (min-width: 900px) { .app-burger { display: none; } }

/* Placed BEFORE every component that uses it: a component rule (the
   drawer's ×, the quick look's) may then override placement — margin,
   position, plate — without raising specificity. Defined after them,
   this block silently won, and the drawer's × lost its offset. */
/* ----- CLOSE / DISMISS — one glyph, one size (design canon) -----
   Every × in the member app is the icon macro's `close` at 14px, stroke
   2.2, #555 that goes ink on hover, inside a SQUARE target. Before this the
   same control shipped as a text × at 13, 16, 18 and 22px (Plus Jakarta's ×
   is a different weight at every size), a 34px round plate, and a 22px box.
   `.dz-close` is the 44px target. `.dz-close--sm` is the 28px feed-card
   corner scale (its ::after still reaches 44px). `.dz-close--boxed` adds
   the over-image white plate + hairline (the quick look's close is the
   anchor). `.dz-close--light` is for a dark scrim. */
.dz-close {
    position: relative; width: 44px; height: 44px; flex: none;
    display: inline-flex; align-items: center; justify-content: center;
    padding: 0; margin: 0; border: 0; border-radius: 0; background: transparent;
    color: #555; cursor: pointer; line-height: 0; font-size: 0;
}
.dz-close:hover, .dz-close:focus-visible { color: #1A1A1A; }
.dz-close svg { width: 14px; height: 14px; }
.dz-close--sm { width: 28px; height: 28px; }
.dz-close--sm::after { content: ""; position: absolute; inset: -8px; }
.dz-close--boxed { background: rgba(255,255,255,.95); border: 1px solid #E8E8E8; }
.dz-close--light { color: #fff; }
.dz-close--light:hover, .dz-close--light:focus-visible { color: #fff; opacity: .8; }

/* ----- HEADER TAP TARGETS — 44px reach, unchanged paint -----
   Measured at 375px: back arrow 36x36, hamburger 36x36, Ask orb 30x30, lockup
   113x28. Four controls on the one row every signed-in surface carries, all
   under the WCAG 2.5.5 floor, and the way OFF a page among them.

   They are not made bigger. The header row is 65px on every page — growing the
   back arrow to a real 44 would make detail-page headers taller than root-page
   ones — and the lockup and the orb are deliberately small marks. So the HIT
   AREA grows and nothing moves: a transparent `::after`, out of flow, hung on a
   negative inset. Layout, paint, and `.app-back:active`'s 36px background are
   all untouched; `position: absolute` also keeps the pseudo-element from
   becoming a grid item inside `.app-burger`'s 2x2 dot grid.

   Scoped to `.app-top` on purpose. `.app-lockup` is the shared masthead — it
   also renders on public pages, the passport card and in email, where an
   invisible box 8px taller than the mark would sit over whatever is beside it.
   `.app-back` likewise belongs to the Ask camera's own floating chrome
   (ask-camera.css), which sizes and paints it itself.

   The insets are what each control needs to reach exactly 44, no more: the back
   arrow paints at x=12 and the lockup starts at x=56, so the two of them share
   8px of gap and neither may spend it twice. The orb's own expansion lives in
   partials/ask_orb.html with the rest of its styles.

   Proven in a browser by tests/render/test_app_shell_touch_targets.py, which
   probes the corners and edge midpoints of a 44x44 square with
   elementFromPoint — a CSS-value assertion cannot see a hit area that is
   deliberately larger than the box it belongs to. */
.app-top .app-back,
.app-top .app-burger,
.app-top .app-lockup { position: relative; }
.app-top .app-back::after,
.app-top .app-burger::after,
.app-top .app-lockup::after {
    content: "";
    position: absolute;
    /* Horizontal only where it is needed: the lockup is already 113px wide. */
    inset: -4px;
}
.app-top .app-lockup::after { inset: -8px 0; }

/* ── Turbo View Transitions (DEN-633) ──────────────────────────────────────
   Turbo 8 wraps same-origin visits in startViewTransition (enabled by the
   <meta name="view-transition"> in base.html, behind feature.turbo.enabled).
   These rules are pure CSS; browsers without the API ignore them and swap
   instantly. Two effects:

   1. Stable header + in-place lead-control swap. The sticky header gets its OWN
      transition group, so it cross-fades in place instead of sliding with the
      page body. The only visible delta between any two pages' headers is the
      lead control — hamburger on root surfaces ↔ back arrow on detail pages —
      so the cross-fade reads as that icon swapping. (The earlier approach gave
      the icon its own view-transition-name, which lifted it out of the page
      snapshot; the body-slide then dragged that floating icon across the
      content. Anchoring the whole header fixes it.) Desktop has no slide, so
      the header just cross-fades with itself — invisible.

      Legal / how-it-works pages render their OWN header (.legal-top), not
      .app-top, so they need the same name or they'd slide whole-page (the "old
      experience" on /privacy, /terms, /how). Each page has exactly one header
      element, so sharing app-header across .app-top and .legal-top is safe —
      and it cross-fades the two header styles into each other when you cross
      between an app page and a legal page. */
.app-top, .legal-top { view-transition-name: app-header; }

/* Card → detail shared-element morph (DEN-851 motion). The product image is
   the anchor: tapping a card lifts its image out of the page and morphs it
   into the PDP hero, rather than cutting to a new screen. The image (card side,
   tagged on click by base.html; PDP side, the hero frame) shares one
   view-transition-name, so the browser tweens position + size between the two.
   Declared globally (not inside the mobile @media) so the hero morphs on every
   width; the ROOT treatment for a morph visit (cross-fade, below) is mobile-only,
   matching the slide. Only ever ONE element carries this per snapshot — the
   clicked card's image, and the PDP's first (visible) hero frame — so the
   name stays unique as the API requires. */
.dz-morph-hero { view-transition-name: dz-hero; }
/* Reduced motion: the root cross-fade below is already gated to no-preference,
   but the hero carries its name globally (so it morphs at every width) — which
   would still animate the image's position/size for a reduced-motion user.
   Drop the name so the hero isn't captured as its own group at all: it stays
   part of the root and just cross-fades, no morph. (Bharat's review form.) */
@media (prefers-reduced-motion: reduce) {
  .dz-morph-hero { view-transition-name: none; }
}

/*   2. Directional page slide. The rest of the page (the ::view-transition
        "root") slides: forward visits push the new page in from the right,
        back/forward-history visits pull it in from the left — native push/pop.
        Direction comes from data-nav-direction on <html> (set in base.html).
        Mobile widths only + only when motion is welcome; desktop and
        reduced-motion users get Turbo's default instant swap. */
@media (prefers-reduced-motion: no-preference) and (max-width: 899px) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.20s;
    animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
    animation-fill-mode: both;
  }
  html[data-nav-direction="forward"]::view-transition-old(root) { animation-name: nav-slide-out-to-left; }
  html[data-nav-direction="forward"]::view-transition-new(root) { animation-name: nav-slide-in-from-right; }
  html[data-nav-direction="back"]::view-transition-old(root) { animation-name: nav-slide-out-to-right; }
  html[data-nav-direction="back"]::view-transition-new(root) { animation-name: nav-slide-in-from-left; }

  /* Refresh (DEN-653): no slide — a PTR pull updates in place, not like a nav. */
  html[data-nav-direction="refresh"]::view-transition-old(root),
  html[data-nav-direction="refresh"]::view-transition-new(root) { animation: none; }

  /* Morph (DEN-851): a card→detail visit. The shared hero image does the
     motion; the rest of the page must NOT slide across it (a slide would drag
     the surrounding chrome sideways while the image expands). Cross-fade the
     root instead, so attention stays on the morphing image. */
  html[data-nav-direction="morph"]::view-transition-old(root) { animation-name: dz-morph-root-out; }
  html[data-nav-direction="morph"]::view-transition-new(root) { animation-name: dz-morph-root-in; }

  /* A Turbo visit already has one directional transition. Do not make the
     incoming page wait for a second 0.55s reveal after that slide: the old
     sequence made a completed navigation look unsettled for nearly a second.
     Cold-load and scroll reveals keep their existing treatment. */
  html.denzl-turbo-nav .reveal { opacity: 1; transform: none; transition: none; }
}
/* The page slide. The incoming page moves as one solid block and its content
   is already settled when the 0.20s snapshot animation ends. The outgoing page
   slides + fades the opposite way. The view transition animates a SNAPSHOT, so
   it runs once per visit and is immune to the DOM-swap reset. */
@keyframes nav-slide-in-from-right { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes nav-slide-in-from-left  { from { transform: translateX(-100%); } to { transform: translateX(0); } }
@keyframes nav-slide-out-to-left   { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-25%); } }
@keyframes nav-slide-out-to-right  { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(25%); } }
/* Morph root cross-fade — the quiet backdrop to the hero image's motion. */
@keyframes dz-morph-root-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes dz-morph-root-in  { from { opacity: 0; } to { opacity: 1; } }

.app-right {
    display: flex; align-items: center; gap: 12px;
}
.app-search {
    display: none;
    align-items: center;
    gap: 8px;
    font-family: var(--sans);
    font-weight: 500;
    font-size: 12px;
    letter-spacing: 0.5px;
    color: var(--muted);
    background: var(--surface);
    border: 1px solid var(--border);
    padding: 7px 10px;
    cursor: pointer;
    transition: border-color 0.15s, color 0.15s;
}
.app-search:hover { border-color: var(--ink); color: var(--ink); }
.app-search kbd {
    font-family: var(--mono);
    font-size: 10px;
    background: var(--soft);
    border: 1px solid var(--border);
    padding: 2px 5px;
    color: var(--graphite);
    margin-left: 6px;
}
@media (min-width: 900px) {
    .app-search { display: inline-flex; }
}
.app-avatar {
    width: 32px; height: 32px;
    border: 1px solid var(--ink);
    display: flex; align-items: center; justify-content: center;
    font-family: var(--sans);
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.04em;
    color: var(--ink);
    text-decoration: none;
    flex-shrink: 0;
}

/* Membership banner (mobile) — sits under nav */
.app-membership-banner {
    display: block;
    padding: 8px var(--pad-page-x);
    background: var(--soft);
    border-bottom: 1px solid var(--border);
    font-family: var(--sans);
    font-size: 11px;
    color: var(--muted);
    text-align: center;
    letter-spacing: 0.02em;
}
@media (min-width: 900px) { .app-membership-banner { display: none; } }

/* ----- MAIN ----- */

.app-main {
    max-width: 1280px;
    margin: 0 auto;
    padding: var(--pad-main-top) var(--pad-page-x) 40px var(--pad-page-x);
}
@media (min-width: 900px) {
    :root { --pad-main-top: 24px; }
    .app-main { padding: var(--pad-main-top) var(--pad-page-x-d) 64px var(--pad-page-x-d); }
}

/* ----- FOOTER ----- */

.app-foot {
    border-top: 1px solid var(--ink);
    margin-top: 96px;
}
/* Native shell (DEN-660): the web-style footer reads as "a webpage" inside the
   Capacitor app. Hide it there (html.native-app is set in base.html when
   window.Capacitor is present). Mobile-web keeps the footer — its links have
   SEO/legal value. */
html.native-app .app-foot { display: none; }

/* Native shell (DEN-660): kill the web long-press "callout" (the iOS Open/Copy/
   Save sheet on links + images) app-wide, and disable text SELECTION on the
   chrome/UI (nav, drawer, header, buttons, tabs, pills, cards, icons/images) so
   a long-press doesn't raise selection handles on a tab label or product card —
   the tell that reads as "a webpage in a wrapper." Gated to html.native-app so
   mobile-web + desktop keep normal selection. Selection is deliberately LEFT ON
   for genuine text the user may copy — body copy, the member code, the ingest
   email, order ids — because -webkit-touch-callout:none suppresses the link/image
   callout WITHOUT touching text-selection, and user-select:none is scoped to
   non-text UI only (never the content column / inputs). */
html.native-app { -webkit-touch-callout: none; }
html.native-app .app-top,
html.native-app .app-drawer-panel,
html.native-app nav,
html.native-app button,
html.native-app .cta,
html.native-app .chip,
html.native-app .eye,
html.native-app .label,
html.native-app img,
html.native-app svg {
  -webkit-user-select: none;
  user-select: none;
}
/* But never block copying real text: inputs + anything that opts in stay
   selectable even inside the chrome selectors above. */
html.native-app input,
html.native-app textarea,
html.native-app [contenteditable],
html.native-app .dz-selectable {
  -webkit-user-select: text;
  user-select: text;
}

/* Safe-area insets (DEN-660). viewport-fit=cover (base.html) exposes
   env(safe-area-inset-*) so the sticky header clears the status bar / notch and
   content clears the home indicator. Applied GLOBALLY, not gated to the
   Capacitor shell: the installed PWA (DEN-531, display:standalone) is also
   full-bleed under the notch but has NO window.Capacitor, so a .native-app gate
   would leave it unprotected (Bharat, #613 review). env() is ~0 in a normal
   browser tab (the browser chrome owns those zones), so a global rule is
   harmless there and fixes both the PWA and the native shell.
   .app-drawer-panel already applies these insets in its own base rule below. */
.app-top { padding-top: var(--dz-safe-top); }
.app-main { padding-bottom: calc(40px + var(--dz-safe-bottom)); }
@media (min-width: 900px) { .app-foot { margin-top: 128px; } }
/* No fixed tabbar anymore (Pattern C drawer) — the footer just needs to clear
   the iOS home indicator on mobile. */
@media (max-width: 899.98px) {
    .app-foot { padding-bottom: var(--dz-safe-bottom); }
}
.app-foot-inner {
    max-width: 1280px;
    margin: 0 auto;
    padding: 28px var(--pad-page-x);
    display: flex;
    align-items: center;
    /* Links keep their right-edge position now that the wordmark byline is
       gone (removed site-wide, Bharat 2026-08-14). */
    justify-content: flex-end;
    gap: 18px;
    flex-wrap: wrap;
}
@media (min-width: 900px) {
    .app-foot-inner { padding: 32px var(--pad-page-x-d); }
}
.app-foot-links { display: flex; gap: 18px; }
.app-foot-links a {
    font-family: var(--sans);
    font-weight: 500;
    font-size: 11px;
    letter-spacing: 1.6px;
    text-transform: uppercase;
    color: var(--muted);
    border-bottom: 1px solid transparent;
    padding-bottom: 1px;
    transition: color 0.15s, border-color 0.15s;
}
.app-foot-links a:hover { color: var(--ink); border-bottom-color: var(--ink); }

/* ----- MOBILE TABBAR ----- */

/* ----- SECTION DRAWER (mobile, Pattern C — 2026-06-29) ----- */
/* Full-screen black overlay opened by the header hamburger. Replaces the
   bottom tabbar: menu-as-destination, no persistent tabs. Hidden by default;
   .open (toggled in base.html) reveals it. Desktop never shows it (.app-nav). */
/* Drawer choreography: the panel SLIDES in from the left edge (it opens from
   the top-left hamburger) while a scrim darkens behind it; close reverses both.
   `display` can't transition, so we toggle a darkening backdrop + visibility —
   visibility flips after the slide on close (0.28s delay) so the panel stays
   visible through the slide-out, then drops out of the a11y/hit tree. The panel
   itself stays fully opaque (pure slide, no fade) for a real drawer feel. */
.app-drawer {
    position: fixed; inset: 0; z-index: 200;
    background: rgba(0, 0, 0, 0);
    visibility: hidden; pointer-events: none;
    transition: background-color 0.28s ease, visibility 0s linear 0.28s;
}
.app-drawer.open {
    background: rgba(0, 0, 0, 0.5);
    visibility: visible; pointer-events: auto;
    transition: background-color 0.28s ease, visibility 0s;
}
@media (min-width: 900px) { .app-drawer, .app-drawer.open { display: none; } }

.app-drawer-panel {
    position: absolute; inset: 0;
    background: var(--ink);
    display: flex; flex-direction: column;
    /* Scroll as a fallback so the footer (passport link) is always reachable
       on short/landscape viewports where bar + tiles + footer exceed the
       height (DEN-536 review). */
    overflow-y: auto;
    padding-top: var(--dz-safe-top);
    padding-bottom: var(--dz-safe-bottom);
    transform: translateX(100%);   /* slides in from the RIGHT — matches the top-right hamburger (DEN-633) */
    transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
    will-change: transform;
}
.app-drawer.open .app-drawer-panel { transform: translateX(0); }

/* Respect reduced-motion: no slide/scrim choreography, just show/hide. */
@media (prefers-reduced-motion: reduce) {
    .app-drawer { transition: visibility 0s; }
    .app-drawer-panel { transform: none; transition: none; }
}

.app-drawer-bar {
    height: 56px; flex-shrink: 0;
    display: flex; align-items: center; justify-content: space-between;
    padding: 0 16px;
}
.app-drawer-x {
    /* Box, glyph and colour from .dz-close--light — 44px square, 14px glyph.
       This rule only hugs the right edge, under the top-right hamburger it
       replaces (DEN-633). The glyph used to be 22px: the largest × in the
       shell, and one of the two Bharat picked out on 2026-09-05.

       The pull aligns this glyph's CENTRE with the hamburger's on the page
       underneath (both 30px in from the right edge), which is what DEN-633's
       "mirrors the hamburger" meant. The two boxes differ — 44 vs 36 — so
       matching the margin would have offset the marks by 8px. */
    margin-right: -8px;
}
.app-drawer-x svg {
    /* size comes from .dz-close; the drawer's × is drawn inline, so it
       carries the icon macro's stroke rather than the macro itself */
    fill: none; stroke: currentColor;
    stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round;
}
.app-drawer-wm {
    font-family: var(--sans); font-weight: 800;
    font-size: 15px; letter-spacing: 3px; text-transform: uppercase;
    color: #fff;
}
.app-drawer-bar-spacer { width: 36px; flex-shrink: 0; }

/* Tiles anchored to the TOP of the panel (not vertically centered), sized to
   ~48% of the viewport so there's breathable space below before the footer
   (which margin-top:auto pins to the bottom). grid-auto-rows:1fr stretches
   each row equally within that height; 14px gap spaces the tiles. */
.app-drawer-grid {
    flex: 0 0 auto;
    height: 48vh;
    display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
    grid-auto-rows: 1fr;
    padding: 8px 20px 0;
}
.app-drawer-tile {
    background: var(--ink-tile);
    border-radius: 14px;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 16px; padding: 20px 0;
    text-decoration: none;
    transition: background 0.15s;
}
.app-drawer-tile:active { background: var(--ink-tile-press); }
.app-drawer-tile.active { background: #fff; }
/* DIRECT children only. A tile's glyph is its own child <svg>; the Ask tile's
   mark is the orb component (partials/ask_orb.html), which carries an <svg> of
   its own INSIDE it. A descendant selector reached that one too and won on
   specificity (0,1,1 over the component's 0,1,0) — sizing the orb's inner
   <svg> to 38px, so its flowing lines drew in a 38px box inside a 48px bead
   (the strokes themselves kept the component's own 2.6px/white: the path
   rules are 0,3,0 and win). */
.app-drawer-tile > svg {
    width: 38px; height: 38px;
    fill: none; stroke: #fff; stroke-width: 1.5;
    stroke-linecap: round; stroke-linejoin: round;
}
.app-drawer-tile.active > svg { stroke: var(--ink); }
/* The orb is a filled bead with its own dark ground, so it reads smaller than
   a line drawing of the same box: 48px against the glyphs' 38px is where the
   two marks carry equal weight in the grid.
   The negative block margin is what keeps the row honest. A tile is a centred
   flex column, so a 48px mark in a 38px row pushes this tile's label 5px below
   its three neighbours' — visible across a 2x2 grid. Bleeding the extra 10px
   symmetrically leaves the orb painting 48px while occupying a glyph's 38px of
   layout, into a 16px gap that has the room. */
.app-drawer-tile .ask-orb {
    --ask-orb-size: 48px;
    margin-block: -5px;
}
/* The --light bloom is sized in absolute px by the component (0 0 46px), tuned
   for a 30-44px bead docked in the header and for the 180px lens that fills the
   camera stage. At 48px on a --ink-tile the blur is wider than the orb itself
   and reads as a spotlight washing the tile, next to three crisp line glyphs.
   The bead still needs separation from #2A2A2A, so this tightens the bloom
   rather than removing it; the orb's own --ask-orb__glow layer is inset in %,
   already scales with the mark, and is left alone. */
.app-drawer-tile .ask-orb--light {
    box-shadow: 0 0 18px rgba(244, 244, 239, .22), 0 6px 16px rgba(0, 0, 0, .5);
}
.app-drawer-tile-label {
    font-family: var(--sans); font-weight: 700;
    font-size: 13px; letter-spacing: 1.8px; text-transform: uppercase;
    color: var(--muted);
}

/* Short / landscape viewports: let the tile grid shrink (don't pin 48vh) so the
   footer + tiles stay on-screen; the panel's overflow-y is the final fallback. */
@media (max-height: 600px) {
    .app-drawer-grid { height: auto; flex: 1 1 auto; min-height: 0; }
    .app-drawer-tile { padding: 14px 0; gap: 10px; }
}
.app-drawer-tile.active .app-drawer-tile-label { color: var(--ink); }

.app-drawer-foot {
    flex-shrink: 0;
    margin-top: auto;
    display: flex; align-items: center; gap: 10px;
    padding: 16px 24px 24px;
    text-decoration: none;
}
.app-drawer-avatar {
    width: 36px; height: 36px; border-radius: 50%;
    background: var(--ink-tile-press); color: #fff;
    display: flex; align-items: center; justify-content: center;
    font-family: var(--sans); font-weight: 700; font-size: 13px;
    flex-shrink: 0;
}
.app-drawer-id { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.app-drawer-id-name {
    font-family: var(--sans); font-weight: 700; font-size: 13px; color: #fff;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.app-drawer-id-sub {
    font-family: var(--sans); font-weight: 500; font-size: 11px; color: var(--muted);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.app-drawer-go { width: 20px; height: 20px; fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }

/* ----- COMMON SECTION PATTERNS ----- */

/* 2026-04-26: mobile uses --pad-section (now 40px, was 56px) for the
   section rhythm. Desktop keeps the 88px breathing room. The eye-row
   bottom-margin (24px below) is the visual divider on top — extra
   .blk margin on mobile compounded into too much air. */
.blk { margin: var(--pad-section) 0; }
.blk:first-of-type { margin-top: 8px; }
@media (min-width: 900px) { .blk { margin: var(--pad-section-d) 0; } }

.eye {
    display: flex; justify-content: space-between; align-items: baseline;
    gap: 12px; flex-wrap: wrap;
    padding-bottom: 14px; margin-bottom: 24px;
    border-bottom: 1px solid var(--ink);
}
.eye .l {
    font-family: var(--sans); font-weight: 700;
    font-size: var(--t-eye); letter-spacing: 1.8px; text-transform: uppercase;
    color: var(--ink); font-feature-settings: "tnum";
}
.eye .r {
    font-family: var(--sans); font-weight: 500;
    font-size: var(--t-eye); letter-spacing: 1.6px; text-transform: uppercase;
    color: var(--muted); font-feature-settings: "tnum";
}

/* ──────────────────────────────────────────────────────────────────
   Type roles use fluid tokens (clamp). 2026-04-26 round-10 — replaces
   the 3-tier @media cascade we used to maintain on every headline.
   See :root --t-* tokens above for the formulas.
   ────────────────────────────────────────────────────────────────── */

.h1, .h2 {
    font-family: var(--sans); font-weight: 800;
    line-height: 1.02; letter-spacing: -0.018em;
    text-transform: uppercase;
    color: var(--ink);
    margin: 0 0 12px 0;
}
.h1 { font-size: var(--t-display); }
/* 2026-06-20 (Bharat): section headers were the full --t-hero (up to 64px),
   a near-page-hero that pushed the data/products below the fold. Dropped to
   --t-h2 (the type scale's own section-header step, up to 32px) so content
   leads. Page heroes (.h1 / digest .cover-h) keep --t-display / --t-hero. */
.h2 { font-size: var(--t-h2); }

.sub {
    font-family: var(--sans); font-weight: 400;
    font-size: var(--t-sub); line-height: 1.55;
    color: var(--body);
    max-width: 60ch;
    margin: 0 0 18px 0;
}
.sub strong { font-weight: 700; color: var(--ink); }

.foot-note {
    margin-top: 32px;
    font-family: var(--sans);
    font-size: 13px;
    color: var(--muted);
    line-height: 1.6;
    padding-top: 18px;
    border-top: 1px solid var(--border);
}

/* ──────────────────────────────────────────────────────────────────
   Layout primitive — .signature-block
   2026-04-26 round-10 (Bharat: "we need to work around with font
   sizing responsively, what's modern design principles for").

   Codifies the pattern that Digest / Discover / Dossier all want:
       - On mobile: the page's signature shape (cover artefact, stats
         grid, credential band) opens FIRST; editorial prose follows.
       - On desktop: signature and prose sit side-by-side, balanced.

   Markup:
       <div class="signature-block">
           <div class="signature">[silhouette / stats / portrait]</div>
           <div class="prose">[eye + h1 + sub]</div>
       </div>

   Replaces the per-page `.cover` / `.cohort-band` / `.portrait-section`
   ad-hoc grids that we shipped piecemeal in rounds 7-9.
   ────────────────────────────────────────────────────────────────── */
.signature-block {
    display: grid;
    grid-template-columns: 1fr;
    gap: clamp(20px, 2vw + 12px, 48px);
    border-top: 1px solid var(--ink);
    border-bottom: 1px solid var(--ink);
    padding: clamp(24px, 2vw + 12px, 48px) 0;
}
@media (max-width: 899.98px) {
    .signature-block .signature { order: -1; }
}
@media (min-width: 900px) {
    .signature-block { grid-template-columns: 1.2fr 1fr; align-items: center; }
    .signature-block.prose-leading { grid-template-columns: 1.4fr 1fr; align-items: end; }
}
.signature-block .signature { display: flex; flex-direction: column; gap: 14px; }
.signature-block .prose { display: flex; flex-direction: column; gap: 14px; }

/* Feed cards are reused across one-, two-, three- and four-column surfaces.
   Their responsive contract belongs to the component's own width. */
.feed-card { container-type: inline-size; container-name: feed-card; }

/* ──────────────────────────────────────────────────────────────────
   Feed card save control (the bookmark, top-right of every card).
   2026-08-21: the glyph moved `+` → bookmark, and the saved/unsaved
   LOOK moved out of JS and into this one rule.

   Why it lives here and not in Tailwind classes on the button: the
   card ships on two surfaces with two different controllers
   (discover_feed.js on Discover, search_controller.js on Search).
   While the look was a list of Tailwind classes, each controller had
   to re-apply that list on toggle — Discover did, Search didn't, so
   saving from Search silently left the button unchanged. Both now
   only flip `data-action`, and the state below is the single source
   of the look. Values match the sibling "Not for me" X button.
   ────────────────────────────────────────────────────────────────── */
/* The product page's corner bookmark (.pd-save-btn, 2026-09-11) wears the
   same rule — Bharat asked for it "like we have on Discover". Its state is
   aria-pressed: the PDP has one controller and no data-action. */
.feed-card-save,
.pd-save-btn {
    background: rgba(255, 255, 255, .95);
    color: #555;
    border-color: #E8E8E8;
}
/* Hover only where there IS hover. On a touchscreen a tapped button keeps
   :hover until the next tap elsewhere, so un-saving left the bookmark
   painted ink — it read as still saved. */
@media (hover: hover) {
    .feed-card-save:hover,
    .pd-save-btn:hover { background: #1A1A1A; color: #fff; border-color: #1A1A1A; }
}
/* Saved — ink button, SOLID bookmark. Must follow :hover so a saved
   card stays filled while the pointer is over it. */
.feed-card-save[data-action="unsave"],
.pd-save-btn[aria-pressed="true"] {
    background: #1A1A1A;
    color: #fff;
    border-color: #1A1A1A;
}
.feed-card-save[data-action="unsave"] .feed-save-icon { fill: currentColor; }
.pd-save-btn[aria-pressed="true"] svg { fill: currentColor; }

/* Explicit card try-on (DEN-1655). The card is still a product link; this
   separate full-width control is a deliberate render action and never fires on
   hover, impression, scroll or card navigation. */
.feed-card-tryon-stage { z-index: 5; }
/* An underlined line of micro-type (DEN-1970 follow-up), 44px tall for the
   thumb. Idle: ink, underlined. Hover: the line thickens. Busy: grey, no
   line ("Making your preview…"). Ready: bold ink, no line ("On you"). */
/* DEN-1973: the reason chip's BOX, not its words. A 28-char chip legitimately
   needs two lines in a 150px card and truncating it would defeat the chip, so
   its padding and the gap under it tighten instead. Written here rather than
   as Tailwind utilities because tailwind.css is a purged build that is not
   regenerated on deploy: py-0.5 and mb-1.5 are absent from it and silently
   removed the padding they were meant to shrink.

   Two class names, not one: base.html loads app-shell.css BEFORE tailwind.css,
   so a single-class rule here LOSES the cascade to `py-1`/`mb-2` at equal
   specificity. `.feed-card .feed-card-reason` outranks them. */
.feed-card .feed-card-reason { padding-top: 2px; padding-bottom: 2px; margin-bottom: 6px; }
.feed-card-tryon-button { cursor: pointer; display: flex; align-items: center; min-height: 28px; text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1px; }
/* DEN-1973: the row was a 44px box centring an 11px line, so half of it was
   dead space above and below the only word on it. The row is 28px now and the
   44px touch target is this pseudo-element (28 + 8 + 8), which is out of flow
   and so costs no layout height — the trick `.dz-zoom-btn::after` already uses.
   Kept symmetric and inside the card: reaching 44px from an 11px row would
   have meant covering the facet line above or spilling past the card's bottom
   edge, and a tap meant for "Try on" should not start on the facts. */
.feed-card-tryon-button::after { content: ""; position: absolute; inset: -8px -12px; }
.feed-card-tryon-button:hover:not(:disabled) { text-decoration-thickness: 2px; }
.feed-card-tryon-button:disabled { cursor: default; text-decoration: none; }
.feed-card[data-feed-tryon-state="busy"] .feed-card-tryon-button { color: #767676; }
.feed-card[data-feed-tryon-state="ready"] .feed-card-tryon-button { color: #1A1A1A; font-weight: 700; }
.feed-card-tryon-spinner {
    width: 24px; height: 24px; border-radius: 999px;
    border: 2px solid #D8D8D8; border-top-color: #1A1A1A;
    animation: feed-card-tryon-spin .8s linear infinite;
}
@keyframes feed-card-tryon-spin { to { transform: rotate(360deg); } }
/* DEN-1906: "Add a photo to try on" broke as "…TO TRY / ON" in a phone's
   2-column card (10px, 1.5px tracking in a ~135px button) — an orphaned
   word on a 49px button. The copy is a pinned promise (DEN-1823), so a
   narrow card tightens the tracking and, where two lines are still needed,
   balances them ("ADD A PHOTO / TO TRY ON") at a compact height. The card
   is a size container, so this follows the column, not the viewport.
   The size stays on the 10px eyebrow floor (design-language.md): 9px was
   the one label in the app under it. */
@container feed-card (max-width: 200px) {
    .feed-card .feed-card-tryon-button {
        font-size: 10px; letter-spacing: .8px; line-height: 1.25;
        text-wrap: balance;
    }
}
@media (prefers-reduced-motion: reduce) {
    .feed-card-tryon-spinner { animation-duration: 1.8s; }
}

/* Reveal-on-scroll — pages opt in by adding .reveal */
.reveal {
    opacity: 0;
    transform: translateY(10px);
    transition: opacity 0.55s ease, transform 0.55s ease;
}
.reveal.in {
    opacity: 1;
    transform: none;
}

/* Interaction acknowledgement (DEN-1781). The press is immediate; async
   controllers opt into the stable pending state through DenzlInteraction.
   No spinner is added here: the existing Turbo ink hairline or the owning
   surface's status copy explains the work without a second visual language. */
:where(a, button, input[type="submit"], input[type="button"], [role="button"])[data-interaction-pressed] {
    opacity: 0.70;
    transition: opacity 0.06s linear;
}
:where(a, button, input[type="submit"], input[type="button"], [role="button"])[data-interaction-pending] {
    cursor: progress;
    opacity: 0.62;
    pointer-events: none;
    transition: opacity 0.12s ease;
}
html[data-interaction-navigation] { cursor: progress; }
/* A confirmed navigation that never landed (NAVIGATION_COMPLETE_MS). The tap
   has already been released by then, so the page is usable again; this only
   says so and hands the destination back. Same ink pill as the wardrobe and
   native-back toasts — no new visual language, no accent colour. It sits
   above the bottom bar and the Ask orb, and clears itself on the next
   navigation. */
.dz-stalled-notice {
    position: fixed;
    left: 50%;
    bottom: calc(92px + var(--dz-safe-bottom, 0px));
    transform: translateX(-50%);
    z-index: 220;
    display: flex;
    align-items: center;
    gap: 10px;
    /* A fixed element at left:50% shrink-to-fits into HALF the viewport, so
       `auto` wrapped "That did not open." onto two lines inside the pill.
       max-content sizes it to the sentence; the max-width still clamps it on
       a narrow phone. */
    width: max-content;
    max-width: min(92vw, 420px);
    padding: 0 6px 0 18px;
    background: #1A1A1A;
    color: #FFF;
    border-radius: 999px;
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
}
.dz-stalled-notice__text {
    font-size: 13px;
    font-weight: 600;
    line-height: 1.2;
}
/* 44px is the touch floor, not a suggestion — this is the only way out of a
   dead tap. */
.dz-stalled-notice__retry {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    min-width: 44px;
    padding: 0 14px;
    color: #FFF;
    font-size: 13px;
    font-weight: 700;
    text-decoration: underline;
    text-underline-offset: 3px;
    white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
    :where(a, button, input[type="submit"], input[type="button"], [role="button"])[data-interaction-pressed],
    :where(a, button, input[type="submit"], input[type="button"], [role="button"])[data-interaction-pending] {
        transition: none;
    }
}
@media (prefers-reduced-motion: reduce) {
    .reveal { opacity: 1 !important; transform: none !important; }
}

/* ----- BUTTONS — .btn ladder (DEN-539) ----- */
/*
   The single button ladder. No per-page button styles exist outside
   this block — page-specific containers may layout buttons, but the
   visual chrome lives here. Doc: docs/design/buttons.md. Guard:
   tests/test_button_layer.py.

   Tiers:
     .btn.primary    solid ink (visually identical to the old .cta)
     .btn.secondary  outlined ink (visually identical to .cta.ghost)
     .btn.tertiary   ghost / text-only
     .btn.link       text + persistent underline (muted → ink on hover)
     .btn.danger     #CC0000 (the "Returned" colour token)

   Context: .on-dark composes with .primary / .secondary to invert the
            ladder (white ↔ ink) for a CTA on a dark / photo backdrop.

   States: :hover / :active / [disabled] / .loading.
   Sizes:  .sm / .md (default) / .lg — padding + font-size only,
           never colour.

   .cta and .cta.ghost are kept as aliases on the same selectors so
   existing call sites keep rendering unchanged while DEN-540/541/542
   sweep them onto the .btn names. The guard child (DEN-543) locks the
   contract once the sweep is in. */

/* ── Base: shared by every tier + the .cta alias ───────────────────── */
.btn, .cta, button.cta, a.cta {
    position: relative;
    display: inline-flex; align-items: center; justify-content: center;
    gap: 8px;
    font-family: var(--sans); font-weight: 700;
    font-size: 11px; letter-spacing: 1.6px; text-transform: uppercase;
    border: 1px solid transparent;
    padding: 11px 14px;
    border-radius: 0;
    cursor: pointer;
    text-decoration: none;
    transition: background 0.15s ease, color 0.15s ease,
                border-color 0.15s ease, opacity 0.15s ease;
}
.btn:focus-visible,
.cta:focus-visible, button.cta:focus-visible, a.cta:focus-visible {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
}

/* ── Tier: primary (solid ink — today's .cta) ───────────────────────── */
.btn.primary,
.cta, button.cta, a.cta {
    color: var(--surface);
    background: var(--ink);
    border-color: var(--ink);
}
.btn.primary:hover,
.cta:hover, button.cta:hover, a.cta:hover {
    background: var(--graphite); border-color: var(--graphite);
}
.btn.primary:active,
.cta:active, button.cta:active, a.cta:active {
    background: #000; border-color: #000;
}

/* ── Tier: secondary (outlined ink — today's .cta.ghost) ─────────── */
.btn.secondary,
.cta.ghost, button.cta.ghost, a.cta.ghost {
    color: var(--ink);
    background: var(--surface);
    border-color: var(--ink);
}
.btn.secondary:hover,
.cta.ghost:hover, button.cta.ghost:hover, a.cta.ghost:hover {
    background: var(--ink); color: var(--surface);
}
.btn.secondary:active,
.cta.ghost:active, button.cta.ghost:active, a.cta.ghost:active {
    background: #000; color: var(--surface);
}

/* ── Tier: tertiary (ghost / text-only) ───────────────────────────── */
.btn.tertiary {
    color: var(--ink);
    background: transparent;
    border-color: transparent;
}
.btn.tertiary:hover  { background: var(--elevated); }
.btn.tertiary:active { background: var(--border); }

/* ── Tier: danger (returned-red) ──────────────────────────────────── */
.btn.danger {
    color: var(--surface);
    background: #CC0000;
    border-color: #CC0000;
}
.btn.danger:hover  { background: #B30000; border-color: #B30000; }
.btn.danger:active { background: #990000; border-color: #990000; }

/* ── Tier: link — text-only with a persistent underline ───────────── */
/* The house "link-button": a low-emphasis text action that reads as a
   link with a hit area. Heavier than tertiary (which is borderless) —
   it carries a persistent underline that flips muted → ink on hover.
   Replaces the per-page .recover-btn (search) / .disc-btn (you, dossier)
   drift. House type comes from the base; no fill, no box. */
.btn.link {
    color: var(--muted);
    background: transparent;
    border: none;
    border-bottom: 1px solid var(--border);
    border-radius: 0;
    padding: 0 0 4px;
    gap: 6px;
}
.btn.link:hover  { color: var(--ink); border-bottom-color: var(--ink); background: transparent; }
.btn.link:active { color: var(--ink); }

/* ── Context: on-dark — invert the ladder on a dark / photo backdrop ─ */
/* Composes with .primary / .secondary. Stays monochrome (white ↔ ink) —
   no chrome colour. Replaces passport's per-page .pp-share-btn
   solid/ghost. Use on any CTA sitting on a dark or image surface. */
.btn.on-dark.primary {
    background: var(--surface); color: var(--ink); border-color: var(--surface);
}
.btn.on-dark.primary:hover  { background: var(--soft); border-color: var(--soft); }
.btn.on-dark.secondary {
    background: transparent; color: var(--surface);
    border-color: rgba(255, 255, 255, 0.4);
}
.btn.on-dark.secondary:hover { background: rgba(255, 255, 255, 0.08); color: var(--surface); }

/* ── State: disabled (every tier, .btn + .cta alias) ──────────────── */
.btn[disabled], .btn[aria-disabled="true"],
.cta[disabled], .cta[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
    pointer-events: none;
}

/* ── State: loading — hides label, spins a CSS-only ring ──────────── */
/* Same visual shape as scanning.html's animate-spin (border-2 + border-t-
   transparent), but self-contained — no Tailwind dependency in the
   component layer. */
.btn.loading {
    color: transparent;
    cursor: wait;
    pointer-events: none;
}
.btn.loading::after {
    content: "";
    position: absolute;
    width: 14px; height: 14px;
    border: 2px solid var(--ink);
    border-top-color: transparent;
    border-radius: 50%;
    animation: denzl-btn-spin 0.7s linear infinite;
}
.btn.primary.loading::after,
.btn.danger.loading::after {
    border-color: var(--surface);
    border-top-color: transparent;
}
@keyframes denzl-btn-spin { to { transform: rotate(360deg); } }

/* ── Sizes — padding + font-size only, never colour ───────────────── */
.btn.sm {
    font-size: 10px; letter-spacing: 1.4px;
    padding: 7px 10px;
}
.btn.md {
    font-size: 11px; letter-spacing: 1.6px;
    padding: 11px 14px;
}
.btn.lg {
    font-size: 12px; letter-spacing: 1.8px;
    padding: 14px 20px;
}

/* ----- BRAND-ROW LOCKUP — .bn-stack / .bm / .bn ----- */
/*
   The little-D inside-a-square for brand initials, plus the
   brand name. Used everywhere — Digest's kept-list, Dossier's
   brands list, brand cards in feeds. Three sizes: default,
   .lg (24px square), .xl (40px square).
*/

.bn-stack {
    display: inline-flex; align-items: center; gap: 8px;
    line-height: 1;
}
.bn-stack.tight { gap: 6px; }
.bm {
    width: 22px; height: 22px;
    border: 1px solid var(--ink);
    display: inline-flex; align-items: center; justify-content: center;
    font-family: var(--sans); font-weight: 800;
    font-size: 10px; letter-spacing: 0.02em;
    color: var(--ink);
    flex-shrink: 0;
}
.bm.lg { width: 32px; height: 32px; font-size: 13px; }
.bm.xl { width: 48px; height: 48px; font-size: 18px; }
.bn {
    font-family: var(--sans); font-weight: 600;
    font-size: 13px; letter-spacing: 0.02em;
    color: var(--ink);
    text-transform: uppercase;
}
.bn-lg {
    font-family: var(--sans); font-weight: 700;
    font-size: 16px; letter-spacing: -0.005em;
    color: var(--ink);
    text-transform: uppercase;
}
.bn-xl {
    font-family: var(--sans); font-weight: 800;
    font-size: 24px; letter-spacing: -0.01em;
    color: var(--ink);
    text-transform: uppercase;
}
@media (min-width: 900px) {
    .bn-xl { font-size: 32px; }
}

/* Inline secondary label often paired with a product name */
.in-desc {
    font-family: var(--sans); font-weight: 400;
    font-size: 12px;
    color: var(--graphite);
    line-height: 1.4;
}

/* ----- ACCESSIBILITY: focus ----- */

a:focus-visible, button:focus-visible {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
}

/* ----- PULL-TO-REFRESH (DEN-531) ----- */
/* Shared by any scroll surface that calls denzlPullToRefresh() (base.html).
   Indicator sits just above the viewport top and is pulled down by the gesture
   via inline transform set in JS. */
@keyframes ptrspin { to { transform: rotate(360deg); } }
.ptr-indicator {
    /* Anchor below the notch/status bar so the pulled-down spinner doesn't sit
       under the cutout on mobile (safe-area inset; --dz-inset-top is the native
       stand-in where env() is 0). */
    position: fixed; top: var(--dz-safe-top); left: 50%;
    margin-left: -16px; margin-top: -44px;
    width: 32px; height: 32px;
    display: flex; align-items: center; justify-content: center;
    z-index: 60; pointer-events: none;
    opacity: 0;
}
.ptr-indicator .ptr-spinner {
    width: 22px; height: 22px; border-radius: 50%;
    border: 2px solid var(--border); border-top-color: var(--ink);
}
.ptr-indicator.ptr-spinning .ptr-spinner { animation: ptrspin 0.7s linear infinite; }

/* ----- PRINT (passport, dossier exports) ----- */
@media print {
    .app-top, .app-drawer, .app-foot { display: none !important; }
    .app-main { padding: 0; max-width: 100%; }
    .reveal { opacity: 1 !important; transform: none !important; }
}

/* ──────────────────────────────────────────────────────────────────
   Quick look (DEN-1901) — the product, in place, under the row he
   tapped. A full-width grid row inserted after the tapped card's row;
   never an overlay. Silent chrome: hairlines, paper, ink. The product
   photos are the only colour.
   ────────────────────────────────────────────────────────────────── */
/* The visible control: a chevron over the footer's first line. The
   footer sits under a 3:4 image, and .feed-card is a size container
   (container-type: inline-size), so 133.33cqw is exactly the image's
   height at any column width. */
.feed-card { position: relative; }
.feed-card-quick { display: none; }
[data-quick-look] .feed-card-quick {
    display: inline-flex;
    position: absolute; right: 6px; top: calc(133.33cqw + 4px);
    width: 44px; height: 44px; align-items: center;
    justify-content: center; background: transparent; border: 0;
    color: #888; cursor: pointer; border-radius: 999px;
    transition: color .15s ease, transform .18s ease;
}
.feed-card-quick svg { width: 16px; height: 16px; }
.feed-card-quick:hover { color: #1A1A1A; }
.feed-card-quick:focus-visible { outline: 2px solid #1A1A1A; outline-offset: -4px; }
.feed-card.is-quick-open .feed-card-quick { color: #1A1A1A; transform: rotate(180deg); }
/* Room for the chevron on the footer's first line; the words read as a
   control only where the quick look is live. */
[data-quick-look] .feed-card-foot[data-quick-foot] { padding-right: 44px; cursor: pointer; }

/* The quick look is scrolled into view (feed_quick_look.js), so this margin is
   a below-the-header offset like every other one — the measured height, never a
   literal (DEN-1960). The old flat 84px was the header (65px on a phone) plus a
   19px gap; in the app the header is 107px, so the row landed 23px UNDER it.
   Same defect as the setup loop's `top: 68px`, same page. mobile-shell.md
   Rule 9; the literal is only the pre-paint fallback. */
.feed-quick-row { grid-column: 1 / -1; min-width: 0; max-width: 100%; scroll-margin-top: calc(var(--dz-top-h, 65px) + 19px); }
/* Search's .rail is a horizontal scroller. Its panel is a sibling of
   .rail-wrap (not a rail item), so it never becomes a giant swipeable card
   or extends the document sideways. */
.feed-quick-row--rail { width: 100%; margin-top: 14px; }
.feed-quick {
    position: relative; background: #fff; border: 1px solid #1A1A1A;
    display: grid; grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 900px) {
    .feed-quick { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); }
}
.feed-quick__close { position: absolute; top: 4px; right: 4px; z-index: 2; }
.feed-quick__close:hover { background: #1A1A1A; color: #fff; border-color: #1A1A1A; }
.feed-quick__close:focus-visible { outline: 2px solid #1A1A1A; outline-offset: 2px; }

/* The swipe strip: 3:4 frames, the next one peeking so the swipe is
   visible; native scroll-snap, no JS motion. */
.feed-quick__gallery { position: relative; background: #F5F5F5; min-width: 0; }
.feed-quick__strip {
    display: flex; gap: 2px; overflow-x: auto; overflow-y: hidden;
    scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
}
.feed-quick__strip::-webkit-scrollbar { display: none; }
.feed-quick__frame {
    flex: 0 0 66%; margin: 0; aspect-ratio: 3 / 4; scroll-snap-align: start;
    background: #F5F5F5;
}
.feed-quick__frame:only-child { flex-basis: 100%; }
/* Wide: a frame at 58% of the gallery column keeps the strip inside one
   screen (a 3:4 frame at 78% ran taller than the viewport at 1280). */
@media (min-width: 900px) { .feed-quick__frame { flex-basis: 58%; } }
.feed-quick__frame img { width: 100%; height: 100%; object-fit: cover; display: block; }
.feed-quick__count {
    position: absolute; left: 10px; bottom: 10px;
    background: rgba(255,255,255,.95); border: 1px solid #E8E8E8;
    padding: 3px 7px; font: 600 10px/1 var(--sans); letter-spacing: 1.2px;
    color: #1A1A1A; font-feature-settings: "tnum";
}

.feed-quick__body { padding: 16px 16px 18px; min-width: 0; }
@media (min-width: 900px) { .feed-quick__body { padding: 22px 24px 24px 20px; } }
.feed-quick__brand {
    margin: 0 44px 4px 0; font: 600 10px/1.3 var(--sans); letter-spacing: 1.4px;
    text-transform: uppercase; color: #888;
}
.feed-quick__name {
    margin: 0; font: 700 17px/1.2 var(--sans); letter-spacing: -.2px; color: #1A1A1A;
}
.feed-quick__price {
    margin: 6px 0 0; display: flex; align-items: baseline; gap: 10px;
    font: 600 14px/1.2 var(--sans); color: #1A1A1A; font-feature-settings: "tnum";
}
.feed-quick__price s { font-weight: 500; font-size: 12px; color: #AAA; }
.feed-quick__gone {
    margin: 10px 0 0; display: inline-block; padding: 5px 9px;
    border: 1px solid #E8E8E8; font: 600 10px/1 var(--sans);
    letter-spacing: 1.2px; text-transform: uppercase; color: #888;
}
.feed-quick__line { margin: 10px 0 0; font: 500 13px/1.5 var(--sans); color: #333; }
.feed-quick__size {
    margin: 14px 0 0; padding-top: 12px; border-top: 1px solid #F0F0F0;
    font: 500 13px/1.4 var(--sans); color: #333;
}
.feed-quick__eye {
    display: inline-block; margin-right: 6px; font: 600 9px/1.2 var(--sans);
    letter-spacing: 1.4px; text-transform: uppercase; color: #888;
}
.feed-quick__size b { font-weight: 700; color: #1A1A1A; }
.feed-quick__why { color: #888; }
.feed-quick__keep {
    margin: 8px 0 0; font: 600 10px/1.3 var(--sans); letter-spacing: 1px;
    text-transform: uppercase; color: #888;
}
.feed-quick__actions {
    display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 18px 0 0;
}
.feed-quick__buy {
    display: inline-flex; align-items: center; justify-content: center;
    min-height: 44px; padding: 0 16px; background: #1A1A1A; color: #fff;
    text-decoration: none; font: 600 11px/1 var(--sans); letter-spacing: 1.2px;
    text-transform: uppercase; border: 1px solid #1A1A1A;
}
.feed-quick__buy:hover { background: #fff; color: #1A1A1A; }
.feed-quick__links { display: inline-flex; align-items: center; gap: 18px; }
.feed-quick__full,
.feed-quick__similar {
    display: inline-flex; align-items: center; min-height: 44px;
    color: #1A1A1A; text-decoration: none; font: 600 11px/1 var(--sans);
    letter-spacing: 1.2px; text-transform: uppercase;
    box-shadow: inset 0 -1px 0 #1A1A1A;
}
.feed-quick__full:hover,
.feed-quick__similar:hover { color: #888; box-shadow: inset 0 -1px 0 #888; }
.feed-quick--failed { display: block; }
.feed-quick__failure { padding: 16px; }
.feed-quick__failure p { margin: 0; padding-right: 44px; font: 500 13px/1.5 var(--sans); color: #333; }
.feed-quick__retry {
    min-height: 44px; padding: 0 16px; border: 1px solid #1A1A1A; background: #fff;
    color: #1A1A1A; font: 600 11px/1 var(--sans); letter-spacing: 1.2px;
    text-transform: uppercase; cursor: pointer;
}
.feed-quick__retry:hover { background: #1A1A1A; color: #fff; }
.feed-quick__retry:focus-visible { outline: 2px solid #1A1A1A; outline-offset: 2px; }

/* Loading: three quiet bars where the words will be; the page link is
   already live beside them. */
.feed-quick--loading { padding: 16px; display: block; }
.feed-quick__skel { display: grid; gap: 10px; margin-bottom: 16px; }
.feed-quick__skel span {
    display: block; height: 12px; border-radius: 3px;
    background: linear-gradient(100deg, #F5F5F5 35%, #EDEDED 50%, #F5F5F5 65%);
    background-size: 240% 100%; animation: feedshimmer 1.4s linear infinite;
}
.feed-quick__skel span:nth-child(1) { width: 32%; }
.feed-quick__skel span:nth-child(2) { width: 68%; height: 16px; }
.feed-quick__skel span:nth-child(3) { width: 22%; }
@keyframes feedshimmer { to { background-position: -140% 0; } }
@media (prefers-reduced-motion: reduce) {
    .feed-card-quick, .feed-quick__skel span { transition: none; animation: none; }
}

/* ──────────────────────────────────────────────────────────────────
   .dz-rail — a horizontal rail that SAYS it scrolls
   ──────────────────────────────────────────────────────────────────
   Bharat, on the Search answer rail: "the scroll right on the product
   row doesn't seem obvious". Measured on origin/main, the rail sized its
   cards at (100% - gap)/2.08, which left 2.6px of the third card at
   375px and 3.2px at 390px — two cards flush to the edge and nothing a
   thumb reads as "more". Desktop was worse: three cards ending exactly
   at the container edge.

   Two halves fix it and only the second one lives here. The first is
   geometry: each rail's own divisor now leaves the last visible card
   genuinely CUT (search.html .rail-item, search_owned_outfit.css
   .owned-look-card). The second is this fade — the web twin of the
   native decision in
   docs/decisions/2026-09-09-den1890-a-chip-never-truncates-the-rail-scrolls.md:
   a 24px fade on the edge that has more behind it, gone once you reach
   that end. No arrows, no separate indicator — the peeking neighbour is
   the affordance and the fade is what makes the peek read as motion.

   A MASK, never a background gradient (the precedent is search.html's
   .refine): the rail scrolls its own children, so a painted overlay
   would have to be a sibling positioned over it, and a background sits
   BEHIND the cards where nothing can see it.

   The two states are attributes published by dz_rail.js — one shared
   evaluator for every rail on every page, which Search's updateRail
   calls rather than copying:
     - neither attribute      -> at the start, more to the right
     - [data-dz-rail-start]   -> scrolled away from the left edge
     - [data-dz-rail-end]     -> nothing more to the right (INCLUDING a
                                 rail with no overflow at all, which is
                                 why "no attributes" cannot be the
                                 no-fade state)
   ────────────────────────────────────────────────────────────────── */
.dz-rail { min-width: 0; }
/* More to the right: fade the right edge. */
.dz-rail:not([data-dz-rail-end]) {
    -webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent 100%);
    mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent 100%);
}
/* Scrolled away from the start, with more still to the right: both edges. */
.dz-rail[data-dz-rail-start]:not([data-dz-rail-end]) {
    -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
    mask-image: linear-gradient(90deg, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
}
/* At the far end: only the left edge still hides anything. */
.dz-rail[data-dz-rail-start][data-dz-rail-end] {
    -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 24px, #000 100%);
    mask-image: linear-gradient(90deg, transparent 0, #000 24px, #000 100%);
}
