/* ————— Sales deck (Headway proposal decks) —————
   Self-hosted brand fonts + the handful of styles Tailwind utilities can't
   express cleanly. Everything except @font-face is scoped under `.sales-deck`
   (the 1440×810 slide canvas) so nothing leaks into app UI.
   Theme tokens live in app/assets/tailwind/application.css (@theme deck-*). */

@font-face {
  font-family: "Poppins";
  src: url("/assets/sales_deck/poppins-400-latin-f3f0f5ee.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Poppins";
  src: url("/assets/sales_deck/poppins-500-latin-ce788af2.woff2") format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Poppins";
  src: url("/assets/sales_deck/poppins-600-latin-1f3eca1a.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "DM Sans";
  src: url("/assets/sales_deck/dm-sans-vf-latin-97b33bb4.woff2") format("woff2");
  font-weight: 100 1000;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Lora";
  src: url("/assets/sales_deck/lora-vf-latin-3476d662.woff2") format("woff2");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Lora";
  src: url("/assets/sales_deck/lora-vf-italic-latin-9d30d990.woff2") format("woff2");
  font-weight: 400 700;
  font-style: italic;
  font-display: swap;
}

/* Static instances of the two variable fonts above (fonttools varLib.instancer,
   DM Sans pinned at opsz=14), used ONLY by the PDF export page: Chrome's PDF
   backend (Skia) can't embed a variable-font instance as a real font program,
   so every distinct opsz/wght combination becomes its own Type 3 font whose
   glyphs are inline drawing procedures — dozens per deck, and the slowest text
   path in Chromium's PDF viewer. These statics embed as CID TrueType, like
   Poppins. Screen keeps the variable files (optical sizing). */
@font-face {
  font-family: "DM Sans Print";
  src: url("/assets/sales_deck/dm-sans-400-latin-0e93d810.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "DM Sans Print";
  src: url("/assets/sales_deck/dm-sans-500-latin-ac5133fc.woff2") format("woff2");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "DM Sans Print";
  src: url("/assets/sales_deck/dm-sans-600-latin-9d68a0d6.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "DM Sans Print";
  src: url("/assets/sales_deck/dm-sans-700-latin-03611a60.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Lora Print";
  src: url("/assets/sales_deck/lora-400-latin-b6e892ed.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Lora Print";
  src: url("/assets/sales_deck/lora-600-latin-0e22c857.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Lora Print";
  src: url("/assets/sales_deck/lora-400-italic-latin-84688e7e.woff2") format("woff2");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

/* ————— Slide canvas ————— */
/* Fixed 1440×810 design surface, scaled to the viewport by --deck-scale
   (set by the sales-deck Stimulus controller on its container). */
.deck-slide-frame {
  position: relative;
  width: 100%;
  /* Height is DERIVED from the (definite) column width via the design aspect
     ratio, and the inner canvas scales to fill it in pure CSS (see below) — the
     whole box is sized by layout alone, with NO dependency on JS or the server's
     --deck-scale guess. That matters: the old code tied both the frame height
     (height: calc(810px * var(--deck-scale))) AND the canvas transform to a
     JS-measured scale, so the thumbnail rendered at the server's placeholder
     scale, then "popped" to full size once Stimulus connected and refined it —
     up to ~1-2s later on this ~550KB deck. Deriving everything from the column
     width means first paint is already correct. */
  aspect-ratio: 1440 / 810;
  /* Query container for the canvas scale below (100cqw = this frame's width). */
  container-type: inline-size;
  overflow: hidden;
  /* A 46-slide deck ships ~550KB of DOM (the story-map slide alone is ~150KB).
     content-visibility lets the browser skip layout/paint for off-screen
     slides — only the ~visible ones cost anything. The aspect-ratio height above
     (definite width → definite height) keeps the box and the scrollbar stable
     while the contents are skipped, so no contain-intrinsic-size is needed. The
     canvas still renders as it scrolls near the viewport, and present mode moves
     it out entirely. */
  content-visibility: auto;
}
/* ————— Deck palette: role layer —————
   Every deck color token is re-pointed at a `--deck-*` ROLE, which
   Decks::Palette stamps as an inline style on this same element (see
   SalesDeckHelper#deck_palette_style). The fallback beside each one is the
   shipped Headway value, so a deck with no palette stamped — a frozen slide
   served from an old bake, a fragment rendered outside the helper — paints
   exactly as it always did.

   THIS BLOCK MUST BE DECLARED ON `.sales-deck`, NEVER ON `:root`. A custom
   property substitutes its var()s at the point it is DECLARED: from `:root` it
   would resolve against `:root`'s (unset) `--deck-*`, take the fallback, and
   inherit down already-substituted — so every stamped palette would be a silent
   no-op with no error and perfectly correct-looking CSS.

   Only the deck's own `--color-deck-*` namespace is aliased here. Overriding a
   generic token such as `--color-white` would need to outrank
   `html[data-theme="dark"] [data-theme-static]` (0,2,1), which a bare
   `.sales-deck` (0,1,0) loses to — see theme.css.

   [data-deck-themed] is the same bridge for editor chrome that CONFIGURES a deck
   and should wear its palette — the timeline settings panel, which sits in the
   row wrapper OUTSIDE the canvas. It stamps its own --deck-* the same way, so
   the "declare it where the palette is stamped" rule above still holds; without
   the alias its bg-deck-menu resolved against an unset --deck-menu and the panel
   stayed black on a light deck. */
.sales-deck,
[data-deck-themed] {
  --color-deck-bg: var(--deck-ground, #000000);
  --color-deck-ink: var(--deck-ink, #ffffff);
  --color-deck-muted: var(--deck-muted, #969696);
  --color-deck-gray: var(--deck-gray, #8a8a8a);
  --color-deck-faint: var(--deck-faint, #585858);

  --color-deck-teal: var(--deck-accent-a, #31aab7);
  --color-deck-lime: var(--deck-highlight, #b8eb86);
  --color-deck-lime-text: var(--deck-highlight-text, #b8eb86);
  --color-deck-grad-from: var(--deck-accent-a, #31aab7);
  --color-deck-grad-to: var(--deck-accent-b, #d7d86b);

  /* No `--color-deck-page` alias: the token it bridged to is gone from the
     @theme block, so this half alone could never make a utility resolve. The
     `--deck-page` palette variable it read is untouched and still stamped. */
  --color-deck-rail: var(--deck-rail, #0a0b0d);
  --color-deck-rail-from: var(--deck-rail-from, #14161a);
  --color-deck-panel: var(--deck-surface, #0e0f10);
  --color-deck-menu: var(--deck-menu, #15161a);
  --color-deck-chip: var(--deck-chip, #232529);
  --color-deck-chip-strong: var(--deck-chip-strong, #3a3a3a);
  --color-deck-track: var(--deck-track, #444444);
  --color-deck-texture: var(--deck-texture, #141414);
  --color-deck-card: var(--deck-card, #ffffff);
  --color-deck-card-ink: var(--deck-card-ink, #101114);
  --color-deck-card-muted: var(--deck-card-muted, #6b6b6d);
  /* Mats are pinned light in every palette — they hold artwork drawn for a
     light ground (client logos, product screenshots, the MoSCoW key). */
  --color-deck-mat: var(--deck-mat, #ffffff);
  --color-deck-mat-ink: var(--deck-mat-ink, #101114);
  --color-deck-mat-muted: var(--deck-mat-muted, #5b6167);

  --color-deck-edge: var(--deck-edge, #26282c);
  --color-deck-edge-strong: var(--deck-edge-strong, #43474e);
  --color-deck-edge-soft: var(--deck-edge-soft, #3a3e44);
  /* The one edge held to 3:1 — editor CONTROLS are drawn entirely by their
     border, so the whisper `edge` leaves them with no visible boundary. Slide
     artwork keeps `edge`; this is chrome only. */
  --color-deck-edge-control: var(--deck-edge-control, #45484f);
  --color-deck-divider: var(--deck-divider, #4d4d4d);

  /* Data colors — priority, ownership, ceremony. Separate from the brand
     roles above because collapsing them into an accent would stop the slide
     saying WHICH priority or WHO owns the work. The *-text roles are
     contrast-picked against their own fill in Ruby, which is what repaired
     the shipped mo-wont pair (#3c3c3c on #7d7f82, about 1.9:1). */
  --color-deck-mo-area: var(--deck-mo-area, #b8d5ff);
  --color-deck-mo-flow: var(--deck-mo-flow, #ffd37c);
  --color-deck-mo-must: var(--deck-mo-must, #d9ffd7);
  --color-deck-mo-should: var(--deck-mo-should, #ffcb7c);
  --color-deck-mo-could: var(--deck-mo-could, #ff7c7c);
  --color-deck-mo-wont: var(--deck-mo-wont, #3c3c3c);
  --color-deck-mo-must-text: var(--deck-mo-must-text, #257a22);
  --color-deck-mo-should-text: var(--deck-mo-should-text, #5f4a2b);
  --color-deck-mo-could-text: var(--deck-mo-could-text, #701516);
  --color-deck-mo-wont-text: var(--deck-mo-wont-text, #acadaf);
  --color-deck-state-headway: var(--deck-state-headway, #9edd7f);
  --color-deck-state-shared: var(--deck-state-shared, #f0c46c);
  --color-deck-state-none: var(--deck-state-none, #75797f);
  --color-deck-sprint-plan: var(--deck-sprint-plan, #d9ffd7);
  --color-deck-sprint-plan-text: var(--deck-sprint-plan-text, #165513);
  --color-deck-sprint-standup: var(--deck-sprint-standup, #e6fdff);
  --color-deck-sprint-standup-text: var(--deck-sprint-standup-text, #25737c);
  --color-deck-sprint-share: var(--deck-sprint-share, #f2c0ff);
  --color-deck-sprint-share-text: var(--deck-sprint-share-text, #9323a6);
}

/* The CANVAS geometry, kept apart from the token aliasing above because the two
   have different audiences: anything wearing the deck's palette wants the
   tokens, but only the slide canvas itself is a 1440×810 absolutely-positioned
   stage. They were one block, and the moment [data-deck-themed] joined the
   selector the timeline settings panel inherited the whole canvas box and
   covered the deck. */
.sales-deck {
  position: absolute;
  top: 0;
  left: 0;
  width: 1440px;
  height: 810px;
  /* Present stage, public viewer, and the template-preview card each set an
     explicit --deck-scale on an ancestor; the editor frames override this with a
     pure-CSS scale (below). */
  transform: scale(var(--deck-scale, 1));
  transform-origin: top left;
  overflow: hidden;
}
/* Editor overview/scroll thumbnails: scale the 1440px canvas to exactly fill its
   frame with NO JS. CSS can't divide length/length, but atan2(100cqw, 1440px)
   is the angle whose tangent is 100cqw/1440px, so tan(atan2(…)) recovers the
   unitless ratio frameWidth ÷ 1440 — the same value JS would compute, applied
   before first paint and with no ResizeObserver settling. (JS still maintains
   --deck-scale for pointer-delta math while dragging images/titles.) */
.deck-slide-frame .sales-deck {
  transform: scale(tan(atan2(100cqw, 1440px)));
}
/* WebKit miscomputes the trig expression above (container units inside atan2
   resolve wrong — the ~0.25 ratio comes out ~5.6×), leaving every frame
   showing just the blown-up empty corner of its slide: blank thumbnails on
   iPhone/iPad. sales-deck#_verifyCssScale probes the first frame after layout
   and, on mismatch, adds .deck-scale-js to fall back to the JS-maintained
   scale (per-frame --deck-scale stamped by _stampFrameScales). Correct
   engines never get the class and keep the zero-flash pure-CSS path. */
.deck-scale-js .deck-slide-frame .sales-deck {
  transform: scale(var(--deck-scale, 1));
}

/* ————— Brand gradient helpers ————— */
.sales-deck .deck-grad {
  background-image: linear-gradient(92deg, var(--color-deck-grad-from), var(--color-deck-grad-to));
}
/* Figma's text gradients put the yellow stop far past the glyphs (the gradient
   line is ~3× the text box, yellow at 76% of it), so text only samples the
   teal→sea-green start of the ramp. Overshooting the end stop to 210% (with a
   slight downward tilt like the Figma line) reproduces that soft drift —
   a 0–100% ramp reads far too yellow at the right end. */
.sales-deck .deck-grad-text {
  background-image: linear-gradient(97deg, var(--color-deck-grad-from), var(--color-deck-grad-to) 210%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* background-clip:text clips the gradient to the element box, so a tight
     line-height shears descender tails (g, y, p, j) on the last line. A hair of
     bottom padding gives them room. NB this sheet is unlayered, so it overrides
     any Tailwind bottom-padding utility on a gradient heading — set bottom
     padding inline if a heading ever needs a different value. */
  padding-bottom: 0.14em;
}
/* Inline gradient text — a word/phrase painted with the brand gradient from the
   selection toolbar. Same ramp as .deck-grad-text but NO heading descender pad,
   since it sits mid-line inside a larger text run. */
.sales-deck .deck-grad-inline {
  background-image: linear-gradient(97deg, var(--color-deck-grad-from), var(--color-deck-grad-to) 210%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
/* Underline/strike must match the colored text they decorate. execCommand
   nests <u>/<s> OUTSIDE the color span as often as inside, and a decoration
   paints in the color of the element that DECLARES it — so <u><span orange>
   drew a white line under orange text. The colored span re-draws the same
   line(s) in its own currentColor, painting over the ancestor's. Gradient
   spans are excluded: their currentColor is transparent (clipped-gradient
   trick) and would erase the line instead. The both-lines rules win by
   specificity — text-decoration-line doesn't accumulate across rules. */
.sales-deck u span[style*="color"]:not(.deck-grad-inline) {
  text-decoration: underline;
}
.sales-deck s span[style*="color"]:not(.deck-grad-inline) {
  text-decoration: line-through;
}
.sales-deck u s span[style*="color"]:not(.deck-grad-inline),
.sales-deck s u span[style*="color"]:not(.deck-grad-inline) {
  text-decoration: underline line-through;
}
/* Pin decoration geometry so the ancestor's line and the colored span's
   re-draw land on the SAME pixels even when a <b> sits between them — the
   bold font carries different underline metrics, so with u's line drawn at
   regular metrics and the span's at bold metrics you saw BOTH lines
   (order-dependent: underline-then-bold produced <u><b><span>). em units
   resolve identically for both since font-size doesn't change with weight. */
.sales-deck :is(u, s),
.sales-deck :is(u, s) span[style*="color"]:not(.deck-grad-inline) {
  text-underline-offset: 0.11em;
  text-decoration-thickness: 0.07em;
}
/* The range being colored while the picker is open with the keyboard
   released — a blurred editable paints no native selection, so the deck
   marks it via the CSS Custom Highlight API (_markPendingSelection).
   Matches the iOS selection blue closely enough to read as "still
   selected". */
::highlight(deck-pending-selection) {
  background-color: rgba(59, 130, 246, 0.4);
}
/* 1.5px gradient border drawn in the padding box (why-card, constraint card,
   guarantee panel). Apply to a `relative rounded-*` element. */
.sales-deck .deck-grad-border::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1.5px;
  background: linear-gradient(92deg, var(--color-deck-grad-from), var(--color-deck-grad-to));
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}

/* ————— Topography texture ————— */
/* Contour lines anchored to the bottom of dark slides, fading out upward.
   topography-fill.svg is a black FILL of the landmass regions — the contour
   lines are the gaps between fills. Painting it over a faint light underlay
   leaves only thin light lines (canonical peak line luminance ≈ 19/255),
   with a mask so nothing shows in the upper half of the slide. */
.sales-deck .deck-topo-bottom {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    url("/assets/sales_deck/topography-fill-e9f95553.svg") left calc(50% - 40px) bottom -60px / 1160px auto repeat,
    var(--color-deck-texture);
  -webkit-mask-image: linear-gradient(180deg, transparent 54%, #000 82%);
  mask-image: linear-gradient(180deg, transparent 54%, #000 82%);
}
/* Caption chrome on a LIGHT page. The slide captions were drawn against a
   near-black editor page — greys read, but the two accents do not: the teal
   "AI-generated" pill lands at 2.3:1 on grey, and every `hover:text-white`
   resolves to white-on-grey, so hovering a control makes it VANISH. Scoped to
   the page polarity rather than re-classed per control, because the controls are
   correct on the dark page they were designed for. */
[data-deck-page="light"] [data-slide-caption] .text-deck-teal,
[data-deck-page="light"] [data-slide-caption] .border-deck-teal\/40 {
  color: color-mix(in srgb, var(--deck-accent-a, #31aab7) 78%, #000);
}
[data-deck-page="light"] [data-slide-caption] .hover\:text-white:hover,
[data-deck-page="light"] [data-slide-caption] .hover\:text-gray-300:hover {
  color: var(--deck-ink, #101114);
}
/* Caption toggles spell their ACTIVE state as literal `text-white` (the SVG
   title toggle, the full-bleed scroll toggle) — the "on" state was therefore the
   invisible one on a light page, which is precisely backwards. Overriding a base
   color utility is normally forbidden here because unlayered CSS beats
   Tailwind's layered `hover:` variants; it is safe in this narrow scope because
   no caption element pairs `text-white` with a hover text color (checked). */
[data-deck-page="light"] [data-slide-caption] .text-white {
  color: var(--deck-ink, #101114);
}
[data-deck-page="light"] [data-slide-annotation] {
  color: color-mix(in srgb, var(--deck-ink, #101114) 62%, transparent);
}

/* The slide's frame in the editor and the overview.
   LIGHT PAGE ONLY, and the shadow is always cast in BLACK. Keying it to
   --deck-ink looked symmetrical but is wrong physics: a shadow is the ABSENCE of
   light, so on the dark deck (ink = white) it inverted into a pale halo ringing
   every slide. On a dark page no shadow is drawn at all — a black shadow on a
   black page is invisible anyway, and the frame's own `ring-1 ring-white/10`
   already separates the slide there. Leaving box-shadow unset on dark matters
   for a second reason: Tailwind composes `ring-*` INTO box-shadow, so setting it
   here unconditionally silently deleted that ring. */
[data-deck-page="light"] .deck-slide-frame {
  box-shadow:
    /* the ring the dark page gets from ring-white/10, re-drawn so it reads on light */
    0 0 0 1px rgb(0 0 0 / 0.08),
    0 1px 2px rgb(0 0 0 / 0.10),
    0 10px 28px -10px rgb(0 0 0 / 0.22);
}

/* The timeline settings panel is the same card material as a slide (bg-deck-bg)
   and sits on the same surround, so it lifts off it the same way — softer,
   because it is chrome rather than artwork. It needs the lift MORE than a slide
   does: every palette derives `page` and `menu` to within 1.04:1 of each other,
   so the panel had no edge at all against the page it sits on, and only ever
   looked right while the whole editor was dark. */
[data-deck-page="light"] [data-timeline-panel] {
  box-shadow:
    0 0 0 1px rgb(0 0 0 / 0.06),
    0 1px 2px rgb(0 0 0 / 0.08);
}

/* topography-fill.svg paints the LANDMASSES black; the contour lines are the
   GAPS, which show the texture underneath. That only works over a dark ground —
   on a light one the landmasses become black slabs (the Why-Headway slide went
   half black). The artwork has no color token to turn, so the element inverts
   instead: black fill → white (invisible on a light slide) and the dark texture
   → a pale wash, giving the same faint contour lines the dark deck has. */
.sales-deck[data-deck-ground="light"] .deck-topo-bottom {
  filter: invert(1);
}

/* The PDF export page swaps this whole element for a pre-composited raster
   (topography-print.png — tiles + #141414 + the fade baked into its alpha):
   printed as-is, the full-slide mask-image becomes a page-covering isolated
   transparency group that PDF viewers composite per-pixel, and the SVG's
   contour paths land as vector ops — together the slowest page in the deck.
   The override lives in export.html.erb, NOT here under @media print: a
   background referenced only in print media is never fetched on screen, so
   the print snapshot would paint without it. */

/* ————— Client-logo app-icon mask (cover + Make Waves) ————— */
/* Toggled deck-wide by the cover caption's "round the logo" control. The radius
   must be a LENGTH, never a percentage: a percentage resolves against the box's
   width horizontally and its height vertically, so a wordmark-shaped logo gets
   elliptical corners (a square one only looks right because the two agree). The
   logo's height is pinned by its wrapper (58px cover / 61px Make Waves), so 13px
   is the ~22% app-icon ratio; on a logo narrower than 26px CSS scales every
   radius by one common factor, leaving the corners circular at any aspect ratio.

   `.rounded-\[22\%\]` is the percentage utility frozen into the HTML of decks
   published before this fix. It shares the rule so those bakes round correctly
   too — Tailwind no longer emits that class, so nothing else claims it. */
.sales-deck .deck-logo-mask,
.sales-deck .rounded-\[22\%\] {
  border-radius: 13px;
}

/* ————— Editable-zone affordance ————— */
/* Preview of the text zones the deck editor will bind to Lexxy: dashed brand
   outline + pill on hover. Purely visual until the editor task lands. */
/* This sheet is UNLAYERED, so it beats Tailwind's @layer utilities regardless
   of specificity (:where() doesn't help) — absolutely-positioned zones (e.g.
   the lean cycle sub-list) must be excluded explicitly or position:relative
   stretches them full-width. */
.sales-deck .deck-zone:not(.absolute) {
  position: relative;
}
.sales-deck .deck-zone {
  border-radius: 8px;
  /* Gradient headings set color: transparent (background-clip: text), which
     would make the text caret invisible while editing — pin it to the brand
     lime so it reads on every zone. */
  caret-color: #b8eb86;
}
.sales-deck .deck-zone::before {
  content: "";
  position: absolute;
  inset: -8px -12px;
  border-radius: 10px;
  border: 1.5px dashed transparent;
  transition: border-color 0.15s ease;
  pointer-events: none;
}
/* Edit affordances are OPT-IN via .deck-editing (the authoring page).
   Display surfaces — present stage, public viewer, any future export —
   never set it, so frozen HTML keeping the deck-zone class stays inert. */
.deck-editing .sales-deck .deck-zone:hover::before {
  border-color: rgba(184, 235, 134, 0.55);
}
/* While editing: no native focus ring (it draws its own mismatched box) —
   the dashed outline just brightens instead, keeping one consistent shape. */
.sales-deck .deck-zone:focus,
.sales-deck .deck-zone:focus-within {
  outline: none;
}
.deck-editing .sales-deck .deck-zone:focus::before,
.deck-editing .sales-deck .deck-zone:focus-within::before {
  border-color: rgba(184, 235, 134, 0.85);
}
/* No-allocation markers: calm gray for clients; red in the editor
   (scroll + overview) so an unstaffed week can't slip out unnoticed. */
.deck-editing .sales-deck .deck-noalloc {
  border-color: rgba(239, 68, 68, 0.55);
  color: rgba(239, 68, 68, 0.85);
}

/* Post-Escape: stay quiet under the still-hovering pointer until it leaves */
.deck-editing .sales-deck .deck-zone.deck-zone-quiet::before {
  border-color: transparent;
}

/* ————— Overview / scroll view modes ————— */
/* Slides live inside per-category .deck-section-slides lists (drag targets).
   Overview: each category lays out as an auto-fill thumbnail grid.
   Scroll: full-width frames. */
.deck-view-overview .deck-section-slides {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(236px, 1fr));
  gap: 24px 20px;
  align-items: start;
}
.deck-view-overview .deck-section-slides [data-slide-frame-key] {
  margin-bottom: 0;
  cursor: pointer;
  /* Keyboard nav (G/hjkl) to the LAST tile scrolls it flush to the bottom edge,
     clipping its ring + caption. scroll-margin-bottom gives breathing room there,
     mirroring the frame's scroll-mt-[64px] top clearance. Only block:"nearest"/"end"
     scrolls honor it, so the scroll view's top-aligned jumps are unaffected. */
  scroll-margin-bottom: 40px;
}

/* Overview is display-only, but the fields keep their canonical
   contenteditable="true" — flipping it to "false" on every view change was a
   hundreds-of-nodes forced-reflow storm (on load AND each switch). Editing is
   blocked HERE instead: user-modify makes a contenteditable node non-typable,
   and pointer-events:none lets a click pass straight through to the tile's
   click-to-open (so there's no caret, no focus-claim, and no text selection to
   summon the inline formatting bar). The editFocusIn / _eligibleInlineRange view
   guards cover the keyboard-tab edge. Scroll view sets none of this, so editing
   works there exactly as before. The caption name keeps pointer-events (it
   renames through its own popover) and is only made non-typable. */
.deck-view-overview .sales-deck [contenteditable] {
  -webkit-user-modify: read-only;
  -moz-user-modify: read-only;
  user-modify: read-only;
  pointer-events: none;
}
.deck-view-overview [data-slide-name] {
  -webkit-user-modify: read-only;
  -moz-user-modify: read-only;
  user-modify: read-only;
}

/* ————— Keyboard highlight cursor (hjkl in the overview grid + import modal) —————
   One class, .deck-nav-cursor, rings whatever the keyboard has highlighted: a slide
   tile in the overview, or a source-deck button (step 1) / slide thumbnail (step 2)
   in the import modal. The outline follows each element's own corner radius.
   NB: scope the overview ring to grid frames ([data-slide-frame-key]) — the import
   modal renders INSIDE the .deck-view-overview page and reuses .deck-slide-frame, so
   an unscoped rule would double-ring import thumbs on top of their box-shadow below. */
.deck-view-overview [data-slide-frame-key].deck-nav-cursor .deck-slide-frame {
  outline: 3px solid var(--color-deck-teal);
  outline-offset: 4px;
}
[data-sales-deck-source-param].deck-nav-cursor {
  border-color: var(--color-deck-teal);
  background: var(--color-deck-chip);
}
/* Header project switcher: ↑/↓ inside the search box highlights a row. */
[data-deck-project-option].deck-nav-cursor {
  background: var(--color-deck-chip);
  color: var(--color-deck-ink);
}
.deck-import-thumb.deck-nav-cursor .deck-slide-frame {
  box-shadow: 0 0 0 2px var(--color-deck-teal);
}
/* When the keyboard scrolls the first/last item into view (G/gg/hjkl), leave a
   buffer so its highlight ring isn't jammed against — or clipped by — the modal's
   header/footer edges. scroll-padding insets scrollIntoView's alignment on both
   the deck-list (step 1) and thumbnail (step 2) scroll containers. Extra top inset
   (> the intro + section-label height above the first tile) so jumping to the first
   item scrolls fully to the top instead of clipping the intro's first line. */
[data-deck-import-slide-target="importDeckStep"],
[data-deck-import-slide-target="importSlideStep"] {
  scroll-padding-top: 5.5rem;
  scroll-padding-bottom: 2rem;
}

/* ————— Import-from-another-deck browser (modal thumbnails) —————
   Each source-deck slide renders as an inert, auto-scaled thumbnail (reusing
   .deck-slide-frame's container-query scale). Hover reveals Link / Copy pills
   that MARK the slide's import mode (data-import-mode); a persistent mode-colored
   ring + corner badge show the choice, and the footer's Import button commits
   each slide by its own mark. Mode accents: link = teal, copy = slate. */
.deck-import-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  gap: 18px 16px;
  align-items: start;
}
.deck-import-thumb {
  position: relative;
}
/* Hover preview ring — only while UNMARKED (a mark shows its own colored ring). */
.deck-import-thumb:not([data-import-mode]):hover .deck-slide-frame {
  box-shadow: 0 0 0 2px var(--color-deck-teal);
}
/* Mark overlay: the Link / Copy pills, revealed on hover / keyboard focus. The
   frame is pointer-events:none, so these buttons receive the clicks. */
.deck-import-actions {
  position: absolute;
  inset: 0;
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: center;
  border-radius: 0.5rem;
  background: rgba(6, 8, 10, 0.72);
  opacity: 0;
  transition: opacity 0.12s;
}
/* Reveal on hover, and on KEYBOARD focus only (:focus-visible) — a mouse click
   leaves plain :focus on the pill, which would otherwise pin the overlay open
   until you clicked away. :focus-visible excludes mouse focus, so clicking a mark
   and moving off hides the pills immediately; the badge already shows the mark. */
.deck-import-thumb:hover .deck-import-actions,
.deck-import-thumb:has(.deck-import-btn:focus-visible) .deck-import-actions {
  opacity: 1;
}
/* Keyboard highlight reveals the Link / Copy pills too — so hjkl/arrow selection
   shows the same affordances as hover. (Its own rule for readability; the reveal
   fades in via the .deck-import-actions opacity transition above.) */
.deck-import-thumb.deck-nav-cursor .deck-import-actions {
  opacity: 1;
}
/* An already-marked card dims less, so its thumbnail + ring + badge stay readable
   on a re-hover to switch/unmark. The opaque pills still read over the scrim. */
.deck-import-thumb[data-import-mode] .deck-import-actions {
  background: rgba(6, 8, 10, 0.4);
}
/* A committed mark: mode-colored outline on the frame. */
.deck-import-thumb[data-import-mode="link"] .deck-slide-frame {
  outline: 2.5px solid #31aab7;
  outline-offset: 3px;
}
.deck-import-thumb[data-import-mode="copy"] .deck-slide-frame {
  outline: 2.5px solid #8a929e;
  outline-offset: 3px;
}
/* Corner badge — hidden until marked, then shows the mode's icon on its accent. */
.deck-import-badge {
  display: none;
}
.deck-import-thumb[data-import-mode] .deck-import-badge {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 40;
  display: grid;
  place-items: center;
  height: 22px;
  width: 22px;
  border-radius: 9999px;
  font-size: 11px;
}
.deck-import-thumb[data-import-mode="link"] .deck-import-badge {
  background: #31aab7;
}
.deck-import-thumb[data-import-mode="copy"] .deck-import-badge {
  background: #8a929e;
}
.deck-import-thumb[data-import-mode="link"] .deck-import-badge::after {
  content: "🔗";
}
.deck-import-thumb[data-import-mode="copy"] .deck-import-badge::after {
  content: "📄";
}
/* The pill matching the slide's mark reads as "pressed" (white ring) on hover. */
.deck-import-btn[aria-pressed="true"] {
  box-shadow: 0 0 0 2px #fff, 0 6px 18px rgba(0, 0, 0, 0.55);
}
/* Link / Copy mark pills + the footer Import button. Fully opaque + a drop
   shadow so nothing behind bleeds through and the pill lifts off the surface. */
.deck-import-btn {
  cursor: pointer;
  border-radius: 9999px;
  padding: 6px 14px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.02em;
  white-space: nowrap;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55);
}
.deck-import-btn-link {
  background: var(--color-deck-teal);
  color: #06251f;
}
.deck-import-btn-copy {
  background: #2b2f36;
  color: #f3f4f6;
  border: 1px solid #454b54;
}
.deck-import-btn-import {
  background: var(--color-deck-teal);
  color: #06251f;
}
.deck-import-btn:hover {
  filter: brightness(1.08);
}
.deck-import-btn:disabled {
  opacity: 0.5;
  cursor: default;
}
/* Overview: slide name reads as an annotation under the thumbnail; the top
   row keeps handle + number + controls. */
.deck-view-overview [data-slide-name] {
  display: none;
}
/* Overview: slide-type-specific controls (e.g. the Constraint Models picker)
   are too wide for a ~250px thumbnail column — they'd overflow and shove the
   toolbar icons out of alignment with neighboring slides. Hidden here; the
   picker stays available in the full-width scroll view. */
.deck-view-overview [data-slide-special-controls] {
  display: none;
}
/* Overview: shrink the AI pill to just "✨ AI" — drop both "-generated" and the
   "· edited" suffix. The caption row is all shrink-0 items here (the flexible
   slide-name is display:none), so any extra pill width overflows the ~236px
   thumbnail column and shoves the toolbar icons past the next slide. The tooltip
   still explains, and the full "AI-generated · edited" wording stays in the
   scroll view. */
.deck-view-overview [data-ai-generated-suffix],
.deck-view-overview [data-ai-edited-suffix] {
  display: none;
}
/* Overview: an AI slide that's generating renders its spinner INSIDE the scaled
   canvas, so at a ~236px thumbnail (--deck-scale ≈ 0.18) the 28px spinner lands
   at ~6px — invisible exactly when it matters most, since the header's ✨ starts
   several slides at once from this view. Counter-scale the block by 1/--deck-scale
   so it draws at its true size (the transform is centered, and the block is
   already centered in the canvas, so it stays put). The label goes: at true size
   its ~30 letter-spaced characters are far wider than the column, and the caption's
   ✨ AI pill already says what kind of slide this is. Scroll view keeps both. */
.deck-view-overview [data-ai-generating] {
  transform: scale(calc(1 / var(--deck-scale, 1)));
}
.deck-view-overview [data-ai-generating] p {
  display: none;
}
/* Overview: collapse the linked-slide chip and its Unlink button to just their
   icons (🔗 / ✂️) — same overflow reason as the AI pill above. In a ~236px
   thumbnail their words shove the toolbar icons out of alignment; the tooltips
   still explain, and the scroll view keeps the full "Live · project" / "Unlink"
   wording. */
.deck-view-overview [data-link-label],
.deck-view-overview [data-unlink-label] {
  display: none;
}
/* Overview: the timeline settings panel disappears — scroll-view editing
   chrome; a ~236px thumbnail column has no room for it. (The Preview menu
   trigger is already just a play glyph everywhere.) */
.deck-view-overview [data-timeline-panel] {
  display: none;
}
/* Overview: tighten the caption's left cluster (drag handle · number · link chip).
   The wide-view gap-3 is loose in a ~236px thumbnail column and, with the extra
   chips, was shoving the toolbar icons out of alignment with neighboring slides.
   Right-side controls keep their own spacing (ml-auto + their own gap). */
.deck-view-overview [data-slide-caption] {
  gap: 0.375rem;
}
.deck-view-overview [data-slide-annotation] {
  display: block;
  cursor: text;
}
/* Scroll view: no category chrome (headers stay for grouping; adding and
   reordering categories happen in the overview). */
.deck-view-scroll .deck-category-control {
  display: none;
}
/* Scroll view: no category headers (sections stay in the DOM for grouping). */
.deck-view-scroll .deck-section > .group\/section {
  display: none;
}
.deck-view-scroll .deck-section {
  margin-top: 0;
}

/* Category headers read as labeled dividers — the name sits in a bordered
   chip and a hairline rule fills the rest of the row, so groups separate at
   a glance in the phone scroll flow and the desktop overview alike. The
   chip's focus state must live here too: the unlayered background would
   otherwise beat the h2's layered focus:bg-* rename affordance. */
.deck-section > .group\/section {
  gap: 10px;
}
/* The rule runs out of the category chip, so it takes the chip's color rather
   than the whisper `edge` — at 1.1:1 the line faded out a few pixels from a
   control it visually continues, which read as a rendering artefact rather than
   as a separator. */
.deck-section > .group\/section::after {
  content: "";
  height: 1px;
  flex: 1;
  background: var(--color-deck-edge-control);
}
.deck-section > .group\/section > [data-section-name] {
  margin: 0;
  padding: 3px 12px;
  /* The category chip IS a control (click to rename), so it takes the control
     edge — and the rule line beside it now matches, so chip and rule read as
     one horizontal band. */
  border: 1px solid var(--color-deck-edge-control);
  border-radius: 9999px;
  background: var(--color-deck-menu);
  color: var(--color-deck-gray);
}
.deck-section > .group\/section > [data-section-name]:focus {
  background: var(--color-deck-chip);
  border-color: var(--color-deck-ink);
  color: var(--color-deck-ink);
}

/* Status pills in the caption row are the one bit of deck chrome painted in a
   FIXED hue rather than a palette role — "hidden" means amber whatever the deck
   looks like. A fixed hue cannot follow the polarity, and amber-400 on a light
   deck's chrome is 1.35:1: the pill was there, the word was not readable. The
   dark deck keeps the original values; light gets the same hue walked down to
   amber-700 (4.06:1) with a faint fill so it still reads as a chip rather than
   as stray text. Keyed off the body's data-deck-ground, which the canvas has
   always carried and the chrome around it now does too. */
[data-deck-ground="light"] [data-status-pill="hidden"] {
  border-color: rgba(217, 119, 6, 0.55);
  background: rgba(217, 119, 6, 0.1);
  color: #b45309;
}

/* Overview breathes between categories. */
.deck-view-overview .deck-section {
  margin-top: 64px;
}
.deck-view-overview .deck-section:first-of-type {
  margin-top: 0;
}
.deck-view-overview .deck-section .group\/section {
  margin-bottom: 20px;
}

/* ————— Bulk-select mode ————— */
/* Selection is the only affordance IN THE CANVAS: slide/category names stay
   for identification, per-slide/per-section controls disappear. The HEADER
   keeps its full control row — adding a category or slide mid-selection is
   harmless, and hiding them just made the header jump. */
.deck-bulk-mode [data-slide-controls],
.deck-bulk-mode [data-drag-handle],
.deck-bulk-mode button[data-action="deck-structure#deleteSection"] {
  display: none !important;
}
/* Frames become plain click targets; inner interactions are inert. */
.deck-bulk-mode [data-slide-frame-key] {
  cursor: pointer;
}
.deck-bulk-mode [data-slide-frame-key] :is(button, [contenteditable], [data-drag-handle], .sales-deck) {
  pointer-events: none;
}
.deck-bulk-selected .deck-slide-frame {
  outline: 2.5px solid #31aab7;
  outline-offset: 3px;
}
.deck-bulk-selected .deck-slide-frame::after {
  content: "✓";
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 40;
  display: grid;
  place-items: center;
  height: 22px;
  width: 22px;
  border-radius: 9999px;
  background: #31aab7;
  color: #04222a;
  font-size: 13px;
  font-weight: 700;
}

/* Drag styling: the in-list placeholder becomes a quiet dashed well; the
   floating clone reads as a picked-up card. */
.deck-drag-ghost {
  opacity: 1 !important;
}
.deck-drag-ghost .deck-slide-frame {
  box-shadow: none; /* drop the ring */
  outline: 1.5px dashed #3c4046;
  outline-offset: -1.5px;
  border-radius: 12px;
}
.deck-drag-ghost .deck-slide-frame .sales-deck,
.deck-drag-ghost [data-slide-annotation],
.deck-drag-ghost > div:first-child {
  visibility: hidden;
}
.deck-drag-item {
  opacity: 0.95 !important;
  transform: rotate(1.2deg) scale(0.98);
  background: transparent !important;
  /* The clone is a full slide — often a heavy SVG — that SortableJS re-transforms
     under the cursor on every pointermove. Without its own compositor layer the
     browser REPAINTS that whole SVG each frame: the reorder "jitter" you feel on
     the SVG/wheel/timeline slides. Promote it to a GPU layer so each move is a
     cheap composite, not a repaint. Universal (every heavy slide benefits) and
     safe: the clone is transient (created/destroyed per drag), so the usual "don't
     leave will-change on permanently" memory caveat doesn't apply. */
  will-change: transform;
}
.deck-drag-item > div:first-child,
.deck-drag-item [data-slide-annotation] {
  visibility: hidden;
}
.deck-drag-item .deck-slide-frame {
  box-shadow: 0 26px 70px rgba(0, 0, 0, 0.65), 0 0 0 1px #31aab7;
}

/* Generic dark drag treatment for deck list rows — the default from the
   deckSortable() factory, shared by the category + features reorder modals
   (and any future deck Sortable). The placeholder is a quiet dashed well; the
   floating clone reads as a picked-up dark card. Slide frames and white
   timeline cards override deck-*-ghost/-item with their own tuned versions. */
.deck-sort-ghost {
  opacity: 1 !important;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.04);
  outline: 1.5px dashed #3c4046;
  outline-offset: -1.5px;
}
.deck-sort-ghost > * {
  visibility: hidden; /* show only the well, not the row's content */
}
.deck-sort-item {
  opacity: 0.97 !important;
  border-radius: 8px;
  background: var(--color-deck-chip) !important;
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), 0 0 0 1px #31aab7;
}

/* Empty categories stay droppable. */
.deck-section-slides:not(:has([data-slide-frame-key])) {
  min-height: 74px;
  border: 1.5px dashed var(--color-deck-edge);
  border-radius: 12px;
}

/* MVP-features section reorder (SortableJS, forceFallback) — matches the slide
   overview: a dashed dark placeholder in the flow, a clean floating clone. */
.mvp-section-ghost {
  opacity: 1 !important;
  outline: 1.5px dashed #3c4046;
  outline-offset: 4px;
  border-radius: 8px;
}
.mvp-section-ghost > * {
  visibility: hidden;
}
.mvp-section-drag {
  opacity: 0.95 !important;
  background: transparent !important;
  cursor: grabbing;
}

/* A bullet's TIER is its data-tier attribute, and the color is derived from it
   HERE — one place. It used to be spelled twice: the partial rendered a
   text-deck-ink / text-[#767676] utility and the click handler toggled its own
   pair. They drifted (the handler still removed `text-white`, a class the
   partial had stopped rendering), so a flipped bullet kept BOTH colors and the
   stale one won — the click looked dead until a reload re-rendered it from the
   attribute. Nothing could catch that: data-tier is what persists, so the saved
   state was right every time and only the live paint was wrong.
   Attribute + class + element beats a bare utility, so this also corrects
   already-published HTML, which carries data-tier through the bake. */
.sales-deck ul[data-mvp-list] > li[data-tier="possibly"] {
  color: #767676;
}

/* MVP tier dots: a wide transparent hit zone over the left marker area with a
   pointer cursor, so the small dot is easy to click. It reaches further LEFT
   than the bullet box (cheap room — the gutter holds no text) than it does
   right, where every extra em is a character that flips the tier instead of
   taking the caret. Both numbers are em multiples read by mvp_groups#listDown,
   which must agree with what is painted here — hence one declaration each,
   inherited down to the li, rather than a constant on each side. */
.sales-deck ul[data-mvp-list] {
  --mvp-hit-x: -0.75;
  --mvp-hit-w: 1.9;
}
.sales-deck ul[data-mvp-list] > li:not(.mvp-empty)::after {
  content: "";
  position: absolute;
  left: calc(var(--mvp-hit-x) * 1em);
  top: 0;
  width: calc(var(--mvp-hit-w) * 1em);
  height: 1lh;
  cursor: pointer;
}

/* Blank bullets act as column-flow spacers on ANY deck-li-dot list: the dot is
   hidden (the line still holds its slot) until the bullet is hovered or gains
   text. Empties are marked .mvp-empty by the editor (mvp_groups#markEmpty for the
   MVP slide); a truly-empty <li> is covered by :empty for other lists too. */
.sales-deck ul.deck-li-dot > li.mvp-empty::before,
.sales-deck ul.deck-li-dot > li:empty::before {
  visibility: hidden;
}
/* …but a visible dot while the caret is in it (active), or on hover. */
.sales-deck ul.deck-li-dot > li.mvp-empty.mvp-active::before,
.sales-deck ul.deck-li-dot > li.mvp-empty:hover::before,
.sales-deck ul.deck-li-dot > li:empty:hover::before {
  visibility: visible;
}
/* The drag handle is the grip icon + the slide number — a comfortable grab
   target (the 14px grip alone was fiddly). The GRIP shows only in the overview
   grid, where reordering happens; the number shows in BOTH views. In scroll the
   handle is inert (pointer-events:none) so the always-visible number can't start
   a drag — reordering stays overview-only, as when the whole handle was hidden.
   (The handle is a flex row via its utilities; no display override here, or it
   would collapse the icon and number onto separate lines.) */
.deck-view-scroll [data-slide-frame-key] [data-drag-grip] {
  display: none;
}
.deck-view-scroll [data-slide-frame-key] [data-drag-handle] {
  pointer-events: none;
}

/* ————— Sticky header seam guard (live story map board) ————— */
/* Sticky cells become their own compositing layers; under the canvas's
   fractional scale() the layer's bottom edge resamples and lets ~1px of the
   cards scrolling beneath show through (reads as a colored underline per
   chip). A background-colored skirt overdraws the seam. */
.sales-deck .deck-sticky-seam {
  /* skirt below the layer-edge seam; no upward skirt — the activity and step
     rows are flush (44px each), and an upward shadow would square off the
     rounded bottoms of the blue activity cards in the row above */
  box-shadow: 0 2px 0 0 var(--color-deck-bg);
}

/* ————— Frozen timeline-card compatibility ————— */
/* Published versions created before the curatable week-card rewrite retain
   deck-card-editable on their inner card. No current template emits it, but its
   last rule was deleted while those immutable versions still referenced it.
   Keep this historical selector until persisted versions are migrated through
   an explicit compatibility policy (bd sales-estimates-875c). */
.sales-deck .deck-card-editable {
  position: relative;
  outline: none;
  caret-color: #166534;
  border-radius: 5px;
}
.sales-deck .deck-card-editable::before {
  content: "";
  position: absolute;
  inset: -6px;
  border-radius: 7px;
  border: 1.5px dashed transparent;
  transition: border-color 0.15s ease;
  pointer-events: none;
}
.deck-editing .sales-deck .deck-card-editable:hover::before {
  border-color: rgba(101, 163, 13, 0.55);
}
.deck-editing .sales-deck .deck-card-editable:focus::before {
  border-color: rgba(101, 163, 13, 0.9);
}

/* The timeline gutter rail's blurred box-shadow prints as a luminosity-softmask
   fill. PDF viewers then build a page-sized mask and composite every pixel,
   making timeline pages unnecessarily expensive. The shadow is black on a
   near-black background, so the print-only removal is visually neutral. Keep
   this rule in the audited stylesheet: deck-tl-rail survives in frozen HTML. */
@media print {
  .sales-deck .deck-tl-rail {
    box-shadow: none;
  }
}

/* ————— Technology list auto-columns ————— */
/* Content-heavy stacks (Elixir) reflow into the canonical two-column wide
   layout; short lists stay a single centered column. Toggled by JS measure. */
.sales-deck .deck-tech-listwrap--cols {
  width: 1180px;
}
.sales-deck .deck-tech-list--cols {
  columns: 2;
  column-gap: 64px;
  font-size: 17.5px;
  line-height: 1.7;
}
.sales-deck .deck-tech-list--cols > li {
  break-inside: avoid;
  margin-bottom: 0.9em;
}

/* ————— Context & Goals: fill column one, then spill to column two ————— */
/* The editable bullet list flows top-to-bottom down the first column and only
   wraps into the second once the first fills (column-fill:auto over the list's
   definite flex height), so a long context list stays on a single slide instead
   of running off the bottom. Putting columns on the class (not per-<li>) means
   new <li> created while editing inherit break-inside automatically. */
.sales-deck ul.deck-li-cols {
  columns: 2;
  column-gap: 80px;
  column-fill: auto;
}
.sales-deck ul.deck-li-cols > li {
  break-inside: avoid;
}

/* ————— Free-placed text: the spilled two-column state ————— */
/* Added by utils/deck_text_fit#autosizeTextElement only after the type has
   already stepped down to its floor, and only on boxes wide enough for a real
   second column. BALANCED here (no column-fill:auto, unlike the bullet zones
   below): that pass measures the box's NATURAL height with the wrapper set to
   height:auto, so an even split is exactly what halves it. A pinned box never
   gets this class — the layout is the author's once they type a size. */
.sales-deck .deck-el-text-body--cols {
  columns: 2;
  column-gap: 32px;
}
.sales-deck .deck-el-text-body--cols li {
  break-inside: avoid;
}

/* ————— Title + bullets: the spilled two-column state ————— */
/* Added by utils/deck_text_fit#fitBulletZone only after the type has already
   stepped down to its floor in one column, so the slide keeps its designed
   narrow measure for as long as the words allow. The wide measure matches
   Context & Goals (the other spilling list), and column-fill:auto over the
   zone's definite flex height fills column one before spilling into two — so
   the reading order stays top-to-bottom rather than balancing across.
   Outranks the zone's own max-w-[800px] utility on specificity. */
.sales-deck ul.deck-bullets-fit--cols {
  max-width: 1180px;
  columns: 2;
  column-gap: 80px;
  column-fill: auto;
}
.sales-deck ul.deck-bullets-fit--cols > li {
  break-inside: avoid;
}

/* ————— Editable bullet lists ————— */
/* The UL is the editable zone; li styling and the dot live HERE (element
   level, driven by custom properties) so new <li> created by pressing Enter
   while editing inherit spacing and the bullet automatically. */
.sales-deck ul.deck-li-dot {
  /* currentColor, not a literal: a bullet is the same color as the sentence it
     belongs to, everywhere. The literal #fff was correct only on the dark slide
     and had to be overridden by hand wherever a list sat on a light surface —
     _mvp_group already passed currentColor for exactly that reason, and the
     white timeline cards pass a grey. Following the text means every list marker
     in the deck tracks a palette change for free, with no swatch of its own. */
  --dot-color: currentColor;
  --dot-size: 0.26em;
  /* Marker geometry (Notion's optics): numbers right-align on the period,
     a roomy --num-gap before the text (Notion ≈0.75em — NOT flush against
     it, or the digits drift far right of the dots); the dot centers under a
     single-digit "N." pair (≈0.84em in DM Sans), landing under the digit
     body ~0.3em left of the period, exactly like Notion. Templates that
     tune their own gutter override these inline. */
  --num-gap: 0.7em;
  --dot-left: calc(var(--li-pad) - var(--num-gap) - 0.42em - var(--dot-size) / 2);
  --li-pad: 1.8em;
  --li-gap: 0.8em;
}
/* The base gutter fits "9." — once a list actually renders 2- or 3-digit
   numbers the editor stamps deck-num-w2/-w3 on the ul (refreshListGutters in
   deck_list_editor.js), widening the whole list's gutter so digits grow
   leftward without clipping at the container edge. The class ships in the
   saved HTML, so published/present/PDF surfaces stay aligned without JS. */
.sales-deck ul.deck-li-dot.deck-num-w2 {
  --li-pad: 2.45em;
}
.sales-deck ul.deck-li-dot.deck-num-w3 {
  --li-pad: 3.05em;
}
.sales-deck ul.deck-li-dot > li {
  position: relative;
  padding-left: var(--li-pad);
  margin-bottom: var(--li-gap);
}
.sales-deck ul.deck-li-dot > li:last-child {
  margin-bottom: 0;
}
.sales-deck ul.deck-li-dot > li.deck-li-plain::before {
  content: none; /* an "exited" bullet: the line stays, the dot goes */
}
.sales-deck ul.deck-li-dot > li::before {
  content: "";
  position: absolute;
  left: var(--dot-left);
  /* Optically center the dot on the first line box — 1lh tracks the li's
     computed line-height, so tall leading never drifts the dot. --dot-top
     remains an escape hatch for odd layouts. */
  top: var(--dot-top, calc(0.5lh - var(--dot-size) / 2));
  width: var(--dot-size);
  height: var(--dot-size);
  border-radius: 999px;
  background: var(--dot-color);
}

/* ————— Notion-style list lines (flat model) —————
   Every line of a list is a direct-child li of one ul.deck-li-dot; classes on
   the li pick that line's rendering (see utils/deck_list_editor.js), so
   per-line type switches and indents never split the list — and they survive
   publish, whose sanitizer keeps only class + text color (an <ol start="…">
   continuation would be stripped):
     (no class)      bullet dot
     .deck-li-plain  paragraph line (content: none above)
     .deck-li-num    ordered — numbered by the counters below, so every
                     surface (editor, present, published link, PDF) agrees
     .deck-li-ind-N  indent level N — margin steps; bullets cycle disc→ring→
                     square and numbers cycle 1.→a.→i. per level, like Notion
   Counter scheme: one counter per indent level. A numbered line increments
   its level's counter and resets all deeper ones; every other line resets its
   own level AND deeper. Numbering therefore restarts after an interruption at
   its own level but survives its own indented sub-lines. */
.sales-deck ul.deck-li-dot {
  --li-ind: 1.8em;
  counter-reset: dk-n0 dk-n1 dk-n2 dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8;
}
.sales-deck ul.deck-li-dot > li:not(.deck-li-num) {
  counter-reset: dk-n0 dk-n1 dk-n2 dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8;
}
.sales-deck ul.deck-li-dot > li.deck-li-num {
  counter-increment: dk-n0;
  counter-reset: dk-n1 dk-n2 dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8;
}
/* The number replaces the dot: an absolutely positioned text box whose RIGHT
   edge pins to the period column (li-pad − num-gap from the left), shrinking
   to fit its content. No width + nowrap, or a wide "10." wraps its period
   onto a phantom second line inside the box; anchored this way multi-digit
   numbers simply grow leftward, Notion-style. top: auto keeps the static
   position, so the number sits on the li's first line box baseline whatever
   the font size. */
.sales-deck ul.deck-li-dot > li.deck-li-num:not(.deck-li-plain)::before {
  content: counter(dk-n0) ".";
  position: absolute;
  left: auto;
  right: calc(100% - var(--li-pad) + var(--num-gap, 0.7em));
  top: auto;
  width: auto;
  height: auto;
  border-radius: 0;
  background: none;
  white-space: nowrap;
  color: var(--dot-color);
  font-variant-numeric: tabular-nums;
}
/* Indent steps */
.sales-deck ul.deck-li-dot > li.deck-li-ind-1 { margin-left: calc(1 * var(--li-ind)); }
.sales-deck ul.deck-li-dot > li.deck-li-ind-2 { margin-left: calc(2 * var(--li-ind)); }
.sales-deck ul.deck-li-dot > li.deck-li-ind-3 { margin-left: calc(3 * var(--li-ind)); }
.sales-deck ul.deck-li-dot > li.deck-li-ind-4 { margin-left: calc(4 * var(--li-ind)); }
.sales-deck ul.deck-li-dot > li.deck-li-ind-5 { margin-left: calc(5 * var(--li-ind)); }
.sales-deck ul.deck-li-dot > li.deck-li-ind-6 { margin-left: calc(6 * var(--li-ind)); }
.sales-deck ul.deck-li-dot > li.deck-li-ind-7 { margin-left: calc(7 * var(--li-ind)); }
.sales-deck ul.deck-li-dot > li.deck-li-ind-8 { margin-left: calc(8 * var(--li-ind)); }
/* Per-level counters. Non-numbered lines reset their level and deeper. */
.sales-deck ul.deck-li-dot > li.deck-li-ind-1:not(.deck-li-num) { counter-reset: dk-n1 dk-n2 dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-2:not(.deck-li-num) { counter-reset: dk-n2 dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-3:not(.deck-li-num) { counter-reset: dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-4:not(.deck-li-num) { counter-reset: dk-n4 dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-5:not(.deck-li-num) { counter-reset: dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-6:not(.deck-li-num) { counter-reset: dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-7:not(.deck-li-num) { counter-reset: dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-8:not(.deck-li-num) { counter-reset: dk-n8; }
/* Numbered lines increment their level, reset deeper. Level 8 must override
   the base .deck-li-num reset with none, or it would zero its own counter. */
.sales-deck ul.deck-li-dot > li.deck-li-ind-1.deck-li-num { counter-increment: dk-n1; counter-reset: dk-n2 dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-2.deck-li-num { counter-increment: dk-n2; counter-reset: dk-n3 dk-n4 dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-3.deck-li-num { counter-increment: dk-n3; counter-reset: dk-n4 dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-4.deck-li-num { counter-increment: dk-n4; counter-reset: dk-n5 dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-5.deck-li-num { counter-increment: dk-n5; counter-reset: dk-n6 dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-6.deck-li-num { counter-increment: dk-n6; counter-reset: dk-n7 dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-7.deck-li-num { counter-increment: dk-n7; counter-reset: dk-n8; }
.sales-deck ul.deck-li-dot > li.deck-li-ind-8.deck-li-num { counter-increment: dk-n8; counter-reset: none; }
/* Number style cycles by depth: 1. → a. → i. */
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-1:not(.deck-li-plain)::before { content: counter(dk-n1, lower-alpha) "."; }
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-2:not(.deck-li-plain)::before { content: counter(dk-n2, lower-roman) "."; }
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-3:not(.deck-li-plain)::before { content: counter(dk-n3) "."; }
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-4:not(.deck-li-plain)::before { content: counter(dk-n4, lower-alpha) "."; }
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-5:not(.deck-li-plain)::before { content: counter(dk-n5, lower-roman) "."; }
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-6:not(.deck-li-plain)::before { content: counter(dk-n6) "."; }
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-7:not(.deck-li-plain)::before { content: counter(dk-n7, lower-alpha) "."; }
.sales-deck ul.deck-li-dot > li.deck-li-num.deck-li-ind-8:not(.deck-li-plain)::before { content: counter(dk-n8, lower-roman) "."; }
/* Bullet glyph cycles by depth: disc → ring (1/4/7) → square (2/5/8) */
.sales-deck ul.deck-li-dot > li.deck-li-ind-1:not(.deck-li-num)::before,
.sales-deck ul.deck-li-dot > li.deck-li-ind-4:not(.deck-li-num)::before,
.sales-deck ul.deck-li-dot > li.deck-li-ind-7:not(.deck-li-num)::before {
  background: transparent;
  border: max(1px, 0.07em) solid var(--dot-color);
}
.sales-deck ul.deck-li-dot > li.deck-li-ind-2:not(.deck-li-num)::before,
.sales-deck ul.deck-li-dot > li.deck-li-ind-5:not(.deck-li-num)::before,
.sales-deck ul.deck-li-dot > li.deck-li-ind-8:not(.deck-li-num)::before {
  border-radius: 1px;
}
/* Center/right-aligned lines: the alignment class moves the TEXT while a
   gutter-pinned marker stays at the li's left edge — bullet orphaned across
   the line from its text. Flow the marker INLINE instead so it hugs the text
   wherever the alignment puts it, with the same optical gap as the gutter
   model (dot gap = li-pad − dot-left − dot-size; number gap = num-gap). The
   gutter padding goes too, so centered lines center on the true box.
   text-justify/left keep the gutter — their text starts at the left edge. */
/* Three ways a line ends up centered/right-aligned, all needing the inline
   marker: its own line class, an aligned ANCESTOR (element-level alignment on
   the content node — the common case), or the ul host itself carrying the
   class (host-list zones). A line's own explicit text-left/justify wins back
   the gutter over an aligned ancestor. */
.sales-deck ul.deck-li-dot > li:is(.text-center, .text-right),
.sales-deck :is(.text-center, .text-right) ul.deck-li-dot > li:not(.text-left):not(.text-justify),
.sales-deck ul.deck-li-dot:is(.text-center, .text-right) > li:not(.text-left):not(.text-justify) {
  padding-left: 0;
}
.sales-deck ul.deck-li-dot > li:is(.text-center, .text-right):not(.deck-li-num)::before,
.sales-deck :is(.text-center, .text-right) ul.deck-li-dot > li:not(.text-left):not(.text-justify):not(.deck-li-num)::before,
.sales-deck ul.deck-li-dot:is(.text-center, .text-right) > li:not(.text-left):not(.text-justify):not(.deck-li-num)::before {
  position: static;
  display: inline-block;
  vertical-align: middle;
  margin-right: calc(var(--num-gap) + 0.42em - var(--dot-size) / 2);
}
.sales-deck ul.deck-li-dot > li.deck-li-num:is(.text-center, .text-right):not(.deck-li-plain)::before,
.sales-deck :is(.text-center, .text-right) ul.deck-li-dot > li.deck-li-num:not(.text-left):not(.text-justify):not(.deck-li-plain)::before,
.sales-deck ul.deck-li-dot:is(.text-center, .text-right) > li.deck-li-num:not(.text-left):not(.text-justify):not(.deck-li-plain)::before {
  position: static;
  margin-right: var(--num-gap);
}

/* ————— Wheel inline rename editor ————— */
.sales-deck .deck-wheel-editor {
  position: absolute;
  z-index: 30;
  min-width: 320px;
  transform: translate(-50%, -50%);
  text-align: center;
  font: 500 17px var(--font-deck-body);
  letter-spacing: 0.02em;
  color: var(--color-deck-ink);
  background: var(--color-deck-menu);
  border: 1.5px solid var(--color-deck-teal);
  border-radius: 9px;
  padding: 8px 12px;
  outline: none;
  box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
}

/* "Reset to staffing" caption control is meaningful only once the columns are
   overridden. The Edit-team toggle carries data-corners-overridden (server-rendered
   from payload["corners"], and flipped to "true" client-side the moment you
   disconnect), so a frame-scoped :has() reveals the reset button reactively — it
   ships `hidden`, and this unlayered rule beats that Tailwind utility. */
[data-slide-frame-key]:has([data-corners-overridden="true"]) [data-team-reset] {
  display: inline-flex;
}

/* ————— Product Team Model "Edit team" mode ————— */
/* The per-row × and per-column "+ role" controls only show while editing (the
   caption "Edit team" toggle sets .deck-team-editing on the frame). They're
   data-deck-chrome, so the publisher/mirror drop them entirely regardless. */
.sales-deck .deck-team-add,
.sales-deck .deck-team-remove {
  display: none;
}
.deck-team-editing .sales-deck .deck-team-add {
  display: inline-flex;
}
.deck-team-editing .sales-deck .deck-team-remove {
  display: inline-block;
}
/* Corner columns render for EVERY position, including one staffing gave no roles
   — that empty column's "+ role" is how you staff it. Outside Edit-team mode a
   role-less column would float a bare title, so hide it; :has() tracks the rows
   live as they're added/removed. Present mode reparents the canvas out of the
   frame (away from .deck-team-editing), so the reveal can't leak on stage — and
   a still-empty column is data-deck-chrome, dropped at publish anyway. */
.sales-deck [data-wheel-corner]:not(:has([data-corner-role])) {
  display: none;
}
.deck-team-editing .sales-deck [data-wheel-corner]:not(:has([data-corner-role])) {
  display: flex;
}
/* Editable field affordance while in edit mode (contenteditable is added by JS).
   Name + title get padding so the outline isn't cramped against the text, offset
   by an equal NEGATIVE MARGIN so the text (and its siblings) never shift when edit
   mode toggles — padding-left 7 pushes content right, margin-left -7 pulls the box
   back, content lands where it started. The % pill already has its own padding, so
   it only takes the outline. */
.deck-team-editing .sales-deck [data-corner-name],
.deck-team-editing .sales-deck [data-corner-title] {
  padding: 3px 7px;
  margin: -3px -7px;
}
.deck-team-editing .sales-deck [data-corner-name],
.deck-team-editing .sales-deck [data-corner-pct],
.deck-team-editing .sales-deck [data-corner-title] {
  border-radius: 6px;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.deck-team-editing .sales-deck [data-corner-name]:focus,
.deck-team-editing .sales-deck [data-corner-pct]:focus,
.deck-team-editing .sales-deck [data-corner-title]:focus {
  box-shadow: inset 0 0 0 1px var(--color-deck-teal);
}
/* Subtle fill only on the bg-less fields — the % pill keeps its own dark chip. */
.deck-team-editing .sales-deck [data-corner-name]:focus,
.deck-team-editing .sales-deck [data-corner-title]:focus {
  background: rgba(94, 234, 212, 0.08);
}
/* Per-row drag grip — editor only, revealed on row hover so the columns stay clean
   while presenting/reviewing. data-deck-chrome drops it at publish. The grip is the
   SortableJS handle; reordering runs regardless of "Edit team" mode, so it's gated
   on .deck-editing, not .deck-team-editing.

   The grip lives in the row's LEFT PADDING (pl-8 -ml-8 on the row), so both the
   hover-reveal area and the SortableJS drop target — which is the row's border-box —
   already cover it. No pseudo-element gutter is needed; you can drag straight down.

   Fade in fast on hover, then LINGER briefly before fading out so a quick exit and
   return doesn't flicker (opacity/visibility, not display, so the hide is
   transitionable). Hidden everywhere by default — present mode and the published
   deck never show it. */
.sales-deck .deck-role-grip {
  opacity: 0;
  visibility: hidden;
  transition: opacity 120ms ease 80ms, visibility 0s linear 200ms;
  /* Pressing/dragging the grip must never read as a text gesture: iOS
     otherwise pops the selection magnifier loupe over the row mid-drag. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.deck-editing .sales-deck [data-corner-role]:hover .deck-role-grip {
  opacity: 1;
  visibility: visible;
  transition: opacity 120ms ease;
}

/* ————— Uploaded-image framing (pan / zoom / crop by framing) ————— */
.sales-deck .deck-img-wrap {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  overflow: hidden;
  touch-action: none;
  /* Same editable affordance as text zones — outline (not a pseudo) because
     the wrap clips its children; outline-offset gives it breathing room. */
  outline: 1.5px dashed transparent;
  outline-offset: 6px;
  transition: outline-color 0.15s ease;
}
/* Shrink-to-fit variant (cover client logo): the wrap takes the image's
   natural width at slot height instead of a fixed box. No crop frame means
   no pan/zoom — just replace, floated above the slot so the tiny wrap
   (which would otherwise clip its own corner controls) can't hide it. */
.sales-deck .deck-img-wrap--fit {
  width: auto;
}
.sales-deck .deck-img-wrap--fit,
.sales-deck .deck-img-wrap--float {
  overflow: visible;
}
.sales-deck .deck-img-wrap--fit .deck-img-controls,
.sales-deck .deck-img-wrap--float .deck-img-controls {
  top: auto;
  right: 50%;
  bottom: calc(100% + 8px);
  transform: translateX(50%);
}
.sales-deck .deck-img-wrap.deck-img-wrap--fit img,
.sales-deck .deck-img-wrap.deck-img-wrap--float img {
  cursor: default; /* no pan on replace-only slots */
}
.sales-deck .deck-img-wrap img {
  user-select: none;
  -webkit-user-drag: none;
}
/* Example deliverable slides default to 100% width, bottom-aligned: object-cover
   fills the width, object-position pins the bottom edge, and the framing transform
   scales from the BOTTOM (transform-origin) so the +/− zoom and the drag-resize
   handle grow/shrink the image while keeping it anchored to the slide bottom
   instead of drifting to center. Pan (translate) still moves it freely. */
.sales-deck .deck-img-example-fill {
  transform-origin: 50% 100%;
  /* The deck canvas paints under transform: scale() (origin top-left), whose
     sub-pixel rounding can rasterize this 100%-size <img> ~1px smaller than its
     parent, revealing a hairline of the .group/example white card at the far
     (right/bottom) edges — visible only on an image whose content runs dark to
     the edge (e.g. Design System). Overscan 2px so the image always covers those
     edges; the wrap's overflow:hidden clips the bleed. max-width:none defeats
     Tailwind preflight's img{max-width:100%}, which would otherwise clamp it. */
  max-width: none;
  width: calc(100% + 2px);
  height: calc(100% + 2px);
}
.deck-editing .sales-deck .deck-img-wrap img {
  cursor: grab;
}
.deck-editing .sales-deck .deck-img-wrap img:active {
  cursor: grabbing;
}
/* Editor-only pointer cursors survive in frozen class lists — neutralize
   everywhere outside the editor. */
.sales-deck .cursor-pointer {
  cursor: default;
}
.deck-editing .sales-deck .cursor-pointer {
  cursor: pointer;
}
.sales-deck .deck-img-controls {
  position: absolute;
  right: 8px;
  top: 8px;
  z-index: 10;
  display: flex;
  gap: 4px;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.sales-deck .deck-img-wrap:hover .deck-img-controls {
  opacity: 1;
}
.sales-deck .deck-img-wrap:hover {
  outline-color: rgba(184, 235, 134, 0.55);
}
.sales-deck .deck-img-controls button {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: 6px;
  border: 1px solid var(--color-deck-edge-soft);
  background: rgba(10, 10, 12, 0.85);
  color: #dfe3e7;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
}
.sales-deck .deck-img-controls button:hover {
  border-color: #b8eb86;
  color: #fff;
}
/* Lower-right drag-to-resize handle for a framed image slot — a second way to
   zoom (mirrors .deck-el-resize on free-placed images). Shown on hover while
   editing; sits inside the clipped wrap corner. */
.sales-deck .deck-img-resize {
  position: absolute;
  right: 6px;
  bottom: 6px;
  z-index: 10;
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 3px;
  background: #b8eb86;
  border: 1.5px solid #0d130d;
  cursor: nwse-resize;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
  opacity: 0;
  transition: opacity 0.15s ease;
  touch-action: none;
}
.sales-deck .deck-img-resize::before {
  content: "";
  position: absolute;
  inset: -9px;
}
.deck-editing .sales-deck .deck-img-wrap:hover .deck-img-resize {
  opacity: 1;
}

/* ————— Overview eyelid state ————— */
.deck-excluded .deck-slide-frame {
  outline: 1.5px dashed #3c4046;
  outline-offset: -1.5px;
}
.deck-excluded .sales-deck {
  filter: grayscale(1) brightness(0.45);
}

/* ————— Client logo wall ("You're in good company") ————— */
/* Flatten every logo to a single black so brand accent colors (Thrivent's red
   dot, Trimble's red swoosh, Panorama's orange square, …) don't sneak in.
   brightness(0) zeroes all color channels to black while preserving each
   logo's alpha shape — the only lever available since the logos are external
   SVGs served via <img>, whose internal fills can't be recolored from the
   host page. */
.sales-deck .deck-logo-mono {
  filter: brightness(0);
}
/* A custom-SVG logo override fits the same fixed box as the shipped <img>: the
   inline SVG (viewBox, no fixed width/height — stripped at save) scales down to
   the box, preserving aspect ratio and staying mono. */
.sales-deck .deck-logo-fit {
  display: grid;
  place-items: center;
  height: 100%;
  width: 100%;
}
.sales-deck .deck-logo-fit > svg {
  max-height: 38px;
  max-width: 148px;
  height: auto;
  width: auto;
}
/* Every logo — shipped <img> or custom-SVG override — fits inside the fixed
   38×148 cell box (the ERB's h-[38px] w-[148px]) via object-contain, so the wall
   stays optically uniform regardless of a logo's aspect ratio. The height cap
   MUST be a definite length: the cell and its inner button are grid +
   place-items-center, whose row tracks are indefinite, so a percentage
   max-height (max-h-full) resolves to `none` and tall-aspect logos (wing 141×57,
   cellcom, ford) render at their intrinsic SVG height and overflow the row. This
   rule is unlayered, so it beats Tailwind's layered max-h-full/max-w-full. Keep
   38/148 in sync with the cell's Tailwind classes in _company.html.erb. */
.sales-deck .deck-logo-cell img {
  max-height: 38px;
  max-width: 148px;
  object-fit: contain;
}
/* Click-to-replace affordance — editor only (published logos are inert). */
.deck-editing .sales-deck .deck-logo {
  cursor: pointer;
  border-radius: 6px;
  outline: 1.5px solid transparent;
  outline-offset: 4px;
  transition: outline-color 0.12s ease;
}
.deck-editing .sales-deck .deck-logo:hover {
  outline-color: rgba(0, 0, 0, 0.18);
}
/* Per-logo hover chrome — editor only, revealed on cell hover so the wall stays
   clean while presenting/reviewing. data-deck-chrome drops it at publish. The
   grip (upper-left) is the drag handle; the actions cluster (upper-right) holds
   edit + delete. */
.deck-editing .sales-deck .deck-logo-cell:hover .deck-logo-grip {
  display: grid;
}
.deck-editing .sales-deck .deck-logo-cell:hover .deck-logo-actions {
  display: flex;
}
/* SortableJS states for the client-logo wall reorder (editor only, light slide):
   the placeholder is a quiet dashed well; the floating clone reads as a lifted
   white chip so the mono mark stays legible over the wall while dragging. (Hover
   chrome hides itself on both — the ghost hides all children, and the clone is
   pointer-events:none so it never gains :hover.) */
.sales-deck .deck-logo-cell.deck-logo-drag-ghost {
  border-radius: 8px;
  outline: 1.5px dashed rgba(0, 0, 0, 0.22);
  outline-offset: 6px;
}
.sales-deck .deck-logo-cell.deck-logo-drag-ghost > * {
  visibility: hidden; /* show only the well, not the logo */
}
.sales-deck .deck-logo-cell.deck-logo-drag-item {
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
}
/* fallbackOnBody clone: the floating chip is appended to <body> — outside the
   scaled canvas (deliberately: inside it, Sortable's client-px moves were
   multiplied by --deck-scale and the chip lagged the finger). The
   .sales-deck-scoped looks above can't reach it there, so re-apply them keyed
   on the drag-item class. Sortable inline-sizes the clone from the tile's
   PAINTED rect (small at low canvas scale), so the inner marks fill 100% of
   that box instead of the canvas-px 38/148 caps. */
body > .deck-logo-cell.deck-logo-drag-item {
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
}
body > .deck-logo-cell.deck-logo-drag-item .deck-logo-mono {
  filter: brightness(0);
}
body > .deck-logo-cell.deck-logo-drag-item .deck-logo-fit {
  display: grid;
  place-items: center;
  height: 100%;
  width: 100%;
}
body > .deck-logo-cell.deck-logo-drag-item .deck-logo-fit > svg,
body > .deck-logo-cell.deck-logo-drag-item img {
  max-height: 100%;
  max-width: 100%;
  height: auto;
  width: auto;
  object-fit: contain;
}

/* ————— Custom SVG slide (kind: svg) ————— */
/* A pasted / edited SVG rendered edge-to-edge. Its optional title — the
   payload["title"] overlay OR a baked-in <text class="deck-svg-title"> — takes
   its COLOR and VISIBILITY from state classes on .deck-custom-svg, so nothing is
   transformed server-side and the classes survive the publish freeze. */
.sales-deck .deck-custom-svg .deck-svg-slot > svg {
  display: block;
  width: 100%;
  height: 100%;
}
/* SHIPPED SVG ARTWORK follows the palette.
   The lean-cycle and early-experiments illustrations are OUR partials, not
   author uploads, so their paint does not have to be literal. Icons take
   currentColor (which works in a presentation attribute — var() does not,
   reliably, in Safari) and the accents go through these classes, so both slides
   render on a white ground with no dark plate behind them.
   Nothing wears a dark plate any more: the Make Waves wordmark ships drawn in
   both colours (slides/_waves picks by ground), and an author-uploaded SVG
   paints in whatever colours it was drawn with — SvgSanitizer never touches
   them, so a light deck shows it exactly as the cover of the file it came from. */
/* The "derived from estimate" / "synced from estimate" chips are META chrome —
   they say where the numbers came from, not something about the brand. The lime
   is the deck's accent on a black slide and reads as a highlight there; on a
   light slide a green chip announces itself louder than the figures it annotates,
   so it drops to ordinary muted copy. */
.sales-deck[data-deck-ground="light"] .deck-derived-chip {
  color: var(--color-deck-muted);
  border-color: color-mix(in srgb, var(--color-deck-muted) 35%, transparent);
}

.sales-deck .deck-art-highlight { fill: var(--color-deck-lime); }
.sales-deck .deck-art-accent { fill: var(--color-deck-teal); }
.sales-deck .deck-art-muted { fill: var(--color-deck-gray); }
.sales-deck .deck-art-faint { fill: var(--color-deck-faint); }
.sales-deck .deck-art-bar { fill: var(--color-deck-chip); }

/* Shared gradient stops for baked-in titles that paint with fill:url(#deck-svg-grad). */
.sales-deck .deck-svg-grad-from {
  stop-color: var(--color-deck-grad-from);
}
.sales-deck .deck-svg-grad-to {
  stop-color: var(--color-deck-grad-to);
}
/* Title default = deck gradient (overlay clips a gradient to the text; a baked
   <text> paints via the shared linearGradient). */
.sales-deck .deck-custom-svg .deck-svg-title {
  fill: url(#deck-svg-grad);
}
.sales-deck .deck-custom-svg .deck-svg-title-overlay {
  /* Above the .deck-svg-slot (both absolute, slot is later in the DOM) so the
     overlay title paints ON TOP of the SVG art instead of behind it. */
  z-index: 10;
  background-image: linear-gradient(97deg, var(--color-deck-grad-from), var(--color-deck-grad-to) 210%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* background-clip:text at a tight line-height shears descender tails (g/y/p);
     the same 0.14em cushion .deck-grad-text uses. Applies in both states so the
     white variant matches and dragging doesn't shift the box top. */
  padding-bottom: 0.14em;
  /* Gradient text is color:transparent, which would make the editing caret
     invisible too — pin it to the brand lime (like .deck-grad-text/.deck-zone). */
  caret-color: #b8eb86;
}
/* Editor: the overlay title is draggable (grab), but shows a text caret while
   it's focused for editing, and grabbing mid-drag. */
.deck-editing .sales-deck .deck-custom-svg .deck-svg-title-overlay {
  cursor: grab;
}
.deck-editing .sales-deck .deck-custom-svg .deck-svg-title-overlay:focus {
  cursor: text;
}
.sales-deck .deck-custom-svg .deck-svg-title-overlay.deck-svg-title-dragging {
  cursor: grabbing;
  user-select: none;
}
/* White */
.sales-deck .deck-custom-svg.deck-title-white .deck-svg-title {
  fill: #fff;
}
.sales-deck .deck-custom-svg.deck-title-white .deck-svg-title-overlay {
  background: none;
  -webkit-background-clip: border-box;
  background-clip: border-box;
  color: #fff;
}
/* Hidden (overlay and baked) */
.sales-deck .deck-custom-svg.deck-title-off .deck-svg-title,
.sales-deck .deck-custom-svg.deck-title-off .deck-svg-title-overlay {
  display: none;
}

/* ————— Locked slide ————— */
/* Frozen for every editor: the drag handle and the controls that would change
   the slide disappear, the canvas and name go inert in the editing (scroll)
   view, and an amber ring marks the state. Only the lock toggle and the
   read-only controls (present, duplicate) stay live. Mirrors the
   .deck-bulk-mode inert pattern above; JS also flips contenteditable off. */
.deck-locked [data-drag-handle],
.deck-locked [data-lock-hide] {
  display: none !important;
}
.deck-view-scroll .deck-locked .sales-deck,
.deck-locked [data-slide-name] {
  pointer-events: none;
}
.deck-locked .deck-slide-frame {
  outline: 1.5px solid rgba(245, 158, 11, 0.55); /* amber-500 */
  outline-offset: -1.5px;
}

/* ————— Actively-edited slide (someone else has a FIELD focused) ————— */
/* Transient and live, and deliberately NOT the whole slide: claims are per
   field, so two people in different parts of one slide both keep working. The
   frame-level class means "someone else is in here" — it outlines the slide and
   puts the destructive controls out of reach — while the freeze itself lands on
   the claimed field only. Distinct from the amber manual lock: teal says "being
   edited right now", amber says "finalized". */
.deck-edit-locked [data-drag-handle],
.deck-edit-locked [data-lock-hide] {
  display: none !important;
}
.deck-edit-locked .deck-slide-frame {
  outline: 1.5px solid rgba(45, 212, 191, 0.55); /* teal-400 */
  outline-offset: -1.5px;
}

/* The claimed field itself: inert, tinted, and labelled with whoever holds it.
   position is set inline-safe via a rule rather than a utility because these
   nodes are server-rendered content we don't control the classes of. */
[data-deck-field-locked] {
  position: relative;
  pointer-events: none;
  border-radius: 3px;
  box-shadow: 0 0 0 1.5px rgba(45, 212, 191, 0.55);
  background: rgba(45, 212, 191, 0.07);
}
/* The owner's name, pinned just above the field. Rendered from the attribute so
   the claim broadcast doesn't have to build any chrome. Hidden when the name is
   empty, which is how a released claim reads mid-repaint. */
[data-deck-field-locked-by]:not([data-deck-field-locked-by=""])::after {
  content: attr(data-deck-field-locked-by);
  position: absolute;
  top: 0;
  left: 0;
  transform: translateY(-100%);
  z-index: 3;
  /* see [data-deck-field-locked-below] — a field flush to the canvas top gets
     its tag underneath, since .sales-deck clips anything above it. */
  padding: 1px 6px;
  border-radius: 999px 999px 999px 0;
  background: rgba(45, 212, 191, 0.9);
  color: #06201d;
  font-size: 10px;
  line-height: 1.5;
  letter-spacing: 0.08em;
  white-space: nowrap;
  pointer-events: none;
}
[data-deck-field-locked-below]::after {
  top: auto;
  bottom: 0;
  transform: translateY(100%);
  border-radius: 0 999px 999px 999px;
}

/* ————— Held save + conflict resolution ————— */
/* A refused save leaves the author's edit on the canvas and stamps the frame.
   The caption chip is the way back to it — revealed by the stamp alone, so no
   broadcast or re-render is involved. */
[data-deck-unsaved] [data-deck-unsaved-chip] {
  display: inline-flex;
}

/* Each side of the comparison is a real slide at design size, scaled to the
   pane. Sized by the canvas's own 1440x810 so both panes agree regardless of
   what is on them. */
.deck-conflict-stage {
  /* .sales-deck is position:absolute (see .deck-slide-frame, which is what a
     live slide nests it in) — without a positioned stage both panes lay out
     against the dialog and land on top of each other. */
  position: relative;
  overflow: hidden;
  border: 1px solid var(--color-deck-edge);
  border-radius: 8px;
  background: var(--color-deck-bg, #0b0b0d);
  aspect-ratio: 1440 / 810;
}
/* The clone is pinned to its natural canvas size; deck-conflict measures the
   pane and sets the scale transform (zoom won't take container-query units). */
.deck-conflict-stage > .sales-deck {
  width: 1440px;
  height: 810px;
}
/* The fields actually in dispute, outlined in both panes so the eye lands on
   what is being decided rather than on the whole slide. */
[data-deck-conflict-field] {
  outline: 2px solid rgba(251, 191, 36, 0.9); /* amber-400 */
  outline-offset: 2px;
  border-radius: 3px;
}

/* ————— Scrollable data slides ————— */
/* Timeline (horizontal) and roadmap (vertical) scroll inside their slide. */
.sales-deck .deck-scroll-x {
  overflow-x: auto;
  overflow-y: hidden;
}
.sales-deck .deck-scroll-y {
  overflow-y: auto;
  overflow-x: hidden;
}
.sales-deck .deck-scroll-xy {
  overflow: auto;
}
.sales-deck .deck-scroll-x::-webkit-scrollbar {
  height: 11px;
}
.sales-deck .deck-scroll-y::-webkit-scrollbar {
  width: 11px;
}
.sales-deck .deck-scroll-xy::-webkit-scrollbar {
  width: 11px;
  height: 11px;
}
.sales-deck .deck-scroll-x::-webkit-scrollbar-track,
.sales-deck .deck-scroll-y::-webkit-scrollbar-track,
.sales-deck .deck-scroll-xy::-webkit-scrollbar-track {
  background: #0b0b0b;
}
.sales-deck .deck-scroll-x::-webkit-scrollbar-thumb,
.sales-deck .deck-scroll-y::-webkit-scrollbar-thumb,
.sales-deck .deck-scroll-xy::-webkit-scrollbar-thumb {
  background: #383c43;
  border-radius: 6px;
  border: 2px solid #0b0b0b;
}
.sales-deck .deck-scroll-xy::-webkit-scrollbar-corner {
  background: #0b0b0b;
}

/* Full-bleed image "scroll to reveal" mode (caption toggle, off by default):
   keeps the CURRENT zoom, but instead of clipping what overflows the slide the
   frame scrolls — in whichever direction(s) the zoom overflows — to reveal it.
   The sales-deck controller sizes the image to its zoomed dimensions (no
   transform, so plain block scrolling — a transform's element box, not the
   visible image, would otherwise drive the scroll region and fight us) and
   scrolls to the framed position; a wide image scrolls right, a tall one down,
   either both. Toggling off restores the framed pan/zoom view untouched. */
.sales-deck .deck-img-scroll .deck-img-wrap {
  overflow: auto;
  touch-action: auto;
}
.sales-deck .deck-img-scroll .deck-img-wrap img {
  cursor: default;
  /* The JS sizes the image to its zoomed dimensions (larger than the frame) so
     the wrap can scroll it — lift Tailwind preflight's img { max-width:100% }
     cap, which would otherwise clamp it back to the frame (a tiny contain strip). */
  max-width: none;
  max-height: none;
}
.sales-deck .deck-img-scroll .deck-img-wrap::-webkit-scrollbar {
  width: 11px;
  height: 11px;
}
.sales-deck .deck-img-scroll .deck-img-wrap::-webkit-scrollbar-track {
  background: #0b0b0b;
}
.sales-deck .deck-img-scroll .deck-img-wrap::-webkit-scrollbar-thumb {
  background: #383c43;
  border-radius: 6px;
  border: 2px solid #0b0b0b;
}
.sales-deck .deck-img-scroll .deck-img-wrap::-webkit-scrollbar-corner {
  background: #0b0b0b;
}

/* ————— Timeline week cards: scrollable, curatable bullet lists ————— */
/* Each week card scrolls its own full feature list. In the editor the author
   curates which bullets show (single-click hides), their order (click-drag),
   and their wording (double-click); present mode and the published deck are
   read-only views of the result. */
/* When an edited bullet is scrolled into view, keep it clear of BOTH edges.
   Left: the sticky lane-label gutter (208px, column 1) occludes content behind
   it, but scrollIntoView treats that as "visible" — 240px (208 + ~32 for the
   card's own margin/padding) clears the white card edge too, not just the text.
   Right: scrollIntoView otherwise stops the instant the bullet's right edge hits
   the viewport, clipping the card's chrome and leaving no room — matching the
   left keeps the whole card visible with the next week peeking in. */
.sales-deck [data-timeline-root] {
  scroll-padding-inline: 240px;
}
.sales-deck .deck-tl-scroll {
  overflow-y: auto;
  overflow-x: hidden;
  /* Scroll chaining stays DEFAULT on both axes (Jon): a card list consumes
     the gesture while it can, then reaching its top/bottom hands the rest
     onward — in the editor's scroll view that continues into the page (next
     slides), and in the phone lane mode into the frame, then the page. An
     earlier `overscroll-behavior-y: contain` here dead-stopped scrolling at
     the card edge, stranding the reader inside the slide. Vertically editor
     -only in effect: the client viewer re-contains just the y axis
     (.deck-viewer-stage .deck-tl-scroll in public/decks/show), where chaining
     would yank the immersive chrome-collapse runway. Horizontal is default
     on BOTH surfaces so a shift+wheel over a card — which the card can't
     consume — chains to the timeline's .deck-scroll-x. */
  /* Shift the scroll gutter toward the card's right edge without narrowing the
     text: the negative margin pulls the bar ~5px from the edge, the matching
     padding keeps bullet text ending where it did (clear of the skinny bar). */
  margin-right: -7px;
  padding-right: 7px;
  /* Same trick on the other three sides: pull the box out into the white card's
     padding and pad it back by the same amount. The list occupies the same
     visual area, but the overflow clip edge now sits a few px OUTSIDE the bullet
     text — so the editing focus ring (an `outline` with a positive offset on the
     edited bullet) shows in full on the first/last bullet and along the left
     edge instead of being clipped by the scrollport. */
  margin-top: -4px;
  padding-top: 4px;
  margin-bottom: -4px;
  padding-bottom: 4px;
  /* The left needs more room than the ring alone: the bullet hover highlight
     (.deck-tl-bullet::after) reaches 6px into this gutter to wrap the hide eye,
     so the clip edge must sit past that or its rounded left corners get squared
     off. 10px clears the -6px highlight (rounded corners included) and the eye. */
  margin-left: -10px;
  padding-left: 10px;
}
/* Room to land in. The horizontal scroller has scroll-padding-inline for the
   sticky gutter (above); this is the vertical half, and Enter on a card's LAST
   bullet is what needs it. ensureBulletVisible scrolls the new bullet into view
   with block:"nearest", which parks it flush against the bottom edge — measured
   at 0.2px of room — so the fresh line read as a clipped sliver until the first
   keystroke gave it some height. One line of padding reveals the new bullet with
   the card's edge still visible under it, at the top of the card as well. */
.sales-deck .deck-tl-scroll {
  scroll-padding-block: 16px;
}
/* PAPER: nothing here can scroll, so nothing may cap-and-clip.
   On the interactive canvas a heavy week's card grows to its lane row and then
   scrolls its bullet list (the max-height above, inline on the card). A PDF page
   has no scrollbar to reach for, so a capped card would simply print with
   features missing — uncap the card AND its list and let the stack run its true
   height.
   TWO surfaces are paper: the export page, and the export-zoom modal's stage,
   which exists to show the author the page they are about to get. The rule used
   to live inline in the export page alone, so the modal drew clipped cards for
   exactly the decks where it mattered.
   What this does NOT change is the ZOOM. The fit measurement
   (helpers/deck_export_panes' contentOverflow) reads the deepest descendant
   edge rather than the scroller's own scrollHeight, and a clipped child still
   reports its true geometry through getBoundingClientRect — so both surfaces
   already saw through the cap and resolved the same zoom. Measured on a
   simulated 40-bullet week: card 2364px uncapped vs 485px capped, height
   overflow 3.292 vs 3.278. This is about what gets PAINTED. */
.deck-export-paper [data-timeline-root] div:has(> .deck-tl-scroll) {
  max-height: none !important;
}
.deck-export-paper [data-timeline-root] .deck-tl-scroll {
  max-height: none !important;
  overflow: visible !important;
}
/* A just-split bullet is empty, and an empty <li> holding an empty inline span
   collapses to zero height — nothing for the scroll above to bring into view.
   Hold one line open so the new bullet is a real row the moment it appears.
   (min-height has to sit on the li: the text span is inline, which ignores it.)
   A no-op for every bullet that has text, since one line is already its floor. */
.deck-editing .sales-deck .deck-tl-bullet {
  min-height: 1.42em; /* the card's leading-[1.42] on its 10.5px text */
}
.sales-deck .deck-tl-scroll::-webkit-scrollbar {
  width: 5px; /* skinny */
}
.sales-deck .deck-tl-scroll::-webkit-scrollbar-thumb {
  background: #c4c9d0;
  border-radius: 3px;
}
/* Inset the track so the bar starts below the (i) button (top-right) and
   stops short of the rounded bottom corner — no overlap with the chrome. */
.sales-deck .deck-tl-scroll::-webkit-scrollbar-track {
  background: transparent;
  margin-top: 20px;
  margin-bottom: 8px;
}

/* Curation affordances live only inside the editing surface, so present mode
   and published decks keep a clean, non-interactive list. They also gate on the
   bullet actually carrying its hide eye (.deck-tl-hide-eye) — read_only_body
   strips that (data-deck-chrome) from a LINKED read-only mirror, which still
   renders under .deck-editing, so without this the mirror's bullets would show
   the pointer cursor + hover highlight as if they were curatable. The eye is the
   precise "this bullet is editable" signal (present iff editable, stripped in
   every read-only context). */
.deck-editing .sales-deck .deck-tl-bullet:has(.deck-tl-hide-eye) {
  cursor: pointer;
  /* Own stacking context so the hover layer below (z-index:-1) stays behind this
     row's text/eye but ABOVE the white card — not lost behind it. */
  isolation: isolate;
}
/* The hover highlight lives on a pseudo-element that reaches 6px into the left
   gutter, so it WRAPS the hide eye (which sits at left:-5px) instead of stopping
   at the text box — enough to clear the eye without crowding the card's edge.
   z-index:-1 keeps it behind the text and eye; no layout shift. */
.deck-editing .sales-deck .deck-tl-bullet::after {
  content: "";
  position: absolute;
  inset: 0 0 0 -6px;
  border-radius: 4px;
  background: transparent;
  transition: background-color 0.12s;
  z-index: -1;
  pointer-events: none;
}
.deck-editing .sales-deck .deck-tl-bullet:has(.deck-tl-hide-eye):hover::after {
  background: #eef0f3;
}
/* Per-bullet hide affordance: a closed eye tucked in the left gutter that fades
   in on bullet hover; clicking it hides that feature from the slide (routed by
   sales-deck#bulletClick). data-deck-chrome drops it on publish / present. It
   sits over the decorative dot, faded out on hover so the two don't stack. */
.deck-editing .sales-deck .deck-tl-hide-eye {
  position: absolute;
  left: -5px;
  /* Center the target on the first line, like the dot's ::before does. */
  top: calc(0.5lh - 7.5px);
  display: grid;
  place-items: center;
  width: 15px;
  height: 15px;
  padding: 0;
  border: 0;
  background: transparent;
  color: #9aa0a7;
  opacity: 0;
  cursor: pointer;
  transition: opacity 0.12s, color 0.12s;
}
.deck-editing .sales-deck .deck-tl-hide-eye svg {
  width: 12px;
  height: 12px;
}
.deck-editing .sales-deck .deck-tl-bullet:hover .deck-tl-hide-eye {
  opacity: 1;
}
.deck-editing .sales-deck .deck-tl-hide-eye:hover {
  color: #ef4444; /* red — this click removes the bullet from the slide */
}
.deck-editing .sales-deck .deck-tl-bullet:has(.deck-tl-hide-eye):hover::before {
  opacity: 0; /* fade the dot while the eye occupies its spot */
}
/* The eye must not eat a click while its own bullet is being text-edited. */
.deck-editing .sales-deck .deck-tl-bullet:has(.deck-tl-bullet-text[contenteditable="true"]) .deck-tl-hide-eye {
  opacity: 0;
  pointer-events: none;
}
/* While editing a bullet's text: a caret, a soft focus ring, and no hover
   highlight competing with the selection. */
.deck-editing .sales-deck .deck-tl-bullet-text[contenteditable="true"] {
  cursor: text;
  outline: 1.5px solid rgba(136, 98, 226, 0.7);
  outline-offset: 2px;
  border-radius: 3px;
  white-space: pre-wrap;
}
.deck-editing .sales-deck .deck-tl-bullet:has(.deck-tl-bullet-text[contenteditable="true"]):hover::after {
  background: transparent;
}
/* Inline rename inside the "Features this week" modal — a single click makes a
   row's text editable (see editFeatureText). The modal lives outside .sales-deck,
   so this rule is unscoped. The modal list carries its own p-3 padding, so this
   outward-offset ring has room and isn't clipped by its overflow-y-auto. */
[data-feature-text][contenteditable="true"] {
  cursor: text;
  outline: 1.5px solid rgba(136, 98, 226, 0.8);
  outline-offset: 3px;
  border-radius: 3px;
  white-space: pre-wrap;
}
/* SortableJS states for the in-card bullet reorder. */
.sales-deck .deck-tl-bullet.deck-tl-drag-ghost {
  opacity: 0.3;
}
.sales-deck .deck-tl-bullet.deck-tl-drag-item {
  background: #fff;
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
}
/* ————— Unstaffed week: a marker that is also a drop target —————
   A week with nothing scheduled renders through the same card partial as a
   real one (see _timeline_card), so a feature dragged out of another week has
   somewhere to land. Until one does it keeps the dashed .deck-noalloc marker
   and its data-deck-chrome, so it still reads as the editor's "this week is
   unstaffed" warning and never reaches present mode or a published deck.

   Its bullet list is stretched across the marker and taken OUT of flow:
   SortableJS drops onto the list's rect, and an empty <ul> has none — this way
   the whole marker is the target and an unstaffed week keeps exactly the size
   it had before it became droppable. */
.deck-editing .sales-deck .deck-tl-empty > .deck-tl-scroll {
  position: absolute;
  inset: 0;
  /* Drop the scroller's negative gutter margins here: with inset they would
     push the target out past the marker and over its neighbours. */
  margin: 0;
}
.deck-editing .sales-deck .deck-tl-empty > .deck-tl-scroll > ul.deck-tl-bullets {
  min-height: 100%;
}
/* The moment a feature is in the list the marker is a card, so it reads as one
   right away — the next server render emits the real card partial and these
   stop applying. Specificity has to clear the editor's red .deck-noalloc rule
   (.deck-editing .sales-deck .deck-noalloc), hence the same two-class prefix. */
.deck-editing .sales-deck .deck-tl-empty:has(li[data-bullet]) > .deck-tl-scroll {
  position: static;
}
.deck-editing .sales-deck .deck-tl-empty:has(li[data-bullet]) {
  background: var(--color-deck-card);
  border: 1px solid color-mix(in srgb, var(--color-deck-card-ink) 10%, transparent);
  padding: 10px 12px;
  color: var(--color-deck-card-ink);
  font-size: 10.5px;
  line-height: 1.42;
  text-align: left;
  text-transform: none;
  letter-spacing: normal;
}
.sales-deck .deck-tl-empty:has(li[data-bullet]) .deck-tl-empty-label {
  display: none;
}
/* Presenting straight after a drop, before the row re-renders: the marker still
   carries data-deck-chrome (which the present stage hides), and the feature the
   author just moved there must not vanish out of the pitch. */
.deck-present-stage .deck-tl-empty:has(li[data-bullet]) {
  display: block;
}

/* Slide-level "hide continued" toggle: drop "… (continued)" carryovers — but
   ONLY in a week that still has a visible net-new bullet, so an all-continued
   week never collapses to an empty card. Applied in the editor and present
   mode; the published deck bakes the same rule (Publisher#freeze_html). */
.sales-deck .deck-tl-hide-continued ul.deck-tl-bullets:has(> li:not([data-continued]):not([data-bullet-hidden])) > li[data-continued] {
  display: none;
}

/* ═════════ Free-placed overlay elements (title / text / image) ═════════
   A universal layer on every slide (see _elements_layer / _element). Elements
   are positioned in DESIGN px inside the 1440×810 .sales-deck, so they scale
   with --deck-scale like everything else. All editor affordances (hover
   toolbar, resize handle, blank hint, empty-image button) carry
   data-deck-chrome — dropped by the Publisher — and only light up under
   .deck-editing. */
.sales-deck .deck-elements {
  position: absolute;
  inset: 0;
  z-index: 30;
  pointer-events: none; /* empty areas pass clicks through to the slide beneath */
}
.sales-deck .deck-el {
  position: absolute;
  pointer-events: auto;
  max-width: 1440px;
}
/* Guide line shown while a dragged element is captured by a snap detent
   (data-el-snap-x/y — e.g. the personas venn's circle centers). Created and
   removed by _showSnapGuides during the drag; never persisted. */
.sales-deck .deck-el-snapline {
  position: absolute;
  pointer-events: none;
  background: rgba(184, 235, 134, 0.85); /* the editor caret green */
}
.sales-deck .deck-el-snapline--x {
  top: 0;
  bottom: 0;
  width: 2px;
  margin-left: -1px;
}
.sales-deck .deck-el-snapline--y {
  left: 0;
  right: 0;
  height: 2px;
  margin-top: -1px;
}
/* The always-available canvas-center detent draws its guide dashed, so
   "centered on the slide" reads differently from a solid sibling/declared
   alignment line. */
.sales-deck .deck-el-snapline--center.deck-el-snapline--x {
  background: repeating-linear-gradient(to bottom, rgba(184, 235, 134, 0.85) 0 10px, transparent 10px 18px);
}
.sales-deck .deck-el-snapline--center.deck-el-snapline--y {
  background: repeating-linear-gradient(to right, rgba(184, 235, 134, 0.85) 0 10px, transparent 10px 18px);
}
.sales-deck .deck-el-content {
  outline: none;
  margin: 0;
}
.sales-deck .deck-el-title-text {
  width: max-content;
  max-width: 1360px;
  padding-bottom: 0.14em; /* gradient descender cushion, matches .deck-grad-text */
  white-space: pre-wrap;
  caret-color: #b8eb86;
}
.sales-deck .deck-el-text-body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  caret-color: #b8eb86;
}
/* A free text block carries an author-chosen font color (inline style on the
   content div). Let its bullet dots track that color too, instead of the fixed
   white the shared deck-li-dot list defaults to. Also tighten the inter-bullet
   gap (the shared --li-gap is a generous 0.8em, tuned for the hero design
   slides; at a bumped-up font size that reads as a big margin). --li-gap is in
   em, so it still tracks the element's font size. */
.sales-deck .deck-el-text-body ul.deck-li-dot {
  --dot-color: currentColor;
  --li-gap: 0.3em;
}
.sales-deck .deck-el-image,
.sales-deck .deck-el-img {
  display: block;
  width: 100%;
  height: auto;
}
.sales-deck .deck-el-image-empty {
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 180px;
  padding: 24px;
  border: 1px dashed rgba(255, 255, 255, 0.28);
  border-radius: 10px;
  color: var(--color-deck-faint, #7c7f86);
  font-size: 15px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  cursor: pointer;
}
.deck-editing .sales-deck .deck-el-image-empty:hover {
  border-color: rgba(255, 255, 255, 0.5);
  color: rgba(255, 255, 255, 0.75);
}

/* Editor affordances — only under .deck-editing, on hover / focus-within. They
   fade in fast but HIDE on a ~0.5s delay, so a pointer travelling from the
   element up to the floating toolbar (or out to the corner handle) can't lose
   them mid-transit. The hover-bridges below (::after / ::before) additionally
   keep the pointer "inside" .deck-el while it crosses the gap, so hover doesn't
   actually drop and there's no outline flicker. */
.sales-deck .deck-el-toolbar,
.sales-deck .deck-el-resize {
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.18s ease 0.5s, visibility 0s linear 0.68s;
  /* Above SIBLING element boxes: .deck-el (z auto) creates no stacking
     context, so this z-index competes directly inside the .deck-elements
     layer — without it, paint/hit order is DOM order and a LATER sibling's
     box covering this toolbar steals the hover (its outline lights up and
     these buttons go unclickable). visibility:hidden chrome never hit-tests,
     so an unhovered element's toolbar can't block the box beneath it. */
  z-index: 2;
}
.deck-editing .sales-deck .deck-el:hover,
.deck-editing .sales-deck .deck-el:focus-within,
.deck-editing .sales-deck .deck-el.deck-el-selected {
  outline: 1px dashed rgba(255, 255, 255, 0.45);
  outline-offset: 6px;
}
.deck-editing .sales-deck .deck-el:hover > .deck-el-toolbar,
.deck-editing .sales-deck .deck-el:focus-within > .deck-el-toolbar,
.deck-editing .sales-deck .deck-el.deck-el-selected > .deck-el-toolbar,
.deck-editing .sales-deck .deck-el:hover > .deck-el-resize,
.deck-editing .sales-deck .deck-el:focus-within > .deck-el-resize,
.deck-editing .sales-deck .deck-el.deck-el-selected > .deck-el-resize {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.12s ease 0s, visibility 0s;
}
/* Mid-drag the chrome gets out of the way — the point of the gesture is
   seeing exactly where the box lands (the counter-scaled phone toolbar is
   big enough to hide a third of a slide). Placed after every reveal rule
   above (equal specificity — order wins), covering hover/focus/selected. */
.deck-editing .sales-deck .deck-el.deck-el-dragging > .deck-el-toolbar,
.deck-editing .sales-deck .deck-el.deck-el-dragging > .deck-el-resize {
  opacity: 0;
  visibility: hidden;
}
/* While ANY element drag is live (deck-el-drag-active on the controller
   root), everything else on the canvas is INERT: the touch anchor jumps the
   element out from under the finger, which then rides over OTHER elements —
   end-of-touch hit-tests must not select them, and their editable text
   (whose user-select:text exception would beat the body drag guard) must
   not highlight. pointer-events:none makes hit-tests fall through; the
   !important user-select beats the coarse contenteditable exception. */
.deck-el-drag-active .sales-deck .deck-el:not(.deck-el-dragging),
.deck-el-drag-active .sales-deck .deck-img-wrap {
  pointer-events: none;
}
.deck-el-drag-active .sales-deck [contenteditable] {
  -webkit-user-select: none !important;
  user-select: none !important;
}
.sales-deck .deck-el-toolbar {
  position: absolute;
  bottom: 100%;
  left: 0;
  margin-bottom: 8px;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 4px 6px;
  border-radius: 8px;
  background: var(--color-deck-menu);
  border: 1px solid var(--color-deck-edge);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  white-space: nowrap;
}
/* Transparent bridge over the gap to the element below — hovering it keeps
   .deck-el:hover alive (it's a descendant), so the toolbar never drops out from
   under a pointer moving up to it. */
.sales-deck .deck-el-toolbar::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 12px;
}
/* Toolbar vertical flips (placeElementToolbar in utils/deck_element_toolbar):
   with the element hugging the slide top the default above-the-box spot is
   outside the canvas — clipped by the frame's paint containment — so the bar
   flips BELOW the box (hover bridge flips with it), and if the element also
   reaches the floor it tucks INSIDE the top-left corner. The HORIZONTAL
   position is not a flip: placeElementToolbar writes an inline left offset
   that clamps the bar into the canvas whatever the element's width, so a box
   hanging off the right margin keeps a reachable grip. */
.sales-deck .deck-el--bar-below > .deck-el-toolbar {
  bottom: auto;
  top: 100%;
  margin-bottom: 0;
  margin-top: 8px;
}
.sales-deck .deck-el--bar-below > .deck-el-toolbar::after {
  top: auto;
  bottom: 100%;
}
.sales-deck .deck-el--bar-inside > .deck-el-toolbar {
  bottom: auto;
  top: 6px;
  margin-bottom: 0;
}
/* While the inline selection bar is up for this element's text
   (deck-el-inline-active, set by _markInlineActive), its own toolbar hides —
   the two bars otherwise stack right above the element with near-duplicate
   controls. visibility (not opacity) so the hover/selected reveal rules,
   which only drive opacity, can't resurrect it. */
.sales-deck .deck-el.deck-el-inline-active > .deck-el-toolbar {
  visibility: hidden;
  pointer-events: none;
}
.sales-deck .deck-el-move {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  color: #6b7280;
  cursor: grab;
  /* A finger on the grip must drag the element, never scroll the page — and
     a control gesture may not spawn the iOS selection loupe or callout. */
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.sales-deck .deck-el-move:active {
  cursor: grabbing;
}
.sales-deck .deck-el-move svg {
  width: 15px;
  height: 15px;
}
.sales-deck .deck-el-btn {
  display: grid;
  place-items: center;
  width: 22px;
  height: 20px;
  border-radius: 5px;
  color: #9ca3af;
  cursor: pointer;
  transition: color 0.12s, background 0.12s;
}
.sales-deck .deck-el-btn:hover {
  color: var(--color-deck-ink);
  background: var(--color-deck-chip);
}
.sales-deck .deck-el-btn.deck-el-del:hover {
  color: #f87171;
}
.sales-deck .deck-el-btn svg {
  width: 15px;
  height: 15px;
}
.sales-deck .deck-el-font {
  font-size: 16px;
  line-height: 1;
}
/* The live size, sitting between − and +. Wider than a glyph button because it
   carries up to three digits, and tabular so the number doesn't jiggle as the
   type steps. This is the affordance that makes two hand-placed boxes
   matchable: you read both instead of counting button presses. */
.sales-deck .deck-el-size {
  width: auto;
  min-width: 26px;
  padding: 0 4px;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
/* Pinned = auto-fit off. The underline says "this one will not move on its own"
   without opening the panel — the same read as the colour button's swatch bar. */
.sales-deck .deck-el-size--pinned {
  color: #e5e7eb;
  box-shadow: inset 0 -2px 0 var(--color-deck-accent, #5ec9b4);
}
/* The alignment trigger stacks all four glyphs; the content node's OWN align
   class picks which one shows (content div and toolbar are siblings inside
   .deck-el) — the trigger always displays the element's CURRENT alignment
   with no JS state to keep in sync. No class on the content = left. */
.sales-deck .deck-el-align [data-align-glyph] {
  display: none;
}
.sales-deck .deck-el-content:not(.text-center):not(.text-right):not(.text-justify) ~ .deck-el-toolbar .deck-el-align [data-align-glyph="left"],
.sales-deck .deck-el-content.text-center ~ .deck-el-toolbar .deck-el-align [data-align-glyph="center"],
.sales-deck .deck-el-content.text-right ~ .deck-el-toolbar .deck-el-align [data-align-glyph="right"],
.sales-deck .deck-el-content.text-justify ~ .deck-el-toolbar .deck-el-align [data-align-glyph="justify"] {
  display: block;
}
.sales-deck .deck-el-resize {
  position: absolute;
  right: -7px;
  bottom: -7px;
  display: block;
  width: 14px;
  height: 14px;
  border-radius: 3px;
  background: #b8eb86;
  border: 1.5px solid #0d130d;
  cursor: nwse-resize;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
  /* Touch resize must own the gesture — without this a finger drag on the
     handle scrolls the page instead (the img handle already had it). */
  touch-action: none;
}
/* Invisible padding around the handle — a larger, easier target that also
   overlaps the element so there's no dead gap between them. */
.sales-deck .deck-el-resize::before {
  content: "";
  position: absolute;
  inset: -9px;
}
.sales-deck .deck-el-image .deck-el-resize,
.sales-deck .deck-el-image > .deck-el-resize {
  cursor: ew-resize;
}

/* Elements are edited only in the scroll view — hide their chrome (and the
   caption "+") in the overview grid, where clicks navigate instead. */
.deck-view-overview .deck-el-toolbar,
.deck-view-overview .deck-el-resize,
.deck-view-overview .deck-el-add {
  display: none !important;
}
.deck-view-overview .sales-deck .deck-el:hover,
.deck-view-overview .sales-deck .deck-el:focus-within {
  outline: none;
}

/* Blank-slide hint — editor-only, and only until the first element is added. */
.sales-deck .deck-blank-hint {
  display: none;
}
.deck-editing .sales-deck .deck-blank-hint {
  display: grid;
}
.deck-editing .sales-deck:has(.deck-el) .deck-blank-hint {
  display: none;
}

/* Floating inline-formatting toolbar (bold / italic / underline / strike /
   color) that follows a text selection in an editable zone or text block.
   position:fixed, placed each selectionchange by sales-deck#_syncInlineBar, so
   it rides above the scaled canvas. The shared color picker (z 999999) still
   opens on top of it. */
.deck-inline-bar {
  /* Lives inside the vv-pinned layer (1 layer px ≡ 1 screen px): absolute
     within it, pointer-events restored (the layer itself is inert). */
  position: absolute;
  pointer-events: auto;
  z-index: 10000;
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 3px;
  border-radius: 9px;
  background: var(--color-deck-menu);
  border: 1px solid #303237;
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.6);
  /* Selection-inert: a touch on the bar must never start an iOS text
     selection/loupe or shift the one it's formatting. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
/* The keyboard-accessory dock: pure CSS inside the vv layer — the layer's
   one transform keeps this glued above the keyboard through every pan and
   zoom with nothing to recalculate. */
.deck-inline-bar.deck-bar-docked {
  top: auto;
  bottom: 6px;
  left: 50%;
  transform: translateX(-50%);
}
.deck-inline-bar[hidden] {
  display: none;
}
.deck-inline-btn {
  display: grid;
  place-items: center;
  min-width: 28px;
  height: 28px;
  padding: 0 5px;
  border-radius: 6px;
  color: #c7cbd1;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.12s, background 0.12s;
}
.deck-inline-btn:hover {
  color: #fff;
  background: var(--color-deck-edge);
}
.deck-inline-btn[aria-pressed="true"] {
  color: #fff;
  background: #33353b;
}
.deck-inline-btn u {
  text-underline-offset: 2px;
}
.deck-inline-btn svg {
  width: 17px;
  height: 17px;
}
/* Gradient button: a small brand-gradient swatch (parallels the color "A"). The
   deck-* theme tokens live on :root, so they resolve here outside .sales-deck. */
.deck-inline-grad span {
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 5px;
  background: linear-gradient(97deg, var(--color-deck-grad-from), var(--color-deck-grad-to));
}
.deck-inline-sep {
  width: 1px;
  height: 18px;
  margin: 0 2px;
  background: #34363c;
}

/* Element text-alignment popover — the shared top-layer panel the toolbar's
   align trigger opens (positioned by _openPopoverAt in viewport coords).
   Overrides the UA [popover] defaults: inset:0 + margin:auto would center
   it, `background: canvas` paints an opaque slab. Selection-inert so a
   touch on it can never start an iOS text selection/loupe while the
   keyboard is up. */
.deck-align-popover {
  position: fixed;
  inset: auto;
  margin: 0;
  padding: 3px;
  gap: 2px;
  border: 1px solid var(--color-deck-edge);
  border-radius: 8px;
  background: var(--color-deck-menu);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.deck-align-popover:popover-open {
  display: flex;
}
.deck-align-option {
  display: grid;
  place-items: center;
  width: 28px;
  height: 26px;
  border-radius: 6px;
  color: #c7cbd1;
  cursor: pointer;
  transition: color 0.12s, background 0.12s;
}
.deck-align-option:hover {
  color: #fff;
  background: var(--color-deck-edge);
}
.deck-align-option[aria-pressed="true"] {
  color: #fff;
  background: #33353b;
}
.deck-align-option svg {
  width: 16px;
  height: 16px;
}

/* Element size panel — same top-layer popover recipe as the alignment choices,
   laid out as a short form rather than a row of glyphs. */
.deck-size-popover {
  position: fixed;
  inset: auto;
  margin: 0;
  padding: 10px 12px;
  border: 1px solid var(--color-deck-edge);
  border-radius: 8px;
  background: var(--color-deck-menu);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
  color: #c7cbd1;
  font-size: 12px;
}
.deck-size-popover:popover-open {
  display: grid;
  gap: 8px;
}
.deck-size-field {
  display: flex;
  align-items: center;
  gap: 8px;
}
.deck-size-field input {
  width: 64px;
  padding: 3px 6px;
  border: 1px solid var(--color-deck-edge);
  border-radius: 6px;
  background: #16181c;
  color: #fff;
  font-variant-numeric: tabular-nums;
}
.deck-size-unit {
  color: #7d838c;
}
.deck-size-auto {
  display: flex;
  align-items: center;
  gap: 7px;
  cursor: pointer;
}
/* Hidden for titles, which have no auto-fit pass to hand the box back to.
   `hidden` alone loses to the flex display above, so state it here. */
.deck-size-auto[hidden] {
  display: none;
}

/* ————— Mobile & touch ————— */
/* Editor chrome (header, caption bars, modals, present overlay) stays usable
   on phones/tablets: controls wrap instead of overlapping, hit targets grow to
   finger size, and hover-revealed affordances become visible (touch has no
   hover). The slide CANVAS itself still scales with the viewport — pinch-zoom
   is the intended way to do fine-grained in-canvas editing on a phone. All
   rules gate on coarse-pointer / narrow-viewport media queries, so desktop is
   untouched. NB: this sheet is unlayered, so these rules beat Tailwind's
   layered utilities (sticky, opacity-0, h-*, gap-*) without !important. */

/* Phones and short landscape viewports: the header scrolls with the page
   instead of pinning — wrapped onto several rows it would otherwise eat a
   third of the screen. `relative`, NOT `static`: static discards the header's
   z-40 (z-index needs a positioned element) while backdrop-blur still forces
   a stacking context, so the whole header subtree — dropdown panels included,
   whatever their inner z-index — painted UNDER the content-visibility slide
   frames (positioned stacking contexts that paint after in-flow content).
   Relative un-pins sticky with identical geometry but keeps z-40 alive,
   preserving the desktop header-above-frames stacking. */
@media (max-width: 639px), (max-height: 500px) {
  .deck-topbar {
    position: relative;
  }
}

/* Floating mobile undo/redo cluster (markup in sales_deck_previews/
   show.html.erb) — lower right corner; redo pops up above undo. Touch
   devices always show undo — it pops the app-level journal when idle and
   the native text stack while editing (sales-deck#undoTap); disabled state
   is managed by undoSyncFab. Fine-pointer devices keep Cmd/Ctrl+Z — the
   pill never renders for them. pointer-events re-enables from the vv
   layer's none (same as .deck-jump-fab); the chrome-motion fade lives with
   the tab's rule below so the right-edge chrome hides as one. */
.deck-undo-fab,
.deck-redo-fab {
  display: none;
}
[data-deck-undo-cluster] {
  bottom: max(0.5rem, env(safe-area-inset-bottom));
  right: max(0.5rem, env(safe-area-inset-right));
  transition: bottom 160ms ease; /* the inline-bar dodge below glides, not jumps */
}
/* While the inline formatting bar is docked along the bottom (text editing,
   keyboard up), the corner would sit under its right end — lift the cluster
   above the bar. Sibling combinator: the bar precedes the cluster in the vv
   layer. In CSS (not inline style) exactly so this override can win. */
.deck-inline-bar.deck-bar-docked:not([hidden]) ~ [data-deck-undo-cluster] {
  bottom: 62px;
}
@media (pointer: coarse) {
  .deck-undo-fab {
    display: flex;
    pointer-events: auto;
    transition: opacity 160ms ease;
  }
  /* :not([hidden]) — the display:flex would otherwise beat the hidden
     attribute (undoSyncFab toggles it as redoable rises and falls). */
  .deck-redo-fab:not([hidden]) {
    display: flex;
    pointer-events: auto;
    transition: opacity 160ms ease;
  }
}

/* Preview-menu copy is device-conditional: the Live viewer opens on THIS
   device, so a phone editor is previewing the client's MOBILE experience
   while a desktop editor sees the desktop one — the description must not
   claim "mobile" on a desktop. Coarse swaps the fine-pointer wording out. */
[data-preview-desc-coarse] {
  display: none;
}
@media (pointer: coarse) {
  [data-preview-desc-coarse] {
    display: inline;
  }
  [data-preview-desc-fine] {
    display: none;
  }
}

/* Caption bordered controls — the play-circle Preview trigger
   ([data-preview-toggle]) and the segmented caption pills ([data-caption-pill]
   via _caption_segments: timeline lanes, constraint model) — share ONE 30px
   control height on touch. Not the 36px icon-target height: bordered controls
   carry visual weight the invisible icon-padding halos don't, and 36 read as
   oversized (Jon). Segment buttons stretch to fill the pill so a finger
   anywhere in its height lands; the circle grows both axes to stay round. */
@media (pointer: coarse) {
  [data-caption-pill] {
    min-height: 30px;
    font-size: 11px;
    align-items: center;
  }
  [data-caption-pill] > button {
    align-self: stretch;
  }
  [data-preview-toggle] {
    height: 30px;
    width: 30px;
  }
}

/* Floating slide jump (markup in sales_deck_previews/show.html.erb, behavior
   in utils/deck_slide_jump.js). A right-edge tab in the mobile scroll view —
   the editor's answer to the client viewer's hold-to-pick arrows: holding it
   streams the finger over the list and release jumps; a quick tap opens the
   same list to browse and tap. Hidden everywhere else: desktop has the
   overview grid and real scrollbars. */
.deck-jump-fab {
  display: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
@media (pointer: coarse) {
  [data-controller~="sales-deck"].deck-view-scroll .deck-jump-fab {
    display: flex;
    /* The tab rides the pointer-events:none vv layer (pinch keeps it 1×);
       pointer-events inherits, so opt back in explicitly. */
    pointer-events: auto;
    /* The hold gesture needs every touchmove cancelable — no native pan may
       start from the tab (a 32px edge sliver is no loss of scroll surface). */
    touch-action: none;
    transition: opacity 160ms ease;
  }
  /* Hidden while the page travels (scroll or pinch), back at viewport settle
     — _markChromeMotion / _settleCaptionPin toggle the root class. Same
     specificity as the display rule above, so order decides: keep this one
     AFTER it or the pointer-events re-enable wins over the disable. The
     undo/redo fabs stack directly above the tab, so they fade with it —
     one right-edge cluster, not a tab that vanishes under staying pills. */
  [data-controller~="sales-deck"].deck-chrome-motion :is(.deck-jump-fab, .deck-undo-fab, .deck-redo-fab) {
    opacity: 0;
    pointer-events: none;
  }
  /* Finger-size halo: the visual stays a slim tab, near-misses still grab it. */
  .deck-jump-fab::before {
    content: "";
    position: absolute;
    inset: -12px -4px -12px -16px;
  }
}
/* The list panel. During a HOLD the pressed tab owns the whole gesture
   (touch targets freeze at touchstart), so the panel stays display-only
   pointer-events:none chrome — only the tap-opened browse mode
   (.deck-jump-tap) makes it interactive. */
.deck-jump-panel {
  position: fixed;
  z-index: 45;
  top: 50%;
  transform: translateY(-50%);
  right: calc(env(safe-area-inset-right, 0px) + 2.75rem);
  display: none;
  flex-direction: column;
  width: min(76vw, 19rem);
  max-height: 72svh;
  background: rgba(10, 11, 14, 0.95);
  border: 1px solid rgba(255, 255, 255, 0.22);
  border-radius: 14px;
  box-shadow: 0 14px 44px rgba(0, 0, 0, 0.55);
  overflow: hidden;
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.deck-jump-panel.deck-jump-on {
  display: flex;
}
.deck-jump-panel.deck-jump-tap {
  pointer-events: auto;
}
.deck-jump-list {
  position: relative;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 6px 0;
  scrollbar-width: none;
}
.deck-jump-list::-webkit-scrollbar {
  display: none;
}
.deck-jump-item {
  display: flex;
  align-items: baseline;
  gap: 10px;
  width: 100%;
  padding: 9px 14px 9px 11px;
  border-left: 3px solid transparent;
  font-size: 13px;
  line-height: 1.3;
  text-align: left;
  color: rgba(255, 255, 255, 0.78);
}
.deck-jump-item.deck-jump-current {
  border-left-color: rgba(255, 255, 255, 0.9);
  color: #fff;
}
.deck-jump-item.deck-jump-hot,
.deck-jump-tap .deck-jump-item:active {
  background: rgba(255, 255, 255, 0.17);
  color: #fff;
}
/* Hidden-from-deck slides stay listed (they still render in the editor) but
   read as muted. */
.deck-jump-item.deck-jump-muted {
  opacity: 0.45;
}
.deck-jump-num {
  flex: none;
  min-width: 1.6em;
  text-align: right;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: rgba(255, 255, 255, 0.42);
}
.deck-jump-item.deck-jump-hot .deck-jump-num,
.deck-jump-item.deck-jump-current .deck-jump-num {
  color: rgba(255, 255, 255, 0.8);
}
.deck-jump-title {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* PDF export zoom dialog: cap to the viewport and scroll. The slide preview
   makes it taller than a landscape phone — the slider sat below the fold of
   an unscrollable dialog. Ungated: the cap only engages when the dialog
   exceeds the viewport, so desktop never changes. */
[data-deck-export-zoom-target="exportZoomModal"] {
  max-height: calc(100dvh - 1rem);
  overflow-y: auto;
}

/* Narrow/portrait: the zoom slider takes its own (second) row — zoom modes
   above it, the pages toggle below — instead of squeezing between the two
   segmented controls. DOM order already reads modes → slider → pages. */
@media (max-width: 639px) {
  [data-deck-export-zoom-target="exportZoomModal"] [data-deck-export-zoom-target="exportZoomSlider"] {
    flex-basis: 100%;
  }
}

/* Portrait phones: the overview/scroll switcher goes away — the overview
   thumbnail grid is unreadable at portrait width, and the controller
   auto-leaves overview for scroll there (_leaveOverviewOnPortrait). Rotating
   to landscape brings the switcher (and overview's usefulness) back. */
@media (pointer: coarse) and (orientation: portrait) {
  .deck-topbar .deck-view-switch {
    display: none;
  }
}

/* Category chrome on phones — BOTH orientations (landscape keeps the header
   composition, so its scroll view needs the same controls). Portrait's
   original reason: with overview unreachable there, the scroll-view "category
   chrome lives in overview" hides would strand category management entirely —
   lift them: the reorder-categories picker returns to the header (the
   +Category pill stays folded into + via a higher-specificity hide), and
   section headers render in the scroll so a newly added category has a
   visible landing. */
@media (pointer: coarse) and (max-height: 500px), (pointer: coarse) and (orientation: portrait) {
  /* inline-FLEX, not inline-block: this un-hide outranks the coarse
     flex-centering rule below (two classes vs class+element), and a plain
     inline-block button left-hugs its preflight display:block SVG once the
     touch min-width inflates it — the round-2 icon-centering bug again. */
  .deck-view-scroll .deck-category-control {
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .deck-view-scroll .deck-section > .group\/section {
    display: flex;
  }
  /* Extra air above each category chip so groups separate from the slide
     rhythm (the scroll view zeroes the desktop section margin). */
  .deck-view-scroll .deck-section {
    margin-top: 1rem;
  }
}

/* Bulk-select action bar: bottom-docked on phones. The wrapped header no
   longer has a fixed height for the top-[68px] slot to clear — and thumbs
   live at the bottom of the screen anyway. */
@media (max-width: 639px) {
  [data-deck-bulk-target="bulkBar"] {
    top: auto;
    bottom: max(1rem, env(safe-area-inset-bottom, 0px));
  }
  /* Portrait/narrow only: "+ Category" folds into the + menu (its markup
     shows the New-category row + bare-+ label below sm). Unlayered on
     purpose: the coarse category un-hide for scroll view
     (.deck-view-scroll .deck-category-control { display: inline-flex })
     would beat a Tailwind max-sm:hidden; this out-specifies it. Landscape
     phones stay ≥640px, keep both full pills, and never enter this block. */
  .deck-topbar .deck-category-control[data-action="deck-structure#addSection"] {
    display: none;
  }
  /* The compact "+" summary: center the glyph regardless of pointer type
     (the coarse blanket centers it on devices, but narrow fine-pointer
     windows fall through to block layout and left-hug), and drop the pill's
     .13em letter-spacing — on a single glyph the trailing tracking is a
     one-sided phantom that shoves the + left of center. */
  .deck-topbar details[data-sales-deck-target="addMenu"] > summary {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    letter-spacing: normal;
  }
}

@media (max-width: 639px), (pointer: coarse) and (max-height: 500px) {
  /* Phone header composition (Jon's spec): row 1 = project switcher · Share ·
     Present; row 2 = edit (bulk) · reorder · refresh · download PDF · + ·
     slide count. Flex `order` re-slots the DOM-ordered cluster; the ::after
     break below pins the two-line structure. Landscape phones get the SAME
     composition (the coarse+short clause in the gate) plus the grid/list view
     switcher opening row 2 (order 9; portrait hides it) and the full
     "+ Category"/"+ Slide" pills back in place of portrait's folded + (their
     hide/compact rules are width-gated, so ≥640px landscape never sees them).
     Short DESKTOP windows are excluded on purpose (fine pointer fails the
     second clause; width alone still applies) so they keep their one-row
     order. */
  .deck-topbar details[data-deck-project-switcher-target="menu"] {
    order: 1;
    /* One above the addMenu lift (60) below: both are lifted siblings, and the
       later-in-DOM +Slide pill would otherwise paint over this menu's open
       panel (the panel's own z-50 only counts inside its details subtree). */
    z-index: 61;
  }
  .deck-topbar button[data-action="deck-publish#open"] {
    order: 2;
  }
  .deck-topbar .deck-present-menu {
    order: 3;
    /* Highest of the lifted header menus: its panel drops right-aligned over
       row 2, where the later-in-DOM + menu's lift (60) would otherwise paint
       the "+" pill through the open panel. */
    z-index: 62;
  }
  .deck-topbar .deck-view-switch {
    order: 9;
  }
  /* Row 2 (Jon's order): edit · reorder · refresh · download · +. The two add
     pills can't share the row with four 40px icons on a portrait phone
     (~408px of content vs ~366px usable), so "+ Category" folds into the
     +Slide menu (phone-only "New category" first row) and the summary
     collapses to a bare "+" below sm — one generic add button. */
  .deck-topbar button[data-action="deck-bulk#toggleBulkMode"] {
    order: 10;
  }
  .deck-topbar button[data-action="deck-categories#openCategoriesModal"] {
    order: 11;
  }
  .deck-topbar button[data-action="deck-structure#regenerateDeck"] {
    order: 12;
  }
  .deck-topbar button[data-action="deck-publish#exportPdf"] {
    order: 13;
  }
  /* Visible in landscape (full pill, order 14 → between download and +Slide);
     portrait folds it into the + menu via the width-gated hide below. */
  .deck-topbar .deck-category-control[data-action="deck-structure#addSection"] {
    order: 14;
  }
  .deck-topbar details[data-sales-deck-target="addMenu"] {
    order: 15;
  }
  /* The slide count squeezes onto the end of row 2 ("40/41 slides" when some
     are hidden — the slash form stays narrow enough). Last on purpose: + stays
     the terminal action, and on the tightest phones it's this info pill that
     wraps to the next line, not the add button. */
  .deck-topbar [data-sales-deck-target="counter"] {
    display: inline-block;
    order: 16;
  }
  .deck-topbar [data-controller="deck-presence"] {
    order: 17;
  }
  .deck-topbar details[data-deck-project-switcher-target="menu"] > summary {
    max-width: min(260px, calc(100vw - 160px));
  }
  /* The panel is wider than the capped pill — keep it inside the phone
     viewport (it's left-flush with the row-1 pill, so anchor left). */
  .deck-topbar details[data-deck-project-switcher-target="menu"] > div {
    left: 0;
    right: auto;
    max-width: calc(100vw - 24px);
  }
  /* The static (scrolling) phone header loses the sticky header's implicit
     stacking, so the +Slide menu panel painted UNDER the slide frames that
     follow it in the DOM — lift the whole details subtree above the page. */
  .deck-topbar details[data-sales-deck-target="addMenu"] {
    z-index: 60;
  }
  /* Pin the two-line structure: a full-width break between Present (3) and
     the row-2 controls, so slack (short project names, landscape width)
     never floats row-2 buttons up beside the switcher pill. */
  .deck-topbar > div.flex-wrap::after {
    content: "";
    order: 4;
    flex-basis: 100%;
    height: 0;
  }
}

/* Landscape phones only: the switcher can breathe — row 1 has the width for
   a longer project name and the panel can show more of each name. */
@media (pointer: coarse) and (max-height: 500px) and (min-width: 640px) {
  .deck-topbar details[data-deck-project-switcher-target="menu"] > summary {
    max-width: min(420px, calc(100vw - 160px));
  }
  .deck-topbar details[data-deck-project-switcher-target="menu"] > div {
    width: 20rem;
  }
}

/* Slide caption bar: wrap onto extra rows instead of overflowing the frame
   column — chips + controls easily exceed a phone-width frame (touch), an
   overview thumbnail column on a tablet (coarse), or a narrow desktop
   window (max-width). */
@media (pointer: coarse), (max-width: 639px) {
  [data-slide-caption] {
    flex-wrap: wrap;
    /* Breathing room between the wrapped lines (title row / icon row / lane
       row) — 2px read as cramped, 8px still jumbled on timeline slides
       (Jon). 12px inter-row, while the caption's mb-2 (8px) to the canvas
       stays SMALLER — the last control row reads as anchored to the slide
       it controls rather than to the rows above. */
    row-gap: 12px;
  }
  [data-slide-controls] {
    flex-wrap: wrap;
    /* Without a cap, the cluster — a flex ITEM of the caption — takes its
       max-content width (the timeline's special controls alone are ~900px),
       so its own flex-wrap never engages and the controls run off-screen.
       Capping at the caption's width forces the internal wrap. shrink-0 on
       the element stays correct: items wrap rather than squeeze. */
    max-width: 100%;
    /* Wrapped control rows hang from the caption's RIGHT edge — the same
       alignment line the desktop single-row layout ends on — instead of
       stacking ragged-left under the title, which read as jumbled (Jon).
       On an unwrapped caption the span is content-width (ml-auto pushes it
       right), so this only shapes the wrapped lines. */
    justify-content: flex-end;
  }
  /* Related special controls travel in nowrap GROUP spans (see
     _timeline_controls) so rows break between groups. A group that alone
     exceeds the row (e.g. a many-phase deck's select stack) may wrap
     internally rather than overflow. */
  [data-caption-group] {
    max-width: 100%;
    flex-wrap: wrap;
  }
  /* The separator between the standard icons and the special controls
     dangles at a row's end once the groups wrap to their own rows. */
  [data-slide-special-controls] > span[aria-hidden] {
    display: none;
  }
  /* One-line fit for the timeline caption on phones: with the desktop
     gap-2.5 (10px) the icon cluster plus the Both/Design/Dev pill (11px
     coarse type) overshoots a ~390px caption, wrapping the pill to its own
     row. Slightly tighter gaps and tracking claw that back so the pill rides
     the icon row. (The 26px net-zero icon hit boxes overlap the 8px gap by
     1px per side — hit-testing still lands the nearer icon.) */
  [data-slide-controls],
  [data-caption-group] {
    gap: 8px;
  }
  [data-caption-pill] {
    letter-spacing: 0.07em;
  }
}

@media (pointer: coarse) {
  /* Header controls: finger-size targets. Blanket min sizes are safe here —
     icon buttons grow around their centered glyph, text pills are already
     wider than 40px, and the full-width rows inside the + Slide dropdown just
     get comfortably taller. The slide-count span joins the lists so its pill
     matches the buttons' height instead of sitting shorter in the row. */
  .deck-topbar :is(button, summary, select, a, [data-sales-deck-target="counter"]) {
    min-height: 40px;
    min-width: 40px;
  }
  /* Explicit flex centering on every header control. Vertically, buttons
     center native but <summary> doesn't (block layout leaves "+ Slide" at the
     top). Horizontally, Tailwind preflight blockifies svg (display:block), so
     a min-width-inflated icon button can't center its glyph via the button's
     text-align — block children ignore it and hug the left padding edge. The
     menu-row override below opts its block rows back out. */
  .deck-topbar :is(button, summary, a, [data-sales-deck-target="counter"]) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  /* Dropdown panels can outgrow a phone screen once their rows are 40px tall. */
  .deck-topbar details > div {
    max-height: min(70vh, 480px);
    overflow-y: auto;
  }
  /* Menu rows opt OUT of the blanket min sizes and flex centering — their
     labels are left-aligned, and the truncate ellipsis on template names only
     works in block layout (a flex container won't ellipsize a bare text
     node). Balanced block padding grows the touch target instead, keeping the
     text vertically centered by construction. */
  .deck-topbar details > div button {
    display: block;
    min-height: auto;
    min-width: auto;
    padding-block: 10px;
  }
  /* Project-switcher rows are flex anchors (truncating name span + current-✓
     svg), not bare-text buttons: keep row flex, restore the start alignment
     the blanket centering stole, and grow the touch target the same way. */
  .deck-topbar details > div a[data-deck-project-option] {
    display: flex;
    justify-content: space-between;
    min-height: auto;
    min-width: auto;
    padding-block: 10px;
  }
  /* Sub-16px inputs make iOS Safari zoom the page on focus. */
  .deck-topbar details input {
    font-size: 16px;
  }

  /* Caption controls: NET-ZERO hit-box growth. The earlier padding:10px /
     margin:-3px pair left +7px of footprint per side, so the cluster ran
     wider than desktop and its last icon hung past the caption's right
     alignment line ("alignment is a bit off" — no extra padding allowed
     here). padding:5px/margin:-5px adds no layout size at all: icons sit
     exactly where the bare desktop layout puts them, and the invisible
     26px hit boxes just meet across the 10px gap without overlapping. */
  /* The bordered play-circle opts out: it sizes itself to the shared 30px
     control height above — a padding/negative-margin halo on a VISIBLE
     border would overlap the neighboring icons' hit boxes with chrome. */
  [data-slide-controls] > button,
  [data-slide-controls] > details > summary:not([data-preview-toggle]),
  [data-slide-controls] [data-sales-deck-target="lockControl"] > button {
    padding: 5px;
    margin: -5px;
  }
  /* Slide-type-specific caption controls (phase pickers, toggles…) come from
     per-slide partials — give their fields a uniform touch height. Any
     summary among them needs the same explicit centering as the header's.
     Segmented-pill buttons are EXEMPT: [data-caption-pill] pins the shared
     30px pill height (36 read as oversized, Jon) and this floor was silently
     re-inflating the lane toggle to 36px next to the 30px Preview pill. */
  [data-slide-special-controls] :is(button, select, summary):not([data-caption-pill] *) {
    min-height: 36px;
  }
  [data-slide-special-controls] summary {
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  /* Rows inside the caption "+" (add element) dropdown: balanced padding, not
     min-height — a forced min-height top-aligns the label in block layout. */
  [data-slide-controls] details > div button {
    padding-block: 10px;
  }

  /* Touch-drag reorders the slide instead of scrolling the page (SortableJS
     fallback mode). The padding/margin pair fattens the grab target. */
  [data-drag-handle] {
    touch-action: none;
    padding: 8px;
    margin: -8px;
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
  }

  /* Touch-draggable rows: iOS may begin a text selection under the moving
     finger (magnifier loupe over the role name / bullet text) even though the
     drag owns the gesture. Selection is meaningless on these rows on a phone
     — EXCEPT inside a field actively being edited, which must keep its caret
     and selection handles. */
  .deck-editing .sales-deck [data-corner-role],
  .deck-editing .sales-deck .deck-tl-bullet:has(.deck-tl-hide-eye),
  .deck-editing .sales-deck .deck-logo-cell {
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
  }

  /* "Good company" logo tiles: finger-size chrome. The whole tile is the
     touch drag surface (initLogoSortable's delayOnTouchOnly mode), and the
     grip/edit/✕ circles grow 20→28px with invisible ::before halos (safe:
     the deck editor drops data-tooltip pseudos on touch, freeing the arrow
     ::before). Halos stay TIGHT horizontally between edit and ✕ — at 8px
     apart, full halos would overlap and mis-route deletes — and generous on
     every free side. */
  .deck-editing .sales-deck :is(.deck-logo-grip, .deck-logo-edit, .deck-logo-del) {
    height: 1.75rem;
    width: 1.75rem;
  }
  .deck-editing .sales-deck :is(.deck-logo-grip, .deck-logo-edit, .deck-logo-del) svg {
    height: 1rem;
    width: 1rem;
  }
  .deck-editing .sales-deck .deck-logo-actions {
    gap: 0.5rem;
  }
  .deck-editing .sales-deck :is(.deck-logo-edit, .deck-logo-del) {
    position: relative;
  }
  .deck-editing .sales-deck :is(.deck-logo-edit, .deck-logo-del)::before {
    content: "";
    position: absolute;
    inset: -10px -4px;
  }
  .deck-editing .sales-deck .deck-logo-grip::before {
    content: "";
    position: absolute;
    inset: -10px;
  }
  .deck-editing .sales-deck [data-corner-role] [contenteditable="true"],
  .deck-editing .sales-deck .deck-tl-bullet [contenteditable="true"] {
    -webkit-user-select: text;
    user-select: text;
  }

  /* Long-pressing an IMAGE (aiming for the grip and grazing the img, or the
     lift sliding the element under a held finger) pops iOS's share/preview
     sheet. -webkit-touch-callout inherits, so the whole element/slot subtree
     goes callout- and selection-inert; text being ACTIVELY edited gets its
     caret and selection handles back (callout stays off — no share sheet
     mid-edit either). */
  .deck-editing .sales-deck .deck-el,
  .deck-editing .sales-deck .deck-img-wrap {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
  }
  .deck-editing .sales-deck .deck-el [contenteditable="true"] {
    -webkit-user-select: text;
    user-select: text;
  }

  /* Product Team Model role rows: the drag grip is hover-revealed on desktop
     (keeps the columns clean), but touch has no hover — pin it visible while
     editing so it's obvious the rows can be reordered. Present mode and
     published decks are untouched: the stage canvas lives outside
     .deck-editing and the publisher strips the grip (data-deck-chrome).
     touch-action:none lets the touch drag reorder instead of scrolling. */
  .deck-editing .sales-deck .deck-role-grip {
    opacity: 1;
    visibility: visible;
    touch-action: none;
  }

  /* Hover-revealed controls have no reveal gesture on touch — show them.
     Category delete + saved-template delete keep their muted resting color,
     so "visible" stays quiet. */
  button[data-action="deck-structure#deleteSection"],
  button[data-action="deck-templates#deleteTemplate"] {
    opacity: 1;
  }

  /* Import browser: the Link/Copy pills are hover-revealed on desktop. Show
     them without the dark hover scrim so thumbnails stay readable. */
  .deck-import-thumb .deck-import-actions {
    opacity: 1;
    background: transparent;
  }

  /* One finger on a framed image scrolls the page (the wrap's base
     touch-action:none would trap it); reframing is a two-finger drag
     (deck-image-framing#onImageTouchStart). pan-y — not auto — keeps the vertical
     page scroll while excluding native pinch-zoom from sequences that start
     on the object, so the second finger's claim wins deterministically
     instead of racing the browser. */
  .deck-editing .sales-deck .deck-img-wrap {
    touch-action: pan-y;
  }

  /* Free-placed element BODIES stay transparent to gestures: one finger
     scrolls, two fingers pinch-zoom — exactly like the rest of the page
     (manipulation only drops double-tap zoom, which would fight tap-to-edit).
     Moving is the GRIP's job (touch-action: none there): with the body as a
     drag surface, a pinch that started on an element moved it instead of
     zooming. Mouse body-drag is unaffected (touch-action is touch-only;
     elementDragStart ignores touch pointers). */
  .deck-editing .sales-deck .deck-el {
    touch-action: manipulation;
  }

  /* Chrome reveal on touch is SELECTION-ONLY. iOS latches :hover on whatever
     was last touched (sticky hover), so the hover reveals would re-show a
     dropped element's toolbar the moment the drag released — and keep a
     PREVIOUS element's chrome lit next to the newly selected one. On coarse
     pointers only .deck-el-selected / .deck-img-selected reveal; tap again
     to bring the controls back. Desktop hover behavior is untouched. */
  .deck-editing .sales-deck .deck-el:hover:not(.deck-el-selected),
  .deck-editing .sales-deck .deck-el:focus-within:not(.deck-el-selected) {
    outline: none;
  }
  .deck-editing .sales-deck .deck-el:hover:not(.deck-el-selected) > .deck-el-toolbar,
  .deck-editing .sales-deck .deck-el:focus-within:not(.deck-el-selected) > .deck-el-toolbar,
  .deck-editing .sales-deck .deck-el:hover:not(.deck-el-selected) > .deck-el-resize,
  .deck-editing .sales-deck .deck-el:focus-within:not(.deck-el-selected) > .deck-el-resize {
    opacity: 0;
    visibility: hidden;
  }
  .deck-editing .sales-deck .deck-img-wrap:hover:not(.deck-img-selected) .deck-img-controls,
  .deck-editing .sales-deck .deck-img-wrap:hover:not(.deck-img-selected) .deck-img-resize {
    opacity: 0;
  }

  /* Element toolbar at finger size: counter-scale against the canvas
     transform AND the live pinch level (--vv-scale, mirrored from
     visualViewport by the controller) so grip / font ± / type / delete paint
     at constant SCREEN px at any canvas scale and any pinch zoom — touch
     size, never dynamic. The origin's x stays at 0 (the bar's own left edge)
     so the painted bar grows RIGHT from the offset placeElementToolbar
     computed — that offset is already clamped against the painted width. */
  .deck-editing .sales-deck .deck-el-toolbar {
    transform: scale(calc(1 / (var(--deck-scale, 1) * var(--vv-scale, 1))));
    transform-origin: 0 100%; /* default bar sits above the box */
    gap: 8px;
    padding: 6px 8px;
  }
  .deck-editing .sales-deck .deck-el--bar-below > .deck-el-toolbar,
  .deck-editing .sales-deck .deck-el--bar-inside > .deck-el-toolbar {
    transform-origin: 0 0;
  }
  .deck-editing .sales-deck .deck-el-toolbar .deck-el-move,
  .deck-editing .sales-deck .deck-el-toolbar .deck-el-btn {
    width: 30px;
    height: 30px;
  }

  /* Resize handles: counter-scale against the canvas transform so the grab
     target stays finger-sized in SCREEN pixels however far the slide is
     scaled down — a 14px design-px handle renders ~4px on a phone otherwise.
     18÷scale design-px paint as ~18 screen px, the ::before pad adds ~14
     screen px of invisible target each side, and the corner offsets scale
     the same way so the handle still hugs its corner. */
  .deck-editing .sales-deck .deck-el-resize,
  .deck-editing .sales-deck .deck-img-resize {
    width: calc(18px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
    height: calc(18px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
    border-radius: calc(5px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
    border-width: calc(2px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
  }
  .deck-editing .sales-deck .deck-el-resize {
    right: calc(-9px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
    bottom: calc(-9px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
  }
  .deck-editing .sales-deck .deck-img-resize {
    right: calc(6px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
    bottom: calc(6px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
  }
  .deck-editing .sales-deck .deck-el-resize::before,
  .deck-editing .sales-deck .deck-img-resize::before {
    inset: calc(-14px / (var(--deck-scale, 1) * var(--vv-scale, 1)));
  }

  /* Caption rows while pinch-zoomed. The JS half is _settleCaptionPin
     (clamp math in utils/deck_caption_pin): once per viewport settle it
     stamps translate(tx, 0) scale(1/pinch) — the row renders at its 1×
     screen size (page-load size, exactly one band wide) aligned with the
     visible band horizontally, but NEVER moves vertically: it stays in its
     natural spot above its slide, so it needs no backdrop (Jon's call — the
     earlier band-top sticky pin floated the controls over the slide under a
     translucent band). The row STAYS in the document — pans move it with
     the content on the compositor (no fixed-layer swim, no per-scroll JS)
     and the transform transition turns each settle re-place into a short
     glide. position + z-index: the transform traps the add-element menu's
     z-50 inside the row's new stacking context, and at z:auto the
     (also-transformed) canvas paints over the open menu in tree order —
     lifting the row keeps the menu above the slide (and under the z-30/z-40
     fixed chrome). Without .deck-pinched (desktop, 1×) none of this applies. */
  .deck-pinched [data-slide-caption] {
    position: relative;
    z-index: 1;
    transform-origin: 0 0;
    transition: transform 180ms ease, opacity 150ms ease;
  }
  /* While the viewport is in motion (pan), _trackCaptionPan re-stamps the
     row transform per vv event — the settle glide transition would lag-chase
     every stamp (visible swim), so it goes quiet until the settle clears
     deck-chrome-motion and the final correction glides again. Ordered after
     .deck-pinched so transition:none wins. */
  .deck-chrome-motion [data-slide-caption] {
    transition: none;
  }
  /* HORIZONTAL pans hide the rows: JS placement is one frame behind the
     compositor, and a content-embedded strip chasing a pan reads as jitter
     (true screen-freeze needs container zoom — the viewer's architecture).
     Placement keeps stamping underneath, so the settle reveal (class comes
     off, transition restores) fades in already-correct. Vertical scrolls
     never add this class — the rows ride their slides jitter-free. */
  .deck-pan-x [data-slide-caption] {
    opacity: 0;
    pointer-events: none;
    transition: none;
  }
  /* Mid-pinch the counter-scale would have to chase the gesture (rAF lag =
     visible wobble) — hide for the gesture's duration instead, like the
     floating bars, and let the settle placement fade the row back in.
     Ordered after .deck-pinched so transition:none wins while hidden (the
     re-place while hidden must snap, not glide). */
  .deck-pinch-live [data-slide-caption] {
    opacity: 0;
    pointer-events: none;
    transition: none;
  }

  /* Image-slot chrome (replace/zoom controls + resize handle): revealed by
     the tap-select model, same as text elements — the first tap on a framed
     image marks the wrap .deck-img-selected (elementTapSelect) and the
     chrome appears; the second tap reaches the controls/picker natively.
     Overview thumbnails stay clean (scroll view only). Free-placed IMAGE
     elements need no special pin anymore either: selection reveals their
     toolbar/handle exactly like text (they never take focus, which is why
     they used to be pinned always-visible). */
  .deck-view-scroll .deck-editing .sales-deck .deck-img-wrap.deck-img-selected .deck-img-controls,
  .deck-view-scroll .deck-editing .sales-deck .deck-img-wrap.deck-img-selected .deck-img-resize {
    opacity: 1;
  }

  /* CSS hover tooltips: iOS pins :hover on the first tap, so a tooltip shows
     and then STICKS — the caption "+" menu opened underneath its own
     "Add element" tooltip, which sat exactly over the first menu row
     (data-tooltip-position="below" puts it where the panel drops). Hover
     hints have no meaning without hover; drop them across the deck editor on
     touch. Same-specificity as the position-variant tooltip rules but later
     in the bundle (sales_deck.css loads after application.css), so no
     !important needed. */
  [data-controller~="sales-deck"] [data-tooltip]::before,
  [data-controller~="sales-deck"] [data-tooltip]::after {
    display: none;
  }
}

/* ————— Mobile timeline: client-view lane canvas ————— */
/* On touch devices the editor presents each timeline the way the client
   viewer does (public/decks/show.html.erb): the canvas at 1:1 — readable
   type, finger-sized edit targets — ONE lane per frame recut to full height,
   the 208px gutter column pulled off-canvas behind a slim sticky lane rail,
   and each FRAME as its own scroll surface (the internal week scroller
   unclips into it, so panning is one native gesture with no inner/outer
   scroll fight). A combined (both-lanes) slide renders as TWO stacked frames
   — the live one recut to Strategy plus an inert Development companion clone
   — matching the viewer's one-lane-per-slide split. All rules are
   CLASS-driven — sales-deck's applyTimelineLaneModes stamps .deck-tl-lane
   (+ a lane class and the companion on combined slides) on coarse pointers
   in the scroll view — so the pipeline is testable without pointer emulation
   and the overview grid keeps its scaled miniatures. Editing (bullet
   curation, drags, zones) works untouched on the live frame: those are the
   live cells, just recut. */
.deck-slide-frame.deck-tl-lane {
  /* The frame becomes a window onto the unscaled canvas: fixed height in
     screen terms (svh so browser chrome doesn't hide the bottom), native
     two-axis panning inside. Scroll chaining stays default so a vertical
     swipe that exhausts the frame keeps scrolling the slide list. */
  aspect-ratio: auto;
  height: min(810px, 66svh);
  overflow: auto;
}
.deck-slide-frame.deck-tl-lane .sales-deck {
  /* 1:1 canvas — beats both scale paths (the trig rule and .deck-scale-js);
     applyTimelineLaneModes pins the frame's --deck-scale var to 1 to match,
     so pointer-delta math and counter-scaled chrome agree. */
  transform: none;
  overflow: visible; /* the canvas is the clipper — weeks must reach the frame scroller */
}
.deck-slide-frame.deck-tl-lane [data-timeline-root] {
  /* Single-cell grid stacks the lane ribbon over the week grid without
     pushing it down; both children share the cell. */
  display: grid;
  overflow: visible; /* week scroller disabled — the frame owns scrolling */
}
.deck-slide-frame.deck-tl-lane [data-timeline-root] > * {
  grid-area: 1 / 1;
}
/* Gutter off-canvas: pull all but a 30px sliver (the ribbon's lane) and
   blank the gutter cells. -178px = 208px column − 30px ribbon. */
.deck-slide-frame.deck-tl-lane [data-timeline-root] > .grid {
  margin-left: -178px;
}
.deck-slide-frame.deck-tl-lane [data-timeline-root] > .grid > [style*="grid-column: 1;"] {
  visibility: hidden;
}
/* Week pills (injected by applyTimelineLaneModes) straddle the phase accent
   line; give the milestone row breathing room below them and lift the phase
   titles clear above them — same geometry as the client viewer. */
.deck-slide-frame.deck-tl-lane [data-timeline-root] > .grid > [style*="grid-row: 3;"] {
  margin-top: 20px;
}
.deck-slide-frame.deck-tl-lane [data-timeline-root] > .grid > [style*="grid-row: 2;"] {
  padding-bottom: 26px !important;
}
/* The mobile timeline recut's three row templates, defined ONCE. Both the
   editor's lane-mode preview (.deck-tl-lane-*, below) and the public viewer's
   coarse-pointer clone (.deck-coarse .deck-tl-focus-*, in the inline <style> of
   public/decks/show.html.erb — which loads this stylesheet) read them from
   here, so a geometry change lands in one place instead of two byte-identical
   copies. They mirror the shape the server renders for a real Split copy
   (_timeline.html.erb) but grow the labels row 54→64 for the week pills; that
   server grid_rows is a separate, dynamic string frozen into published decks,
   so it is deliberately not shared. */
:root {
  --deck-tl-rows-strategy: 70px 46px 64px 1fr 0px 0px 16px;
  --deck-tl-rows-development: 70px 46px 64px 0px 0px 1fr 16px;
  --deck-tl-rows-development-labeled: 70px 46px 64px 0px 28px 1fr 16px;
}
/* Combined (both-lanes) slides recut to the shown lane's focused geometry
   (grid rows are inline styles, hence !important). */
.deck-slide-frame.deck-tl-lane-strategy [data-timeline-root] > .grid {
  grid-template-rows: var(--deck-tl-rows-strategy) !important;
}
.deck-slide-frame.deck-tl-lane-strategy [data-timeline-root] > .grid > [style*="grid-row: 5"],
.deck-slide-frame.deck-tl-lane-strategy [data-timeline-root] > .grid > [style*="grid-row: 6"] {
  display: none;
}
/* In "both" the strategy cards carry a 275px inline cap; focused they fill.
   [data-deck-card] is the semantic name for "a timeline feature card"; the
   .bg-white arm is kept permanently beside it because decks published before
   the attribute existed are frozen HTML and match only that. */
.deck-slide-frame.deck-tl-lane-strategy [data-timeline-root] > .grid > .bg-white[style*="grid-row: 4"],
.deck-slide-frame.deck-tl-lane-strategy [data-timeline-root] > .grid > [data-deck-card][style*="grid-row: 4"] {
  max-height: calc(100% - 12px) !important;
}
.deck-slide-frame.deck-tl-lane-development [data-timeline-root] > .grid {
  grid-template-rows: var(--deck-tl-rows-development) !important;
}
/* Keep the 28px package-label row only when the slide has package labels. */
.deck-slide-frame.deck-tl-lane-development:has([data-timeline-root] > .grid > [style*="grid-row: 5"]:not(.sticky)) [data-timeline-root] > .grid {
  grid-template-rows: var(--deck-tl-rows-development-labeled) !important;
}
.deck-slide-frame.deck-tl-lane-development [data-timeline-root] > .grid > [style*="grid-row: 4"] {
  display: none;
}
/* Lane rail — the passive label naming the lane a frame shows (the combined
   slide renders as two frames, one lane each, so there is nothing to flip).
   Sticky against the frame scroller in the 30px sliver the gutter pull
   leaves, so it never scrolls away horizontally. Hidden everywhere but
   active lane mode. */
.deck-tl-lane-ribbon {
  display: none;
}
.deck-slide-frame.deck-tl-lane [data-timeline-root] > .deck-tl-lane-ribbon {
  display: flex;
  position: sticky;
  left: 0;
  z-index: 40; /* above the (hidden) z-30 gutter rail and the phase art */
  justify-self: start;
  width: 30px;
  height: 100%;
  flex-direction: column;
  align-items: center;
  /* flex-start, NOT center: the inner label's static spot is the rail top,
     so its vertical sticky (below) engages at every scroll position — a
     canvas-centered start only reads centered when the 810px canvas happens
     to fit the cropped frame. */
  justify-content: flex-start;
  padding: 0;
  background: linear-gradient(90deg, var(--color-deck-rail-from), var(--color-deck-rail));
  border-right: 1px solid rgba(255, 255, 255, 0.14);
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
/* The label rides the FRAME's scroll, CENTERED in the visible window —
   consistent wherever the canvas is scrolled (the rail spans the full 810px
   canvas; any fixed canvas alignment lands at a random viewport spot after
   the frame crops it). The offset backs the sticky TOP off by half the
   label's height (~150px for the longer Strategy & Design; Development rides
   a hair high, within a couple percent). Lane mode runs unscaled (transform
   none, --deck-scale 1), so sticky tracks the scroller correctly here. */
.deck-tl-lane-ribbon-inner {
  position: sticky;
  top: calc(50% - 75px);
  display: flex;
  flex-direction: column;
  align-items: center;
}
.deck-tl-lane-ribbon-label {
  display: none;
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #d7dade;
}
.deck-slide-frame.deck-tl-lane-strategy .deck-tl-lane-ribbon-label[data-lane="strategy"],
.deck-slide-frame.deck-tl-lane-development .deck-tl-lane-ribbon-label[data-lane="development"] {
  display: block;
}
/* The Development companion (an applyTimelineLaneModes clone) stacks under
   the live Strategy frame as its own slide: a between-slides-sized gap, so
   the pair reads as two slides sharing one caption rather than one canvas
   torn in half. */
[data-tl-lane-companion] {
  margin-top: 24px;
}
/* Week pills — injected grid items (applyTimelineLaneModes), so they scale
   and scroll with the weeks. Mirrors the client viewer's pills. */
.sales-deck .deck-tl-week-pill {
  grid-row: 3;
  justify-self: start;
  align-self: start;
  z-index: 6;
  margin: -12px 0 0 10px;
  width: max-content;
  pointer-events: none;
  background: #0c0d10;
  color: rgba(255, 255, 255, 0.82);
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 999px;
  padding: 3px 11px;
  font-size: 11px;
  letter-spacing: 0.15em;
  line-height: 1.35;
  text-transform: uppercase;
}

/* PDF-export preview: sticky elements (the timeline's gutter rail + lane
   labels) must ride WITH the panned content, not pin to the clone's
   scrollport. Sticky answers scrollLeft, but the preview positions content
   via translate (scrollLeft stays 0) — and iOS WebKit mis-anchors the
   zoom+translate+sticky combo into a block floating over the slide. The
   exported PDF's first page renders the gutter in flow for the same
   scrollLeft-0 reason, so unsticking here is also the faithful preview.
   RELATIVE, not static: z-index only applies to positioned boxes, so
   static would kill the rail's z-30 and every `relative` timeline card
   would paint over the pinned gutter — a white card-edge strip beside the
   lane labels whenever the pan slides a card column under them. Relative
   keeps the offsets inert (left-0/top-0 become no-ops) while preserving
   the exact stacking the editor and the exported PDF both use. */
[data-deck-export-zoom-target="exportZoomStage"] .sticky {
  position: relative;
}
/* PDF pages can't scroll: in the export preview, timeline cards must show
   their FULL bullet lists, not the interactive canvas's capped-and-
   scrolling boxes (inline max-height on the card div — hence !important).
   Mirrors the same rules on the export page itself; the fit/auto
   measurement then reads the true extent and shrinks or panes instead of
   hiding content behind a scroll that never happens on paper. */
[data-deck-export-zoom-target="exportZoomStage"] [data-timeline-root] div:has(> .deck-tl-scroll) {
  max-height: none !important;
}
[data-deck-export-zoom-target="exportZoomStage"] [data-timeline-root] .deck-tl-scroll {
  max-height: none !important;
  overflow: visible !important;
}
/* The stage's inline touch-action:none does NOT shield the clone's own
   .deck-scroll-x scrollers — touch-action only restricts scroll containers
   that are ANCESTORS of the element carrying it, and the stage sits above
   the cloned scroller. A touch starting on slide content let iOS natively
   scroll the inner scroller (the pan translate extends its scrollable
   overflow), firing pointercancel — which killed the JS pan mid-gesture
   (no release velocity → no glide) and moved scrollLeft, a position the
   export never sees. Stamping every descendant keeps the whole gesture on
   the pointer stream that drives the pan + momentum. */
[data-deck-export-zoom-target="exportZoomStage"] * {
  touch-action: none;
}
/* The clone's scroll regions must never natively scroll — desktop wheel
   input scrolled them (scrollLeft the pan math knows nothing about, plus a
   stray scrollbar in the preview); every shift is translate-driven. */
[data-deck-export-zoom-target="exportZoomStage"] :is(.deck-scroll-x, .deck-scroll-y, .deck-scroll-xy) {
  overflow: hidden !important;
}

/* PDF-export zoom slider: custom-drawn (appearance: none) because native
   WebKit range thumbs can't be resized — and the default thumb is a
   fingertip-sized miss on phones. Same look both pointer types; coarse
   pointers get a 44px hit box and a bigger thumb. */
input[data-deck-export-zoom-target="exportZoomSlider"] {
  -webkit-appearance: none;
  appearance: none;
  height: 28px;
  background: transparent;
}
input[data-deck-export-zoom-target="exportZoomSlider"]::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 2px;
  background: var(--color-deck-edge);
}
input[data-deck-export-zoom-target="exportZoomSlider"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -6px; /* (track 4px − thumb) / 2 keeps it centered */
  border-radius: 50%;
  border: none;
  background: #5ec9b4;
}
input[data-deck-export-zoom-target="exportZoomSlider"]::-moz-range-track {
  height: 4px;
  border-radius: 2px;
  background: var(--color-deck-edge);
}
input[data-deck-export-zoom-target="exportZoomSlider"]::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border: none;
  border-radius: 50%;
  background: #5ec9b4;
}
/* The "pg" abbreviation spans only exist on portrait phones (below). */
[data-pg-short] {
  display: none;
}
/* Portrait phone: the export modal's controls re-rag for a ~350px dialog
   (Jon's 2026-07-21 annotated layout): the Auto/Fit/Manual segments
   stretch full width; the pages toggle abbreviates to "One pg / Multi-pg";
   and the pane stepper takes the LEFT of the Reset-position line (the
   margin-right:auto pushes it off the group's justify-end, so Reset keeps
   the right edge — and keeps it when the stepper is hidden). */
@media (pointer: coarse) and (max-width: 639px) and (orientation: portrait) {
  /* The canvas frame is fluid everywhere (full modal width, 1440/810 aspect
     inline on the frame itself) — nothing phone-specific needed for it. */
  [data-export-zoom-segments] {
    width: 100%;
  }
  [data-export-zoom-segments] button {
    flex: 1 1 0;
    text-align: center;
  }
  [data-pg-long] {
    display: none;
  }
  [data-pg-short] {
    display: inline;
  }
}
@media (pointer: coarse) {
  /* Phone: the One page / Multi-page toggle joins the wrapped control flow
     LEFT-aligned — right-orphaned beside dead space it read as a wrap
     accident (the stepper + Reset own the right edge of the summary row). */
  [data-export-pages-toggle] {
    margin-left: 0;
  }
  input[data-deck-export-zoom-target="exportZoomSlider"] {
    min-height: 44px;
  }
  input[data-deck-export-zoom-target="exportZoomSlider"]::-webkit-slider-thumb {
    width: 28px;
    height: 28px;
    margin-top: -12px;
  }
  input[data-deck-export-zoom-target="exportZoomSlider"]::-moz-range-thumb {
    width: 28px;
    height: 28px;
  }
}

/* Preview menu "Desktop layout" item — meaningless without the mobile lane
   mode (a fine-pointer editor already sees the combined desktop canvas), so
   it only exists on coarse pointers in the scroll view. */
[data-tl-desktop-preview] {
  display: none;
}
@media (pointer: coarse) {
  .deck-view-scroll [data-tl-desktop-preview] {
    display: block;
  }
}

/* Preview menu "Phone layout" item — the exact mirror: a coarse-pointer
   editor IS the phone experience, so the row only exists on fine pointers.
   Not scroll-gated like Desktop layout (which depends on lane mode existing):
   "how does this slide read on a phone?" is answerable from either view. */
[data-phone-preview] {
  display: none;
}
@media (pointer: fine) {
  [data-phone-preview] {
    display: block;
  }
}

/* Split-hidden canonical timeline: the two focused copies stand in for the
   combined slide, so its dimmed placeholder would only push them down a full
   frame height — collapse the row to just the "Timeline settings" panel
   (which holds Unsplit and the phase editor) on every device. Unsplit is the
   way back; un-hiding via the overview eyelid drops .deck-excluded, which
   brings the row back here too. */
.deck-view-scroll [data-tl-split].deck-excluded > [data-slide-caption],
.deck-view-scroll [data-tl-split].deck-excluded > .deck-slide-frame,
.deck-view-scroll [data-tl-split].deck-excluded > [data-slide-annotation] {
  display: none;
}

/* Timeline settings panel on phones: the +/− steppers ride the LAST phase
   select's line, pinned to the right edge inside thumb reach — they step the
   dropdowns, so they belong beside them (Jon). Split/Unsplit then closes the
   panel as its OWN full-width centered action bar with a taller tap target
   (a compact pill floating beside dead space read as a wrap accident).
   Desktop keeps the single row: selects, steppers, pill pushed right. */
@media (pointer: coarse) {
  /* Each phase-boundary select gets its OWN full row: two selects sharing a
     line wrapped raggedly. The LAST one instead grows to fill its line, so
     the +/− pair lands beside it, shoved to the right edge. */
  [data-timeline-panel] select[data-phase-boundary] {
    flex-basis: 100%;
  }
  [data-timeline-panel] select[data-phase-boundary]:last-of-type {
    flex: 1 1 auto;
  }
  [data-timeline-panel] .deck-tl-split-slot {
    flex-basis: 100%;
    margin-top: 2px;
  }
  [data-timeline-panel] .deck-tl-split-slot button {
    flex: 1 1 auto;
    justify-content: center;
    padding-block: 0.4rem;
  }
  /* The scope hint reads as a SUBTITLE under "Timeline settings" — full row,
     left-aligned. Desktop keeps it right-aligned on the header line; on the
     narrow panel it wrapped there anyway, ragged-right against the title. */
  [data-timeline-panel] [data-timeline-panel-scope] {
    flex-basis: 100%;
    margin-left: 0;
    text-align: left;
  }
  /* Detach the deck-wide settings card from the slide's OWN caption below —
     with the caption's 12px internal row-gap, an equal-or-smaller card gap
     made the whole cluster read as one jumble (Jon's screenshot). The top
     margin keeps the gap ABOVE the card (to the previous slide) visibly
     larger than the gap below it: equidistant gaps read as floating between
     the slides, but the card belongs to the timeline family under it. */
  [data-timeline-panel] {
    margin-top: 0.75rem;
    margin-bottom: 1.25rem;
  }
}


/* ————— The "@" insert menu ————— */

/* Top layer (popover): the deck canvas is transform: scale()d, which breaks
   position: fixed for its descendants, and every slide frame clips overflow.
   The UA centers a popover with inset: 0 + margin: auto and paints an opaque
   `canvas` background behind it — all three are cleared here, because the panel
   is placed from JS and draws its own surface. */
.deck-insert-menu[popover] {
  position: fixed;
  inset: auto;
  margin: 0;
  padding: 0;
  width: 17rem;
  max-height: min(24rem, 70svh);
  display: none;
  flex-direction: column;
  overflow: hidden;
  background: var(--color-deck-menu);
  border: 1px solid var(--color-deck-edge);
  border-radius: 0.5rem;
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
  color: #d1d5db;
  font-size: 12px;
}
.deck-insert-menu[popover].deck-insert-on {
  display: flex;
}
/* "INSERT INTO · 10 · WHAT SUCCESS LOOKS LIKE" — the target slide's number and
   name, which is the whole point of the header, so it must not be the part that
   gets cut. On a 17rem panel with uppercase + 0.16em tracking, one nowrap line
   truncated most real slide names mid-word; wrap to at most two lines and clamp
   instead, so the name survives and only a very long one ellipsizes.
   The extra bottom padding keeps the search input's focus ring (an outline, drawn
   outside its box) from reaching up over this text. */
.deck-insert-head {
  padding: 0.5rem 0.75rem 0.5rem;
  color: #6b7280;
  font-size: 9.5px;
  line-height: 1.45;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  overflow-wrap: anywhere;
}
.deck-insert-input {
  margin: 0 0.5rem 0.35rem;
  padding: 0.35rem 0.5rem;
  background: var(--color-deck-panel);
  border: 1px solid var(--color-deck-edge);
  border-radius: 0.25rem;
  color: #fff;
  font-size: 13px;
  outline: none;
}
.deck-insert-input:focus {
  border-color: var(--color-deck-edge-strong);
}
.deck-insert-list {
  flex: 1 1 auto;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-bottom: 0.25rem;
}
.deck-insert-group {
  padding: 0.5rem 0.75rem 0.2rem;
  color: #4b5563;
  font-size: 9.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  border-top: 1px solid var(--color-deck-edge);
  margin-top: 0.25rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.deck-insert-list > .deck-insert-group:first-child {
  border-top: 0;
  margin-top: 0;
}
.deck-insert-row {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  width: 100%;
  padding: 0.3rem 0.75rem;
  text-align: left;
  cursor: pointer;
  color: #d1d5db;
  background: transparent;
  border: 0;
}
.deck-insert-row:disabled {
  cursor: default;
  color: #4b5563;
}
/* Hover and keyboard share one highlight: the pointer moves the same cursor
   the arrow keys do (pointermove sets it), so there is never a second,
   competing "selected" row for Enter to disagree with. */
.deck-insert-row.deck-insert-hot {
  background: var(--color-deck-chip);
  color: var(--color-deck-ink);
}
.deck-insert-label {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.deck-insert-hint {
  flex: none;
  color: #6b7280;
  font-size: 10px;
}
.deck-insert-empty {
  padding: 0.6rem 0.75rem;
  color: #6b7280;
}
/* Wraps rather than truncating: the whole line is the keyboard contract, and
   "esc can…" teaches nobody anything. */
/* The key hints wrap as a group at this width, which is fine — but a break
   INSIDE one ("esc / cancel") reads as a stray word. Each hint is its own
   nowrap span, so the line can only break at the separators between them. */
.deck-insert-foot {
  display: flex;
  flex-wrap: wrap;
  gap: 0 0.65rem;
  padding: 0.35rem 0.75rem;
  border-top: 1px solid var(--color-deck-edge);
  color: #4b5563;
  font-size: 10px;
  line-height: 1.5;
}
.deck-insert-foot > span {
  white-space: nowrap;
}

/* The slide the menu will insert into — named in the menu's header, and ringed
   here so a fallback guess (no pointer over any slide) is visible BEFORE the
   author commits to it. */
.deck-insert-target .deck-slide-frame {
  outline: 2px solid rgba(163, 230, 53, 0.55);
  outline-offset: 2px;
}

/* The ghost preview: the real element, built by _buildElement and promoted on
   commit, shown at the insertion point while the menu is open. Inert — its own
   data-action bindings can never fire, so it can't be selected, dragged, or
   take a field claim before it exists. */
.deck-el-ghost {
  pointer-events: none;
  opacity: 0.45;
  outline: 1px dashed rgba(163, 230, 53, 0.9);
  outline-offset: 3px;
}
.deck-el-ghost .deck-el-toolbar,
.deck-el-ghost .deck-el-resize {
  display: none !important;
}

/* ————— --deck-chrome: bands 1 and 2 wear the APP's nav surface —————
   These two bands hold app furniture and nothing else — the global
   destinations, the terminal, the timer, the org menu, the project tabs — so
   they are the app's header that the deck sits under, and they now carry the
   app's header COLOR: what every other project view's nav paints, to the byte.
   They used to track the palette (`menu` on dark, `ground` on light), which put
   the deck editor a shade off every page you reach it from — the palette's
   raised-surface role against the app's #18191d — and a header that is ALMOST
   the same reads as a rendering fault rather than as a different surface.

   Everything BELOW them still travels with the palette: band 3 (the deck
   toolbar) and the page the slides sit on are --deck-well, and the slides are
   the palette outright. The line between "app chrome" and "deck artwork" is now
   the bottom edge of band 2, which is where it always described itself.

   The dark value is theme.css's dark --color-white, repeated rather than
   referenced because this document is deliberately theme-less (data-deck-document
   strips data-theme so the app can never repaint authored slides), so there is
   no inverted token here to read. Keyed off data-deck-ground, NOT the app theme:
   a light deck opened by somebody in dark mode still shows light chrome, because
   the deck's polarity is the palette's, not the viewer's. Change either value
   only alongside theme.css's — the "matches the app's own nav surface" example
   in app_band_spec.rb reads the app's nav and this band in one browser and
   compares them, so it fails rather than drifting. */
[data-deck-editor][data-deck-ground="dark"] {
  --deck-chrome: oklch(21.5% 0.007 265.0);
}
[data-deck-editor][data-deck-ground="light"] {
  --deck-chrome: #ffffff;
}

/* ————— Bands 1+2: app navigation + project bar above the deck toolbar —————
   The shared app partials rendered inside the band — org dropdown, timer pill
   (whose broadcasts re-render app-standard HTML outside any request), the
   project navbar — speak the app's neutral vocabulary. The band gives it to
   them on the wrapper, with zero markup branches: custom properties inherit,
   so broadcast-replaced children resolve the same way.

   These are the APP's neutrals, not a palette mix, for the same reason the band
   wears the app's surface: it is the app's header. The mix used to be
   "N-strong palette ink over the band", which is a flat GREY — the app's ramp
   carries a deliberate blue cast (265°) and sits a step lighter, so the two
   headers read as different typography rather than the same header twice
   (Dashboard was #cbcbcc here against the app's #d3d8e0).

   Only the DARK half is repeated. On a light deck there is no override at all:
   this document is theme-less, so --color-gray-* are already Tailwind's stock
   light values, which is exactly what the app's light mode uses. The dark half
   has no such luck — theme.css's inversion is keyed to html[data-theme="dark"],
   which data-deck-document deliberately strips so the app can never repaint
   authored slides — so it is copied here verbatim, INCLUDING the spelling, to
   stay greppable against its source. Change it only alongside theme.css: the
   "matches the app's own header neutrals" example in app_band_spec.rb resolves
   both ramps in one browser and compares them, so a drift fails rather than
   ships. Accent colors (blues, the timer's green) pass through untouched.

   --color-white follows --deck-chrome, which IS theme.css's dark --color-white
   on a dark ground and #fff on a light one — so a panel hanging off the band
   (the timer's, its note field and Stop button, the org dropdown) lands on the
   band's own surface, the way the app draws a white panel on a white nav. */
.deck-app-band {
  --color-white: var(--deck-chrome);

  /* The band's own drop shadow, hand-set between two Tailwind tiers because
     neither fit. This shadow does a different job from the app nav's: the app's
     falls into open page, while this one lands on the deck toolbar, which
     begins at exactly the pixel the band ends. `shadow` (3px blur) read as a
     seam between two bars rather than as one floating over the other;
     `shadow-md` (6px, and 4px down) read as a slab. THIS is the number to turn
     if it wants more or less — nothing else here depends on it.

     --tw-shadow-color with a fallback, exactly the way Tailwind's own shadow
     utilities are written: the elevation block below sets it to the deepened
     black on a dark ground, and on a light one it stays unset and this falls
     back to Tailwind's stock alpha, which is what reads correctly on white. */
  box-shadow: 0 2px 4px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
}

[data-deck-ground="dark"] .deck-app-band {
  --color-gray-50: oklch(26.2% 0.008 265.0);
  --color-gray-100: oklch(30.2% 0.009 265.0);
  --color-gray-200: oklch(34.5% 0.010 265.0);
  --color-gray-300: oklch(40.5% 0.012 265.0);
  --color-gray-400: oklch(64.0% 0.017 265.0);
  --color-gray-500: oklch(73.0% 0.018 265.0);
  --color-gray-600: oklch(81.0% 0.015 265.0);
  --color-gray-700: oklch(88.0% 0.012 265.0);
  --color-gray-800: oklch(93.0% 0.008 265.0);
  --color-gray-900: oklch(97.0% 0.004 265.0);
}

/* ————— The band's elevation, on a dark palette —————
   The neutral remap above hands the band's app partials the palette's COLORS.
   It cannot hand them the app's dark-ground ELEVATION, and they need that too.

   theme.css draws a floating panel on dark with a white rim light plus a
   deepened shadow, for the reason it states there: a black shadow is invisible
   against dark, so the rim is what says "this floats". Both rules are scoped to
   html[data-theme="dark"], which this document deliberately never carries —
   data-deck-document strips the app theme so it can never repaint authored
   slide artwork. So a dark palette took the band's chrome dark while its panels
   kept Tailwind's stock 10%-black shadow and no rim at all: the timer panel and
   the org menu were separated from the bar they hang off by their 1px border
   alone, which is a hairline where the app has a lit edge.

   Keyed off deck_ground (layout-stamped) rather than a theme attribute, and
   scoped to the BAND rather than the editor. This compensates components
   written in the APP's vocabulary, and the band is the only place those live —
   the same boundary, for the same reason, as the token remap above. The deck's
   own chrome was authored dark-first and draws its own palette-derived edge
   (the theme popover's border-deck-edge, template_setup's literals), so a rim
   there would be a second boundary over one that already works; and a rule loose
   enough to reach .sales-deck would put a hairline on the slides themselves.

   In @layer base, like the rules it mirrors, so a per-element shadow-<color>
   utility still wins. --tw-inset-ring-shadow is the slot theme.css borrows (no
   inset-ring-* utility appears anywhere in the app) and its @property declares
   inherits:false, so both slots are set on the elevated element itself. */
@layer base {
  /* The deepened shadow color goes on the band AND everything in it, matching
     the breadth of theme.css's `html[data-theme="dark"] *`. Narrower — only the
     .shadow-md+ tiers below — left the band's OWN `shadow` behind: the <nav>
     wraps both tiers, so the line that reads as tier 2's drop shadow is drawn by
     the nav, and at 10% black it did not read against the palette at all. */
  [data-deck-ground="dark"] .deck-app-band,
  [data-deck-ground="dark"] .deck-app-band * {
    --tw-shadow-color: oklch(0% 0 0 / 0.5);
  }

  /* The rim light, on the floating tiers only — NOT on the band itself, which
     is not floating: an inset rim there would draw a lit box around the whole
     header. theme.css scopes it the same way, and the band's plain `shadow`
     utility is below that threshold in both places. */
  [data-deck-ground="dark"] .deck-app-band :is(.shadow-md, .shadow-lg, .shadow-xl, .shadow-2xl) {
    --tw-inset-ring-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.12);
  }

  /* The heaviest tiers carry a brighter rim, matching theme.css: they are
     modals and detached panels, usually over a backdrop. */
  [data-deck-ground="dark"] .deck-app-band :is(.shadow-xl, .shadow-2xl) {
    --tw-inset-ring-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.16);
  }
}

/* The header's preview play — the chrome's only filled CTA. `background`
   rather than a Tailwind gradient utility because --deck-cta carries EITHER a
   color or a linear-gradient() depending on the palette's polarity (stamped on
   the layout body, where deck_ground is known), and `bg-(--token)` can only
   ever be a color. */
.deck-cta-play {
  background: var(--deck-cta);
}

/* The shared dropdown Stimulus controller paints its keyboard-focused row
   with the app's `bg-gray-50` — near-white, a white slab on the deck's dark
   menus (the theme popover's current row wore it). Remap just that token for
   dropdown menus inside the topbar; the engagement popover is a different
   controller (and a deliberately white mat), so it is out of reach here. */
.deck-topbar [data-dropdown-target="menu"] {
  --color-gray-50: color-mix(in oklab, var(--color-deck-ink) 8%, var(--color-deck-menu));
}

/* ————— One stature for the deck toolbar's controls —————
   Band 3 held FOUR heights in a single row, because every control sized itself
   from its own text and padding: 26px (the view switch), 28px (the deck
   switcher, both + pills, the colors toggle), 30px (the stats pill) and 32px
   (every icon circle). Nothing in a row of controls lines up when that happens
   — the pills read as sitting slightly high beside the circles they sit
   between (Jon).

   32px because the icon circles were already there and already agreed with each
   other: this raises the text controls rather than resizing the majority.

   The hook is an attribute, not the `border-deck-edge-control` utility every one
   of them happens to share — the border token can be re-pointed, or a control
   given a different edge, without that meaning "stop being a toolbar control".

   HEIGHT ONLY, deliberately. `display` belongs to the coarse-pointer and
   narrow-window blocks further down, which set display:none on the category
   pill and inline-flex on the compact "+" summary for reasons documented there;
   a blanket display here would have to out-specify them. The three controls
   that had no centering of their own got `inline-flex items-center` in markup
   instead, where it composes with those rules rather than fighting them. */
[data-deck-tool] {
  height: 2rem;
}

/* The editor's chrome arithmetic. --deck-chrome-offset is the sticky header
   line slide anchors and the j/k stepper clear: the app band (64, plus 44
   when the project bar is riding in it) + the topbar line (64, matching the
   slides' historical scroll-mt-[64px] slack over the 52px bar). Defined
   here — not just as var() fallbacks — so JS can read the live value with
   getComputedStyle. */
[data-deck-editor] {
  --deck-nav-height: 64px;
  --deck-chrome-offset: 128px;
}
[data-deck-editor]:has([data-deck-project-nav]) {
  --deck-nav-height: 108px;
  --deck-chrome-offset: 172px;
}

/* This unlayered rule outranks the slide partial's scroll-mt-[64px] utility
   (see the note at the .deck-topbar overrides: unlayered beats @layer without
   !important). The partial itself stays untouched — its utility is frozen-HTML
   surface area shared with the public viewer. */
[data-deck-editor] [data-sales-deck-target="frame"] {
  scroll-margin-top: var(--deck-chrome-offset);
}

/* Same breakpoints as the .deck-topbar override above: all the bars go
   position:relative on phones/short screens (stacked sticky bars would eat
   the viewport), and the anchor clearance returns to its historical 64px.
   The :has() variant repeats so this override outranks the desktop :has()
   rule's higher specificity. */
@media (max-width: 639px), (max-height: 500px) {
  .deck-app-band {
    position: relative;
  }
  [data-deck-editor],
  [data-deck-editor]:has([data-deck-project-nav]) {
    --deck-chrome-offset: 64px;
  }
}

/* The view switch's active pill, in CSS rather than controller-toggled
   classes: the server bakes the root's deck-view-* class (from the
   deck_view cookie), so the switch reads correctly from first paint,
   before the controller ever connects — no overview flash in the pill. */
.deck-view-overview .deck-view-switch [data-view="overview"],
.deck-view-scroll .deck-view-switch [data-view="scroll"] {
  color: var(--color-deck-ink);
  background: var(--color-deck-chip);
}
