/* ============================================================================
   feishu-deck-h5 · design system stylesheet
   Source of truth for every slide. Do not invent tokens; use only these.
   Canvas: deck-defined; legacy default 1920×1080. Scaled by JS via
   --fs-scale on each .slide-frame.
   ============================================================================ */

:root {
  /* — Backgrounds — */
  --fs-bg-0: #000000;
  --fs-bg-1: #04060F;
  --fs-bg-2: #0A1230;
  --fs-bg-3: #1B1F3A;

  /* — Brand accents (use ONE per slide) — */
  --fs-blue:   #3C7FFF;   /* default */
  --fs-cyan:   #24C3FF;   /* inline highlight only */
  --fs-teal:   #33D6C0;
  --fs-purple: #5C3FFB;
  --fs-violet: #9F6FF1;
  --fs-orange: #FE7F00;
  --fs-accent: var(--fs-blue);

  /* — Text — */
  --fs-text:    #FFFFFF;
  --fs-text-72: rgba(255,255,255,0.72);
  --fs-text-65: rgba(255,255,255,0.65);
  --fs-text-48: rgba(255,255,255,0.48);
  --fs-text-40: rgba(255,255,255,0.40);
  --fs-text-16: rgba(255,255,255,0.16);

  /* — Lines — */
  --fs-hairline: rgba(255,255,255,0.10);
  --fs-divider:  rgba(255,255,255,0.20);

  /* — Gradients — */
  --fs-grad-hero:    radial-gradient(120% 100% at 0% 0%, #0F1A4A 0%, #060B22 38%, #000 78%);
  --fs-grad-section: radial-gradient(140% 110% at 100% 0%, #1A2256 0%, #050817 50%, #000 100%);
  --fs-grad-keyline: linear-gradient(90deg, #33D6C0 0%, #3C7FFF 50%, #5C3FFB 100%);
  --fs-grad-glow-blue: radial-gradient(80% 60% at 50% 50%, rgba(60,127,255,0.22), transparent 60%);
  --fs-grad-close:   radial-gradient(60% 50% at 92% 8%, rgba(159,111,241,0.28), transparent 60%),
                     radial-gradient(60% 50% at 8% 92%, rgba(60,127,255,0.22), transparent 60%),
                     #000;

  /* — Type — */
  /* 2026-08-21 · 豆包工作 × 飞书品牌规范。一级主标题用汉仪雅酷黑
     75W；副标题、正文和 PPT 常规文本用方正兰亭黑 Pro GB18030；英文与
     数字用 TikTok Display。字体不随 Skill 分发，未安装时回退到系统字体。 */
  --fs-font-display-cjk: "汉仪雅酷黑 75W", "HYYakuHei-75W",
                         "汉仪雅酷黑", "HYYakuHei",
                         "方正兰亭黑 Pro GB18030", "Noto Sans SC",
                         "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
  --fs-font-cjk:   "方正兰亭黑 Pro GB18030", "方正兰亭黑Pro_GB18030",
                   "FZLanTingHeiPro_GB18030 Light", "FZLanTingHeiPro-Light",
                   "FZLanTingHei", "Noto Sans SC", "PingFang SC",
                   "Microsoft YaHei", system-ui, sans-serif;
  --fs-font-latin: "TikTok Display", "Helvetica Neue", "Arial", sans-serif;
  --fs-font-mono:  "JetBrains Mono","SF Mono","Menlo",monospace;

  /* — Type scale (4-tier strict for CONTENT pages) — */
  /* 2026-05-16: content slides use only these 4 sizes. Hero layouts
     (cover / section / big-stat / end / quote) keep their master-spec
     hero values and are NOT subject to this scale. See SKILL.md
     "Typography floor" for the discipline. Rule of thumb:
       PPT 16:9 13.33"×7.5" at 1pt = 1/72" → 1920×1080 canvas, 1pt ≈ 2px.
       Title 24pt = 48px · Sub 14pt = 28px · Body 12pt = 24px · Foot 8pt = 16px. */
  --fs-title: 48px;        /* Action Title on content pages */
  --fs-sub:   28px;        /* Subtitle / column-title / lede (optional) */
  --fs-body:  24px;        /* Body copy, list items, table cells, captions */
  --fs-foot:  16px;        /* Footnote, eyebrow caps, pill, tag, attrib, source */

  /* — Geometry (1920 canvas) — */
  --fs-pad-x: 96px;
  --fs-pad-y: 64px;
  --fs-radius-card: 16px;
  --fs-radius-tile: 14px;
  --fs-radius-pill: 999px;

  /* — Brand assets (override these to inline as data: URIs) — */
  /* Official precomposed lockups. Do not rebuild the separator or spacing in CSS. */
  --fs-asset-cobrand: url("doubao-work-feishu-cobrand-color-on-dark.png");
  --fs-asset-cobrand-on-light: url("doubao-work-feishu-cobrand-color-on-light.png");
  --fs-asset-cobrand-mono: url("doubao-work-feishu-cobrand-mono-on-dark.png");
  --fs-asset-cobrand-mono-on-light: url("doubao-work-feishu-cobrand-mono-on-light.png");
  /* Legacy single-brand tokens stay available for explicit content use only. */
  --fs-asset-logo:        url("lark-logo.png");
  --fs-asset-logo-mono:   url("lark-logo-mono-white.png");
  --fs-asset-cover-bg:    url("lark-cover-bg.jpg");      /* 飞书 master flower background */
  --fs-asset-section-bg:  url("lark-section-bg.jpg");    /* cool blue glow gradient */
  --fs-asset-content-bg:  url("lark-content-bg.jpg");    /* subtle dark ambient gradient */
  --fs-asset-slogan:      url("lark-slogan.png");        /* 先进团队 先用飞书 */

  /* — Runtime scale (set by JS) — */
  --fs-scale: 1;
  /* — Deck canvas (renderer overrides these on .deck) — */
  --fs-deck-width: 1920px;
  --fs-deck-height: 1080px;
  --fs-deck-half-width: 960px;
  --fs-deck-half-height: 540px;
  --fs-deck-aspect: 1920 / 1080;

  /* — Motion tokens (AVAILABLE, not mandatory) —
     The deck's shared motion language. custom_css MAY reference these so bespoke
     per-page animation stays consistent instead of re-typing curves/durations
     (see references/motion-system.md). They do NOT force a house style — a page
     is free to design its own; this only kills copy-paste drift. */
  --fs-ease-out:    cubic-bezier(.16, 1, .3, 1);   /* signature expo-out */
  --fs-ease-soft:   cubic-bezier(.4, 0, .2, 1);    /* gentle in-out */
  --fs-dur-enter:   .6s;                            /* entrance / reveal */
  --fs-dur-quick:   .35s;                           /* hover / small state */
  --fs-dur-ambient: 40s;                            /* looping ambient drift */
  --fs-stagger:     90ms;                           /* steady stagger step */
  --fs-stagger-tight: 60ms;                         /* tech-tone tighter step */
}

/* Magic Move (opt-in via deck.json `magic_move`): default morph timing for any
   element pair sharing a view-transition-name across two slides. Override
   per-name in slide.custom_css with ::view-transition-group(<name>). Gated so it
   never runs when the platform/user disables motion. */
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(*) {
    animation-duration: var(--fs-dur-enter);
    animation-timing-function: var(--fs-ease-out);
  }
}

/* ---------- Reset (scoped, non-destructive to host pages) ---------- */
.deck, .deck * { box-sizing: border-box; }
.deck h1, .deck h2, .deck h3, .deck h4, .deck p, .deck ul, .deck ol, .deck li,
.deck figure, .deck blockquote { margin: 0; padding: 0; }
.deck ul, .deck ol { list-style: none; }
.deck img { display: block; max-width: 100%; }

/* ---------- Page chrome ---------- */
html, body { height: 100%; }
body {
  margin: 0; background: #000; color: var(--fs-text);
  font-family: var(--fs-font-cjk); font-weight: 500;
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* ============================================================================
   Deck modes
   ============================================================================ */

/* Wrapper that holds N slide-frames */
.deck { position: relative; width: 100%; }

/* The slide-frame is the responsive box. Internally it uses the deck's exact
   design canvas (1920×1080 when deck.canvas is absent).
   Perf: contain isolates layout/paint inside the frame; transitions become local
   repaints instead of full-document. */
.slide-frame {
  position: relative;
  width: 100%;
  aspect-ratio: var(--fs-deck-aspect, 1920 / 1080);
  overflow: hidden;
  background: #000;
  contain: layout paint size;
  /* F-370 (2026-08-24) · PRE-JS render containment — the file:// cold-open fix.
     ROOT CAUSE of「用 file:// 打开非常慢」: every off-screen-skipping rule in this
     sheet is scoped to .deck[data-mode=…] (present L199 / scroll L392), but
     `data-mode` is only set by feishu-deck.js::setMode — and that script is
     `defer`, so it cannot run until the ENTIRE html document is parsed. On a
     58-page / 750 KB deck that leaves a long window in which the deck has NO
     data-mode at all, so NEITHER perf rule matches and the browser does a full
     style+layout+paint pass over all N 1920×1080 slides, each one promoted to
     its own compositor layer by the `will-change:transform` below (the very
     ≈1.7 GB VRAM blow-up documented at L336) with every infinite CSS/SMIL
     animation on all N pages live at once. Over file:// there is no streaming /
     compression, so the parse window — and the stall — is at its worst.
     Fix: put the containment on the BASE rule so it holds from first paint,
     before any JS. Both mode rules below already re-declare content-visibility
     (present: auto→hidden/visible per is-current; scroll: auto), so each keeps
     full control once JS lands — this is a pre-JS floor, not a new policy.
     contain-intrinsic-size is unnecessary here: the base box is already sized by
     `aspect-ratio + width:100% + contain:size`, so skipped frames reserve their
     exact height and the page cannot reflow when they render (zero CLS). */
  content-visibility: auto;
}
/* F-370 · keep the FIRST frame eagerly rendered pre-JS. content-visibility:auto
   skips off-screen frames, but the cover is what the user is actually waiting to
   see; exempting it means first paint is never gated on the skip/render pass.
   Dropped the moment JS is ready — from then on the mode rules own visibility. */
.deck:not([data-js-ready]) .slide-frame:first-child {
  content-visibility: visible;
}
.slide-frame .slide {
  position: absolute; top: 0; left: 0;
  width: var(--fs-deck-width, 1920px);
  height: var(--fs-deck-height, 1080px);
  transform-origin: top left;
  transform: scale(var(--fs-scale, 1)) translateZ(0);  /* GPU layer hint */
  will-change: transform;
}
/* F-370 · pre-JS compositor relief. The `will-change:transform` +
   `translateZ(0)` above promote EVERY slide to its own 1920×1080 layer. Both are
   only needed for present mode's animated scale (scroll mode already opts out at
   L392 for exactly this reason) — pre-JS they cost N layers for zero benefit and
   are the second half of the cold-open stall. Hold them back until JS has picked
   a mode; present mode then re-promotes the current frame normally. */
.deck:not([data-js-ready]) .slide-frame .slide {
  transform: scale(var(--fs-scale, 1));
  will-change: auto;
}

/* ---- Present mode (1 slide visible, fills viewport) ----
   规范: in fullscreen the slide must letterbox (preserve 16:9), never stretch.
   We position the slide absolutely, anchor its CENTER at the viewport center
   via negative half-margins, then scale around its own center. This pattern
   is immune to grid-cell auto-sizing edge cases and to overflow:hidden
   clipping the pre-transform layout box.
   Use both 100vh and 100dvh so iOS Safari doesn't crop with the URL bar. */
.deck[data-mode="present"] {
  position: fixed; inset: 0;
  width: 100vw; height: 100vh; height: 100dvh;
  /* 2026-05-06 · letterbox 边带改用与 slide gradient 同调的深色径向渐变.
     16:9 内容显示在 16:10/16:10+ 屏时上下不可避免有 letterbox 物理边距,
     之前 #000 实色边带视觉上像"加边框",现在用与 slide 深底色一致的
     dark radial 让边带自然融入 slide bg。content / section / cover 三
     大母版的边缘色都接近 #000–#06091a,这个渐变对它们都协调。 */
  background: radial-gradient(120% 100% at 50% 50%, #0A1126 0%, #04081B 45%, #000 90%);
  overflow: hidden;
}
.deck[data-mode="present"] .slide-frame {
  position: absolute; inset: 0;
  aspect-ratio: auto; opacity: 0; pointer-events: none;
  transition: opacity .25s ease;
  /* background omitted — deck-level radial fills the letterbox area, then
     per-layout :has() rules below paint each frame with its slide's own bg
     so the 16:9 slide letterboxed in a 16:10 viewport blends seamlessly. */
  /* Off-screen frames don't render until they become current — saves
     ~600 ms of initial paint on a 22-slide deck (was rendering all 22
     radial-gradient decor layers up front). */
  content-visibility: auto;
  contain-intrinsic-size: 100vw 100vh;
}

/* 2026-05-06 · per-layout slide-frame background blends letterbox bars
   with the slide's actual content background. 16:9 design canvas in a
   16:10 viewport leaves ~60–130px top/bottom letterbox area; without
   this, those bars are pure black against a textured slide bg → visible
   border. With this rule, the bars carry the same gradient/image as
   the slide content edge → seamless fullscreen.

   Uses :has() selector (Chrome 105+ / Safari 15.4+ / Firefox 121+).
   On older browsers, falls through to the deck-level radial gradient. */
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="cover"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="end"]) {
  background: #000 var(--fs-asset-cover-bg) center/cover no-repeat;
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="section"]) {
  background: #000 var(--fs-asset-section-bg) center/cover no-repeat;
}
/* 2026-05-08 · agenda's slide bg is overridden to content-bg image (line ~1003);
   frame must match or a visible seam appears at slide boundary in present mode. */
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="agenda"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="content-2col"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="content-3up"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="big-stat"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="stats"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="quote"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="table"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="timeline"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="process"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="image-text"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="content-before-after"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="matrix-2x2"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="issue-tree"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="flow-swim"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="waterfall"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="arch-stack"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="logo-wall"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="chart"]),
/* 2026-05-28 · raw + iframe-embed default to content-bg letterbox fill.
   Without these in the whitelist, the slide-frame keeps the deck-level
   radial gradient in the letterbox area while .slide itself paints
   content-bg — luma jump at the slide top edge reads as "标题上方有黑条"
   on non-16:9 viewports. Most raw slides are content-page-ish; raw
   slides emulating cover / end / replica can override per-slide.
   Phase 1.c extras + chart stay in this asset-owning sheet so the relative
   lark-content-bg URL resolves beside feishu-deck.css in every delivery. */
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="raw"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="iframe-embed"]),
.deck[data-mode="present"] .slide-frame:has(> .slide[data-layout="canvas"]) {
  background: #000 var(--fs-asset-content-bg) center/cover no-repeat;
}
/* F-318 · present mode = SINGLE ambient-bg layer (the slide-frame, painted cover
   above so it fills the letterbox). The slide itself goes transparent so the
   frame's bg shows through SEAMLESSLY. ROOT CAUSE of the recurring「顶部有条边 /
   颜色和背景不一样」report: F-307 made BOTH the frame AND the slide paint
   content-bg, but at DIFFERENT cover crops — the frame crops to the VIEWPORT
   aspect (e.g. 16:10), the slide to its fixed 16:9 canvas. On any non-16:9
   fullscreen the letterbox appears and the two crops disagree at the boundary →
   a visible horizontal colour seam (worse the bigger the screen). Collapsing to
   one layer (frame paints, slide transparent) removes the seam for EVERY layout,
   every viewport — no per-deck patch. Scroll/browse mode is unaffected (the
   slide keeps its own card bg there — this rule is data-mode="present" only).
   A slide that needs a bespoke present-mode full-bleed background just sets it on
   `.slide` the obvious way: F-364's render-time _css_utils.promote_root_bg_to_frame
   hoists a slide-root `background` onto THIS frame for present mode (fills the
   letterbox seamlessly) and keeps it on the slide for scroll. BEFORE F-364 a
   slide-root bg silently TIED this rule's specificity (0,4,0) and — emitted after
   the framework sheet — won the cascade, regrowing the very seam below; authors
   had to hand-target `.slide-frame`. No longer.
   Full-page replicas are the exception: their contain-fitted image must remain
   on the slide, while the surrounding frame stays black. */
.deck[data-mode="present"] .slide-frame:has(> .slide.page-replica) {
  background: #000 !important;
}
.deck[data-mode="present"] .slide-frame > .slide:not(.page-replica) {
  background-image: none !important;
  background-color: transparent !important;
}
/* F-344 · extends F-318 ONE LEVEL DEEPER. Lifted/raw pages frequently carry their
   own full-bleed background wrapper (.qilu-page / .source-frame-wrap / .ppt-stage /
   .slide65-redo …) that re-paints the content-bg image (at the 16:9 slide crop) or a
   flat dark solid OVER the now-transparent .slide → the exact seam F-318 removed
   reappears, just nested inside the slide. F-318's `.slide-frame > .slide` selector
   can't reach those arbitrary descendants. feishu-deck.js::markBleedPanels
   geometry-detects them (≥95% slide coverage + content-bg-or-dark-solid backdrop),
   preserves any decorative gradient glows in --fs-bleed-grads, and tags them
   .fs-bleed-panel; this rule drops their opaque backdrop so the frame's single
   content-bg layer shows through seamlessly. Doubled class keeps specificity above
   the panel's own per-slide rule (wins over !important regardless of source order).
   Present-mode only, same contract as F-318 (scroll mode keeps the panel's bg). */
.deck[data-mode="present"] .slide .fs-bleed-panel.fs-bleed-panel {
  background-color: transparent !important;
  background-image: var(--fs-bleed-grads, none) !important;
}
/* F-345 · runtime decor mirror for LIFTED / custom full-bleed decoration. The
   declarative data-decor mirror (see §decor below) only covers framework decor
   TOKENS; a lifted page (raw/iframe/canvas) often paints CUSTOM radial glows + a
   darkening ::before vignette directly on a full-slide wrapper (.qilu-page …) that the
   frame can't know about, so the decoration stops at the 16:9 slide edge and the
   letterbox stays un-decorated → the same horizontal luma seam ("黑边"). When
   feishu-deck.js::markBleedPanels finds such a panel it captures its decoration (with
   per-layer sizing) into --fs-bleed-deco-* and the frame's own backdrop into
   --fs-bleed-frame-*, then tags the frame .fs-bleed-host; this re-paints the frame as
   [decoration ON TOP of the original backdrop] so the SAME decoration now covers the
   letterbox + slide uniformly. The decoration rides in the frame BACKGROUND (never a
   ::after) so it always sits behind the slide content — critical for the darkening
   vignette, which must not dim the cards/text. The panel's own slide-confined copy is
   zeroed (.fs-bleed-panel drops its backdrop; .fs-bleed-promoted drops its ::before
   vignette) so nothing is painted twice. Present-mode only, same contract as F-318. */
.deck[data-mode="present"] .slide-frame.fs-bleed-host {
  background-image:
    var(--fs-bleed-deco-image, none),
    var(--fs-bleed-frame-image, var(--fs-asset-content-bg)) !important;
  background-size:
    var(--fs-bleed-deco-size, auto),
    var(--fs-bleed-frame-size, cover) !important;
  background-position:
    var(--fs-bleed-deco-position, center),
    var(--fs-bleed-frame-position, center) !important;
  background-repeat:
    var(--fs-bleed-deco-repeat, no-repeat),
    var(--fs-bleed-frame-repeat, no-repeat) !important;
}
.deck[data-mode="present"] .slide .fs-bleed-promoted.fs-bleed-promoted::before {
  background-image: none !important;
  background-color: transparent !important;
}
.deck[data-mode="present"] .slide-frame.is-current {
  opacity: 1; pointer-events: auto;
  content-visibility: visible;
}
/* 2026-06-14 · present-mode compositor-layer relief (page-turn flicker / black
   flash on animation-heavy multi-page decks). All N frames sit stacked at
   inset:0 INSIDE the viewport, so content-visibility:auto (L181) never skips
   them — the browser treats every hidden frame as "on-screen", keeps each
   .slide's `will-change:transform` 1920×1080 layer alive (≈1.7GB VRAM @2x on a
   56-page deck) AND keeps compositing the hidden pages' (often infinite)
   animations. The GPU thrashes layers → constant flicker, worst mid page-turn.
   Fix: once JS has set .is-current, make non-current frames TRULY skip rendering
   (content-visibility:hidden — this also halts their CSS + SMIL animations, so
   it supersedes the earlier universal animation-pause rule AND the per-frame
   svg.pauseAnimations hack) and release their compositor layer
   (will-change:auto). Gated on [data-js-ready] so the pre-JS :first-child cover
   (below) still paints; current frame + scroll mode are untouched. */
.deck[data-mode="present"][data-js-ready] .slide-frame:not(.is-current) {
  content-visibility: hidden;
}
.deck[data-mode="present"][data-js-ready] .slide-frame:not(.is-current) .slide {
  will-change: auto;
}
/* Pre-JS state: first frame visible immediately so users don't see a
   black screen during the gap between CSS-applied (frame opacity:0) and
   JS-loaded (frame .is-current). IMPORTANT: gate this fallback behind
   :not([data-js-ready]) so the cover does NOT remain visible underneath
   later slide transitions after init — that caused occasional "flash back
   to cover" during content-page navigation. */
.deck[data-mode="present"]:not([data-js-ready]) .slide-frame:first-child {
  opacity: 1; pointer-events: auto;
}
.deck[data-mode="present"] .slide-frame .slide {
  position: absolute;
  left: 50%; top: 50%;
  width: var(--fs-deck-width, 1920px);
  height: var(--fs-deck-height, 1080px);
  margin-top: calc(0px - var(--fs-deck-half-height, 540px));
  margin-left: calc(0px - var(--fs-deck-half-width, 960px));
  transform: scale(var(--fs-scale, 1)) translateZ(0);  /* GPU layer hint */
  transform-origin: center center;  /* scale around the slide's center */
  will-change: transform;
}

/* ---- Scroll mode (mobile / browse: vertical stack) ---- */
.deck[data-mode="scroll"] {
  display: flex; flex-direction: column; gap: 12px;
  padding: 12px; max-width: 1280px; margin: 0 auto;
}
.deck[data-mode="scroll"] .slide-frame {
  /* allow:drop-shadow — scroll-mode preview cards float on a dark page;
     a soft drop shadow gives them physical separation from the page bg.
     Not slide content — the frame itself is the chrome that holds the
     slide; R12 ban is about shadows on .slide-internal cards. */
  border: 1px solid var(--fs-hairline);
  border-radius: 6px;
  box-shadow: 0 8px 32px rgba(0,0,0,0.45);
  /* 2026-05-29 · F-27 perf: skip painting off-screen frames in scroll/browse
     mode too (present mode already does this at line ~157). No
     contain-intrinsic-size needed — the base .slide-frame keeps
     `aspect-ratio:16/9 + width:100% + contain:size`, so each frame's box is
     sized by layout (not content), reserving correct height with zero
     scroll-anchor jank. Saves a full 1920×1080 paint per off-screen slide on
     long decks (esp. mobile browse). */
  content-visibility: auto;
}
/* F-338 · scroll-mode (mobile default) compositor relief. In scroll/browse mode
   the slide's scale transform is STATIC — set once on layout/resize, never
   animated (present mode is the only path that animates the scale + fs-reveal).
   So the base `.slide-frame .slide` rule's `will-change:transform` + the
   `translateZ(0)` baked into its transform promote EVERY laid-out slide to its
   own 1920×1080 compositor layer for ZERO benefit here — pure GPU memory /
   backing-store cost on the weak mobile GPUs scroll mode targets (the layer is
   created the moment content-visibility:auto renders an on-screen frame). Drop
   both promotion hints in scroll mode only; present mode (line ~277) is left
   completely untouched, so its tuned page-turn flicker behaviour is unaffected.
   Mirrors the present-mode non-current relief at line ~262. */
.deck[data-mode="scroll"] .slide-frame .slide {
  transform: scale(var(--fs-scale, 1));  /* no translateZ(0) — don't promote a static slide */
  will-change: auto;
}

/* ---- Deck UI overlay (progress bar, mode toggle, bottom controls) ---- */
.deck-ui {
  position: fixed; z-index: 50; pointer-events: none;
  font-family: var(--fs-font-latin); color: var(--fs-text-48);
  font-size: var(--fs-foot); letter-spacing: 0.08em;
}

/* Top progress bar — present-mode only, brand keyline gradient */
.deck-ui .deck-progress {
  position: fixed; top: 0; left: 0; right: 0;
  height: 3px; background: rgba(255,255,255,0.08);
  pointer-events: none; z-index: 60;
}
.deck-ui .deck-progress .bar {
  height: 100%; width: 0%;
  background: var(--fs-grad-keyline);
  transition: width 320ms cubic-bezier(.2,.7,.2,1);
}

/* Top-right mode toggle — REMOVED 2026-05-06.
   Was redundant with bottom .ctl.fs (fullscreen toggle) and noisy where the
   brand wordmark sits. Mobile scroll mode is auto-detected via viewport,
   no explicit toggle needed. */

/* Bottom-center control bar — prev / page-no / next | fullscreen */
.deck-ui .deck-controls {
  position: fixed; left: 50%; bottom: 24px;
  transform: translateX(-50%); pointer-events: auto;
  display: flex; align-items: center; gap: 6px;
  padding: 6px 10px; border-radius: 999px;
  background: rgba(8, 12, 24, 0.85);     /* opaque enough to drop blur on idle */
  border: 1px solid var(--fs-hairline);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  box-shadow: 0 12px 32px rgba(0,0,0,0.45);
  transition: opacity .35s ease;
}
.deck-ui .deck-controls .ctl {
  width: 36px; height: 36px; padding: 0; border: 0;
  background: transparent; cursor: pointer; border-radius: 999px;
  color: var(--fs-text-72);
  display: grid; place-items: center;
  transition: color .12s ease, background .12s ease;
}
.deck-ui .deck-controls .ctl:hover { color: #fff; background: rgba(255,255,255,0.08); }
.deck-ui .deck-controls .ctl:disabled {
  color: var(--fs-text-16); cursor: default; background: transparent;
}
.deck-ui .deck-controls .ctl-sep {
  display: inline-block; width: 1px; height: 20px;
  background: var(--fs-hairline); margin: 0 4px;
}
.deck-ui .deck-controls .indicator {
  display: inline-flex; align-items: baseline;
  min-width: 64px; justify-content: center;
  font: 600 var(--fs-foot)/1 var(--fs-font-latin); letter-spacing: 0.10em;
  color: #fff;
}
.deck-ui .deck-controls .indicator .cur {
  width: 2.8ch; min-width: 24px; box-sizing: content-box;
  margin: 0; padding: 3px 2px; border: 1px solid transparent; border-radius: 6px;
  background: transparent; color: #fff; outline: none; text-align: center;
  font: inherit; line-height: 1; letter-spacing: inherit; font-variant-numeric: tabular-nums;
  appearance: none; -webkit-appearance: none;
  transition: border-color .12s ease, background .12s ease;
}
.deck-ui .deck-controls .indicator .cur:hover {
  border-color: rgba(255,255,255,.18); background: rgba(255,255,255,.08);
}
.deck-ui .deck-controls .indicator .cur:focus {
  border-color: #3a86ff; background: rgba(58,134,255,.24);
}
.deck-ui .deck-controls .indicator .sep { color: var(--fs-text-40); margin: 0 2px; }
.deck-ui .deck-controls .indicator .total { color: var(--fs-text-48); font-weight: 500; }
.deck-ui .deck-controls .ctl.fs .i-exit { display: none; }

/* Tiny corner hint — present mode only */
.deck-ui .nav-hint {
  position: fixed; left: 24px; bottom: 32px; pointer-events: none;
  color: var(--fs-text-40); font: 500 var(--fs-foot)/1 var(--fs-font-latin);
  letter-spacing: 0.10em;
}

/* Hide chrome in scroll mode (also belt-and-suspenders to JS display: none) */
.deck[data-mode="scroll"] ~ .deck-ui .deck-progress,
.deck[data-mode="scroll"] ~ .deck-ui .deck-controls,
.deck[data-mode="scroll"] ~ .deck-ui .nav-hint { display: none !important; }

/* F-316 · deck-wide opt-in (deck.json `deck.hide_progress: true` → data-hide-progress
   on .deck): hide ONLY the top reading-progress keyline, persistently, without the
   per-URL #clean/kiosk hash (those also hide controls + hints). !important beats
   feishu-deck.js's inline `.deck-progress` display set per present-mode. */
.deck[data-hide-progress] ~ .deck-ui .deck-progress { display: none !important; }

/* 规范: in 16:9 fullscreen there's no letterbox to hide chrome in — chrome
   would permanently overlay slide content (e.g., the slide's own footer at
   slide y=1032 is at viewport y=1032 when scale=1, right where the bottom
   pill lives). Auto-fade after 2.5s idle and restore on any user input.
   Note: pointer-events stays AUTO when faded so that hovering/moving onto
   the chrome wakes it. The JS mousemove listener also wakes it; this is
   belt-and-suspenders. Backdrop-filter is dropped to free GPU during fade. */
.deck-ui .deck-controls,
.deck-ui .nav-hint { transition: opacity .35s ease; }
.deck-ui.is-idle .deck-controls,
.deck-ui.is-idle .nav-hint {
  opacity: 0;
  /* pointer-events stays auto so hover wakes chrome */
}
.deck-ui.is-idle .deck-controls {
  backdrop-filter: none; -webkit-backdrop-filter: none;  /* free GPU */
}
.deck-ui.is-idle .deck-progress { opacity: .35; }
/* Hovering chrome cancels idle fade visually (the JS mousemove also fires) */
.deck-ui .deck-controls:hover { opacity: 1 !important; }
.deck-ui .deck-controls:focus-within { opacity: 1 !important; }
/* Kiosk / projection mode (URL hash #proj | #bare | #clean | #kiosk):
   force-hide ALL chrome — page bar, controls, hint — and stay hidden
   (unlike .is-idle, hover/mousemove never wakes it). For projection,
   embedded iframes (e.g. Miaoda), and signage. Toggled by JS on hashchange. */
.deck-ui.is-kiosk .deck-progress,
.deck-ui.is-kiosk .deck-controls,
.deck-ui.is-kiosk .nav-hint { display: none !important; }

/* Mobile: shrink controls, remove hint */
@media (max-width: 640px) {
  .deck-ui .deck-controls { gap: 2px; padding: 4px 6px; bottom: 16px; }
  .deck-ui .deck-controls .ctl { width: 32px; height: 32px; }
  .deck-ui .deck-controls .indicator { min-width: 56px; font-size: 13px; }
  .deck-ui .nav-hint { display: none !important; }
}

/* ============================================================================
   Slide chrome — logo + footer (used by every non-cover layout)
   ============================================================================ */

.slide { color: var(--fs-text); font-family: var(--fs-font-cjk); overflow: hidden; }

/* ---- Official 豆包工作｜飞书 cobrand lockup ----
   规范 only permits the Doubao Work-first lockup separated by a vertical rule.
   The complete lockup is one official image; never compose it from two logos,
   text, avatar art, or pseudo-elements. Dark pages use the color-on-dark asset;
   light and mono variants are explicit classes. */
.slide .wordmark {
  position: absolute;
  top: 58px; right: 124px;             /* -3px from master y=61 aligns the two text tops */
  width: 317px; height: 50px;          /* 3869:612 official native ratio */
  background: var(--fs-asset-cobrand) left center/contain no-repeat;
  text-indent: -9999px; overflow: hidden;
  z-index: 20;                          /* brand chrome ABOVE flattened content.
     .slide>* (line ~1838) puts every direct .slide child at z-index:1; the
     wordmark declared none, so it tied content at 1 and a later full-bleed raw
     child (layout:"raw") painted over it by DOM order = logo flash-then-gone.
     20 sits in the chrome band 2–49: above content, below pager(50/60) &
     mobile chrome(1001). cover/end overrides inherit this. */
}
/* Cover & End (封面 / 封底) — same official lockup, top-left, larger. */
.slide[data-layout="cover"] .wordmark,
.slide[data-layout="end"] .wordmark {
  top: 113px; left: 120px; right: auto;
  width: 469px; height: 74px;
  background-image: var(--fs-asset-cobrand);
}
/* Explicit variants; color-on-dark remains the default. */
.slide .wordmark.is-on-light {
  background-image: var(--fs-asset-cobrand-on-light);
}
.slide .wordmark.is-mono {
  background-image: var(--fs-asset-cobrand-mono);
}
.slide .wordmark.is-mono.is-on-light {
  background-image: var(--fs-asset-cobrand-mono-on-light);
}
/* Mono exports already use a vertically centered canvas; keep the master y. */
.slide:not([data-layout="cover"]):not([data-layout="end"]) .wordmark.is-mono {
  top: 61px;
}

/* Per-slide .footer chrome (brand line + page no.) is RETIRED as of 2026-05.
   The fullscreen present-mode pager (bottom-center prev/page-no/next bar
   in feishu-deck.js / .deck-ui) shows the page number, and the corner
   logo (.wordmark) carries the brand. The repeated bottom-right strip
   was redundant clutter. Templates and render.py no longer emit this
   markup; this rule hides any leftover .footer/.pageno DOM in legacy
   decks generated before the cleanup. */
.slide .footer, .slide .pageno { display: none; }

/* Eyebrow (uppercase Latin, accent color, tracked) */
.slide .eyebrow {
  font: 600 var(--fs-foot)/1 var(--fs-font-latin);
  letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--fs-accent);
}

/* Keyline accent bar */
.slide .keyline {
  width: 96px; height: 3px;
  background: var(--fs-grad-keyline);
  border: 0; border-radius: 999px;
}

/* Reusable bilingual title block */
.slide .title-zh {
  font: 600 var(--fs-title)/1.15 var(--fs-font-display-cjk);
  letter-spacing: -0.005em; color: #fff;
}
.slide .title-en {
  margin-top: 14px;
  font: 500 var(--fs-body)/1.4 var(--fs-font-latin);
  color: var(--fs-text-48); letter-spacing: 0.02em;
}

/* Body text */
.slide .body {
  font: 500 var(--fs-sub)/1.5 var(--fs-font-cjk); color: var(--fs-text-72);
}
.slide .lede {
  font: 500 var(--fs-sub)/1.4 var(--fs-font-cjk); color: var(--fs-text-72);
}
.slide .caption {
  /* Body content per SKILL.md "Typography floor — content body & captions
     (mandatory, ≥ 22 px)". 2026-05-16: bumped from 16 → 22; pre-bump
     value was below the documented body floor and read as illegible
     fine print on projector. For chrome-style citations (e.g. footnote
     under a stats table) use `.footnote` (14 px chrome) instead. */
  font: 500 var(--fs-body)/1.4 var(--fs-font-latin);
  color: var(--fs-text-48); letter-spacing: 0.06em;
}

/* Inline highlight: cyan keyword inside white text. Use sparingly. */
.slide .accent-text {
  background: var(--fs-grad-keyline);
  -webkit-background-clip: text; background-clip: text;
  color: transparent;
}
.slide .hl { color: var(--fs-cyan); }

/* Tinted icon tile (48–64 px) */
.slide .tile {
  width: 64px; height: 64px;
  border-radius: var(--fs-radius-tile);
  background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
  border: 1px solid color-mix(in srgb, var(--fs-accent) 42%, transparent);
  display: grid; place-items: center;
  color: var(--fs-accent);
}
.slide .tile svg { width: 32px; height: 32px; }
.slide .tile.sm { width: 48px; height: 48px; }
.slide .tile.sm svg { width: 24px; height: 24px; }

/* Pills */
.slide .pill {
  display: inline-flex; align-items: center; gap: 10px;
  border: 1px solid var(--fs-text-16);
  border-radius: var(--fs-radius-pill);
  padding: 10px 18px;
  font: 500 var(--fs-foot)/1 var(--fs-font-cjk);
  color: var(--fs-text-72);
  background: rgba(255,255,255,0.02);
}
.slide .pill svg { width: 20px; height: 20px; color: var(--fs-accent); }
.slide .pill.solid {
  background: var(--fs-accent); border-color: transparent; color: #fff;
}

/* ---- Content tag (24 Body) — for labels that CARRY CONTENT, not chrome.
   Use when the label is "who owns / when due / what status / what category"
   that the reader needs to read at body weight. Differs from `.pill` (16
   Foot chrome) — `.pill` is for true chrome like pagination indicator;
   `.content-tag` is for content-bearing labels.
   2026-05-16: introduced after a session where 6 slides used `.pill`-style
   16px chrome for category labels and the user flagged each as "too small". */
.slide .content-tag {
  display: inline-flex; align-items: center; gap: 8px;
  font: 700 var(--fs-body)/1.2 var(--fs-font-cjk);
  letter-spacing: 0.04em;
  color: var(--fs-accent);
}

/* ---- Column pill (28 Sub) — for COLUMN-LABEL chips that label a panel
   or column. E.g. "困境 / 解法" in a content-2col compare layout,
   "重点客群" in a big-stat focus card, "DEADLINE / 截止时间" in a
   deadline-card. These are SECTION-level labels, not meta — they belong
   to Sub tier (28), with optional pill chrome (border + padding). */
.slide .column-pill {
  display: inline-flex; align-items: center; gap: 10px;
  font: 700 var(--fs-sub)/1 var(--fs-font-cjk);
  letter-spacing: 0.06em;
  color: var(--fs-accent);
  padding: 10px 22px; border-radius: var(--fs-radius-pill);
  background: color-mix(in srgb, var(--fs-accent) 18%, transparent);
}

/* ---- Pattern N · 5-up Overview with Hero Numerals (2026-05-16) ----
   When a `.slide[data-layout="content-3up"]` has a `.grid.overview-grid`
   child, override the framework's hardcoded `repeat(3, 1fr)` to give
   the 5-column layout. Uses a 2-class selector (`grid.overview-grid`)
   to outweigh the single-attribute framework rule on specificity.
   Pair with `.overview-card` for the per-card layout (hero numeral +
   title + body, with min-height to give the hero numeral breathing room).
   Tonal accents `.is-c1 ... .is-c5` ship below. */
.slide .grid.overview-grid {
  grid-template-columns: repeat(5, 1fr);
  gap: 24px;
}
.slide .grid.overview-grid .overview-card {
  padding: 36px 28px 32px;
  display: flex; flex-direction: column; gap: 18px;
  min-height: 320px;
  border-radius: var(--fs-radius-card);
  border: 1px solid var(--fs-text-16);
  background: rgba(255,255,255,0.04);
}
.slide .grid.overview-grid .overview-card .ov-num {
  /* allow:typescale — Hero numeral, 88px (>1.8× Title 48 to clear muddled
     middle per Goldilocks). Weight 500 medium + 55% semi-transparent brand
     color → editorial, doesn't compete with the bold card title. */
  font: 500 88px/0.95 var(--fs-font-latin);
  letter-spacing: -0.01em;
  margin-bottom: 4px;
}
.slide .grid.overview-grid .overview-card .ov-name {
  font: 700 var(--fs-title)/1.15 var(--fs-font-cjk);
  color: #fff; letter-spacing: -0.005em;
}
.slide .grid.overview-grid .overview-card .ov-desc {
  font: 500 var(--fs-body)/1.5 var(--fs-font-cjk);
  color: #fff; opacity: 0.92; margin-top: auto;
}
/* Tonal accents per card */
.slide .grid.overview-grid .overview-card.is-c1 { border-color: rgba(60,127,255,0.55);  background: linear-gradient(180deg, rgba(60,127,255,0.12) 0%, rgba(8,12,24,0.50) 100%); }
.slide .grid.overview-grid .overview-card.is-c1 .ov-num { color: color-mix(in srgb, var(--fs-blue) 55%, transparent); }
.slide .grid.overview-grid .overview-card.is-c2 { border-color: rgba(159,111,241,0.55); background: linear-gradient(180deg, rgba(159,111,241,0.14) 0%, rgba(8,12,24,0.50) 100%); }
.slide .grid.overview-grid .overview-card.is-c2 .ov-num { color: color-mix(in srgb, var(--fs-violet) 55%, transparent); }
.slide .grid.overview-grid .overview-card.is-c3 { border-color: rgba(51,214,192,0.55);  background: linear-gradient(180deg, rgba(51,214,192,0.12) 0%, rgba(8,12,24,0.50) 100%); }
.slide .grid.overview-grid .overview-card.is-c3 .ov-num { color: color-mix(in srgb, var(--fs-teal) 55%, transparent); }
.slide .grid.overview-grid .overview-card.is-c4 { border-color: rgba(254,127,0,0.50);   background: linear-gradient(180deg, rgba(254,127,0,0.14) 0%, rgba(8,12,24,0.50) 100%); }
.slide .grid.overview-grid .overview-card.is-c4 .ov-num { color: color-mix(in srgb, var(--fs-orange) 55%, transparent); }
.slide .grid.overview-grid .overview-card.is-c5 { border-color: rgba(255,255,255,0.30); background: linear-gradient(180deg, rgba(255,255,255,0.06) 0%, rgba(8,12,24,0.50) 100%); }
.slide .grid.overview-grid .overview-card.is-c5 .ov-num {
  /* allow:white-opacity — decorative Hero numeral, 55% mirrors c1-c4. */
  color: rgba(255,255,255,0.55);
}

/* ---- Canonical card recipe (use this for ANY "tag + title + body" card) ----
   2026-05-16: extracted from slide-7/8 pattern that took 8 iterations
   to converge. The canonical shape:
     <div class="canonical-card">
       <span class="cc-tag">类别 / 状态 / owner (24 body bold brand)</span>
       <h3 class="cc-title">标题 (28 sub bold white)</h3>
       <p class="cc-body">描述 (24 body regular white)</p>
     </div>
   Child classes are `cc-*` prefixed (NOT `card-*`) to avoid collision
   with the existing `.card` global class system (`.card .ctitle`,
   `.card .cbody`, `.card .cfoot`). Apply `.is-hero` for the focal
   card (brand border + glow). Apply `.is-urgent` for orange warning.
*/
.slide .canonical-card {
  padding: 26px 28px;
  border-radius: var(--fs-radius-card);
  border: 1px solid var(--fs-text-16);
  background: rgba(255,255,255,0.04);
  display: flex; flex-direction: column; gap: 10px;
}
.slide .canonical-card .cc-tag {
  font: 700 var(--fs-body)/1.2 var(--fs-font-cjk);
  letter-spacing: 0.04em;
  color: var(--fs-accent);
}
.slide .canonical-card .cc-title {
  font: 700 var(--fs-sub)/1.25 var(--fs-font-cjk);
  color: #fff; letter-spacing: -0.005em; margin: 0;
}
.slide .canonical-card .cc-body {
  font: 500 var(--fs-body)/1.45 var(--fs-font-cjk);
  color: #fff; margin-top: auto;
}
.slide .canonical-card.is-hero {
  border: 1.5px solid color-mix(in srgb, var(--fs-accent) 55%, transparent);
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--fs-accent) 16%, transparent) 0%,
    rgba(8,12,24,0.50) 100%);
}
.slide .canonical-card.is-urgent {
  border-color: rgba(254,127,0,0.55);
  background: linear-gradient(180deg, rgba(254,127,0,0.14) 0%, rgba(8,12,24,0.50) 100%);
}
.slide .canonical-card.is-urgent .cc-tag { color: var(--fs-orange); }

/* Cards on dark */
.slide .card {
  position: relative;
  background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0));
  border: 1px solid var(--fs-hairline);
  border-radius: var(--fs-radius-card);
  padding: 36px;
  display: flex; flex-direction: column; gap: 20px;
  min-height: 400px;
}

/* Global card-text fallback — applies to ANY card-style container
   (.card, .verdict-card, .voice-card, .arch-hand, etc.). Without this
   only content-3up's `.card .ctitle/.cbody` were sized; verdict-card on
   other layouts (slides 02 / 16 / 18) inherited browser-default 16 px,
   which read as visibly smaller than neighboring slides. content-3up's
   tighter overrides at lines 514-518 still apply where present. */
.slide .ctitle {
  font: 600 var(--fs-sub)/1.3 var(--fs-font-cjk);
  color: var(--fs-text); margin: 0;
}
.slide .cbody {
  font: 500 var(--fs-body)/1.6 var(--fs-font-cjk);
  color: var(--fs-text-72); margin: 0;
}

/* ============================================================================
   LAYOUT 01 — Cover (封面) — matches master slideLayout1
   - Background: image2.jpg (white flower on dark, occupies right ~50%)
   - 官方豆包工作｜飞书联名标 top-LEFT at (120, 113) size 469×74 — .wordmark override
   - Title at left: 124, top: 285, width: 884 (left half), font 100px / 700
   - Subtitle below at top: 551, same font family, smaller
   - Author / company block at bottom-left at top: 803
   ============================================================================ */
.slide[data-layout="cover"] {
  background: #000 var(--fs-asset-cover-bg) center/cover no-repeat;
}
.slide[data-layout="cover"] .stage {
  position: absolute; top: 285px; left: 124px;
  width: 980px;                         /* left half — widened from master 884 because
                                           典型 9-字中文标题 @ 100px 会在 884px 内溢出
                                           造成 4-5 行 auto-wrap. Dark gradient overlay
                                           extends to x≈1152, so 980 still preserves
                                           flower visibility on the right. */
}
.slide[data-layout="cover"] h1.title {
  font: 700 100px/1.18 var(--fs-font-display-cjk);
  letter-spacing: -0.005em;
  color: #fff;
  margin: 0;
  padding-block: 10px;
}
.slide[data-layout="cover"] .subtitle {
  /* allow:white-opacity — cover subtitle is master-spec hierarchy; SKILL.md
     "Step 2 · Cover page" actually says NO subtitle at all in the default
     authoring flow — this rule exists for the rare bilingual cover. */
  margin-top: 132px;                    /* master gap: y=367-189-156 = ~22 native = 33 → larger to breathe */
  font: 600 40px/1.4 var(--fs-font-cjk);
  color: rgba(255,255,255,0.92);
  margin-bottom: 0;
}
.slide[data-layout="cover"] .author {
  /* allow:white-opacity — author block is master-spec; 0.85 keeps the
     name+date a half-step quieter than the 100px title without going gray.
     2026-05-06 · top: 803 → 720 — author block sits closer to a 2-line title.
     Master spec said 803 (bottom-left); user feedback elevated this since the
     huge gap reads as "two unrelated stacks" rather than "title + signature". */
  position: absolute; left: 124px; top: 720px;
  font: 600 30px/1.45 var(--fs-font-cjk);
  color: rgba(255,255,255,0.85);
  letter-spacing: 0.02em;
}
.slide[data-layout="cover"] .author .role {
  /* allow:white-opacity — `.role` is the small date prefix on cover, master
     spec dims it to 0.55 to keep the eye on the name. */
  display: inline-block; margin-right: 32px;
  color: rgba(255,255,255,0.55); font-weight: 500;
}
/* (cover .footer hide rule retired — global .slide .footer { display: none } now handles it.) */

/* ============================================================================
   LAYOUT 02 — Section / chapter divider (一级章节页) — master slideLayout3
   - Background: image4 (dark with cool blue glow on right) — section gradient
   - Chapter "01." at (126, 271), font 160 px / 700  ← was 280, that's why it clipped
   - Title at (126, 447), font 88 px / 700           ← was 128
   - Subtitle at (126, 567), font 48 px / 500
   - Official cobrand lockup top-RIGHT handled by default .wordmark
   ============================================================================ */
.slide[data-layout="section"] {
  background: #000 var(--fs-asset-section-bg) center/cover no-repeat;
}
.slide[data-layout="section"] .chapter-num {
  position: absolute; top: 271px; left: 126px;
  /* line-height 1.0 (was 0.9) + padding-top prevents the gradient-clipped glyph from
     being cut off at the line-box top edge in WebKit when -webkit-background-clip:text */
  font: 700 160px/1 var(--fs-font-latin);
  letter-spacing: -0.02em; padding-top: 8px;
  color: #fff;
  background: var(--fs-grad-keyline);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}
.slide[data-layout="section"] h2.title {
  position: absolute; top: 447px; left: 126px;
  font: 700 88px/1.18 var(--fs-font-display-cjk);
  letter-spacing: -0.005em; color: #fff;
  max-width: 1300px;
}
.slide[data-layout="section"] .lede {
  /* allow:white-opacity — section .lede is master-spec subtitle; 0.72 keeps
     it visually subordinate to the 88px chapter title above. */
  position: absolute; top: 597px; left: 126px;
  font: 500 36px/1.45 var(--fs-font-cjk);
  color: rgba(255,255,255,0.72);
  max-width: 1100px;
}
.slide[data-layout="section"] .pills {
  position: absolute; bottom: 96px; left: 126px; right: 124px;
  display: flex; flex-wrap: wrap; gap: 14px;
}
/* 2026-05-20 · section pill 升 16→24 (Body tier) — Hero-context label floor 规则
   (SKILL.md 2026-05-17): section 页 chapter-num 160 / title 88 是 hero anchor,
   同一页内 content label(产品名)必须 ≥ 24 Body,不能 16 chrome,否则在投影
   距离上看不清。 */
.slide[data-layout="section"] .pills .pill {
  font-size: var(--fs-body);
  padding: 12px 22px;
}

/* ============================================================================
   LAYOUT 03 — Content (3-up cards)
   ============================================================================ */
.slide[data-layout="content-3up"] {
  /* background set by the unified content-bg rule below — uses
     lark-content-bg.jpg (飞书母版 image1.jpg) for the subtle dark gradient. */
}
/* Removed (F-271, 2026-06-10): the content-3up `.header` override that pinned
   it to top:96/left:96/right:96 (grid, bottom-rule). It was a DEAD rule —
   the unified content/raw header rule below (~1521, `.slide[...content-3up...]
   .header { top:61; left:73; right:420; display:block }`) has identical
   specificity but wins on later cascade, so every content-3up title already
   renders at the master coord 61/73 (verified: computed top/left = 61/73 both
   before and after this deletion, byte-identical example renders). Keeping it
   only misled docs / layout-lint into thinking content-3up titles sit at 96/96. */
/* Removed v1.5.2: content-3up override that made the title 64 px while
   every other content layout was 52 px (per the unified header rule).
   The user noticed: pages with content-3up showed visibly larger titles
   than content-2col / process / table / etc. — inconsistent. The unified
   52 px rule below now applies to ALL content layouts.
   See feishu-deck.css line ~922 .slide .header .title-zh */
.slide[data-layout="content-3up"] .header .pageno {
  font: 500 var(--fs-foot)/1 var(--fs-font-latin);
  color: var(--fs-text-48); letter-spacing: 0.18em;
}
.slide[data-layout="content-3up"] .grid {
  position: absolute; top: 360px; left: 96px; right: 96px; bottom: 120px;
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 36px;
}
.slide[data-layout="content-3up"] .card .head {
  display: flex; justify-content: space-between; align-items: center;
}
.slide[data-layout="content-3up"] .card .num {
  /* allow:typescale — hero numeral 88 anchors the card; was 48 (off-tier
     but visually competing with the 28 ctitle); upgraded so the eye locks
     onto the index, then the 48 title, then the body. Pairs with Pattern N. */
  font: 500 88px/0.95 var(--fs-font-latin);
  color: color-mix(in srgb, var(--fs-accent) 55%, transparent);
  letter-spacing: -0.01em;
}
.slide[data-layout="content-3up"] .card .ctitle {
  /* 2026-05-17: 28 → 48 — when a hero numeral is the visual anchor, the
     accompanying title must hold its own at Title tier; 28 reads as sub
     and the title looked weak against the 88 num. */
  font: 700 var(--fs-title)/1.15 var(--fs-font-cjk); color: #fff;
}
.slide[data-layout="content-3up"] .card .cbody {
  /* 2026-05-16: 20 → 22 — body content per SKILL.md typography floor. */
  font: 500 var(--fs-body)/1.55 var(--fs-font-cjk); color: var(--fs-text-65); flex: 1;
}
.slide[data-layout="content-3up"] .card .cfoot {
  display: flex; align-items: center; justify-content: space-between;
  border-top: 1px solid var(--fs-hairline); padding-top: 16px;
  font: 500 var(--fs-foot)/1 var(--fs-font-latin); letter-spacing: 0.14em;
  text-transform: uppercase; color: var(--fs-text-48);
}
.slide[data-layout="content-3up"] .card .cfoot svg { color: var(--fs-accent); }
/* 规范 L3 落地(2026-05-30):content-3up 卡片按内容尺寸 + 共享中线 + 卡内垂直
   居中,取代全局 `.slide .grid .card { align-self: stretch }` 的强制等高。等高会把
   内容最多的那张卡片文字挤到底边(实测下内距 −27px 溢出,两侧却空 113px)。改后:
   稀疏卡片居中、留白均衡;最满的卡片长高吃下内容,不再贴底。只覆盖 content-3up,
   不动 1712 行的全局 legacy stretch(其它版式仍依赖它)。
   验证:assets/check-distribution.py 在青啤 slide 3 上 3 findings → 0,全 deck 零回归。 */
.slide[data-layout="content-3up"] .grid .card {
  /* 2026-06-01: align-self center → stretch. The 2026-05-30 L3 fix used
     `center` to stop the fullest card overflowing under the THEN-current
     fixed-height band. The next day (2026-05-31, ~line 1685) the grid was
     reworked to `flex:1 + align-content:center`, so the single grid row is
     now `auto` = tallest card's content height and centered in the band.
     Under THAT geometry `center` left shorter cards at their own (smaller)
     content height → visibly ragged card heights (user-reported: "第三个框
     变大了"). `stretch` makes every card match the row track (= tallest
     content), giving an even grid WITHOUT overflow — the track is auto-sized
     to content, so the tallest card's content exactly fits by construction.
     `justify-content: center` keeps content vertically centered inside. */
  align-self: stretch;
  justify-content: center;
}

/* ============================================================================
   LAYOUT 04 — Content 2-column (text + visual)
   ============================================================================ */
/* content-2col background set by the unified content-bg rule below — uses
   lark-content-bg.jpg (飞书母版 image1.jpg) for the subtle dark gradient. */
.slide[data-layout="content-2col"] .header {
  position: absolute; top: 96px; left: 96px; right: 96px;
  padding-bottom: 32px; border-bottom: 1px solid var(--fs-hairline);
  display: grid; grid-template-columns: 1fr auto; align-items: end;
}
.slide[data-layout="content-2col"] .grid {
  position: absolute; top: 360px; left: 96px; right: 96px; bottom: 120px;
  display: grid; grid-template-columns: 1.1fr 1fr; gap: 96px;
}
.slide[data-layout="content-2col"] .col-text { display: flex; flex-direction: column; gap: 32px; }
.slide[data-layout="content-2col"] .col-text .lede { color: #fff; }
.slide[data-layout="content-2col"] .col-text ul.feature-list {
  display: flex; flex-direction: column; gap: 22px; margin-top: 12px;
}
/* li uses block + left padding (NOT grid). Grid would treat <b> and the
   trailing text node as two separate items: text node would auto-place
   into row 2 / col 1 (36px wide) and CJK chars would wrap one-per-line. */
/* feature-list bullet pattern — generalized 2026-05-22 from content-2col
   `.col-text` scope to ANY slide (utility class). Use `<ul class="feature-list">`
   in any raw layout to get framework-standard colored-dot bullets with halo. */
.slide ul.feature-list li {
  /* 2026-05-17: text-72 → #fff. feature-list items ARE body content;
     muted color was readability gap on projector. */
  position: relative; padding-left: 28px;
  font: 500 var(--fs-body)/1.45 var(--fs-font-cjk); color: #fff;
  list-style: none;
}
.slide ul.feature-list li::before {
  content: ""; position: absolute; left: 0; top: 14px;
  width: 8px; height: 8px; border-radius: 999px;
  background: var(--fs-accent);
  box-shadow: 0 0 0 6px color-mix(in srgb, var(--fs-accent) 16%, transparent);
}

/* ============================================================================
   UTILITY · .fs-claim-row — icon + label + description row (added 2026-05-22)
   ----------------------------------------------------------------------------
   For "highlight badge / claim tile / KPI callout" patterns where each tile
   has: small icon (left) + bold label (line 1) + lighter desc (line 2 below).
   USE THIS instead of authoring ad-hoc `.highlight` / `.hi` / `.kpi-tile`
   structures in raw layouts.

   Structure:
     <div class="fs-claim-row is-teal">              <!-- accent variant -->
       <span class="fs-claim-row__icon">✓</span>
       <div class="fs-claim-row__text">
         <span class="fs-claim-row__label">全自动 · 端到端</span>
         <span class="fs-claim-row__desc">从三流输入到双平台产出零人工介入</span>
       </div>
     </div>

   Accents: .is-blue / .is-teal / .is-violet / .is-purple / .is-orange
   (defaults to teal if no variant class).
   ============================================================================ */
.slide .fs-claim-row {
  display: flex; align-items: flex-start; gap: 12px;
  padding: 12px 16px; border-radius: 12px;
  border: 1px solid var(--fs-divider);
  background: rgba(255, 255, 255, 0.04);
}
.slide .fs-claim-row__icon {
  width: 24px; height: 24px; flex: 0 0 24px;
  border-radius: 50%;
  display: grid; place-items: center;
  font: 700 var(--fs-foot)/1 var(--fs-font-latin);
  background: rgba(51, 214, 192, 0.20);
  color: var(--fs-teal);
}
.slide .fs-claim-row__text {
  flex: 1; display: flex; flex-direction: column; gap: 4px;
  min-width: 0;
}
.slide .fs-claim-row__label {
  font: 700 var(--fs-body)/1.3 var(--fs-font-cjk); color: #fff;
  letter-spacing: -0.005em;
}
.slide .fs-claim-row__desc {
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk); color: #cdd0df;
}
/* Accent variants */
.slide .fs-claim-row.is-blue   { border-color: rgba(60,127,255,0.40); background: rgba(60,127,255,0.05); }
.slide .fs-claim-row.is-blue   .fs-claim-row__icon { background: rgba(60,127,255,0.20); color: var(--fs-blue); }
.slide .fs-claim-row.is-teal   { border-color: rgba(51,214,192,0.40); background: rgba(51,214,192,0.05); }
.slide .fs-claim-row.is-teal   .fs-claim-row__icon { background: rgba(51,214,192,0.20); color: var(--fs-teal); }
.slide .fs-claim-row.is-violet { border-color: rgba(159,111,241,0.40); background: rgba(159,111,241,0.05); }
.slide .fs-claim-row.is-violet .fs-claim-row__icon { background: rgba(159,111,241,0.20); color: var(--fs-violet); }
.slide .fs-claim-row.is-purple { border-color: rgba(123,90,209,0.40); background: rgba(123,90,209,0.05); }
.slide .fs-claim-row.is-purple .fs-claim-row__icon { background: rgba(123,90,209,0.20); color: var(--fs-purple); }
.slide .fs-claim-row.is-orange { border-color: rgba(254,127,0,0.40); background: rgba(254,127,0,0.05); }
.slide .fs-claim-row.is-orange .fs-claim-row__icon { background: rgba(254,127,0,0.20); color: var(--fs-orange); }

/* ============================================================================
   UTILITY · .fs-titled-panel — bind a heading to its members in ONE panel
   (added 2026-06-05, promoted from richness-primitives recipe / pg-27 fix).
   USE when a heading LABELS a set (steps / criteria / KPIs / do-don't): the
   title + items live in one tinted panel — header gets a divider, members are
   internal divider-rows. Without it, a bare heading floats orphaned above a row
   of equal-weight bordered cards and the group stops reading as one unit.
   Pair it 等高 with a sibling visual (image / chart) via the row's
   `align-items: stretch` so the two read as a balanced pair.

   Structure:
     <div class="fs-titled-panel is-teal">                <!-- accent variant -->
       <div class="fs-titled-panel__head">
         <h3 class="fs-titled-panel__title">三步算账</h3>
         <span class="fs-titled-panel__tag">10 MIN</span>     <!-- optional -->
       </div>
       <div class="fs-titled-panel__rows">
         <div class="fs-titled-panel__row">
           <span class="fs-titled-panel__n">01</span>
           <div><p class="fs-titled-panel__rt">看线上单量</p>
                <p class="fs-titled-panel__rd">打开美团看竞品当日单量。</p></div>
         </div>
       </div>
     </div>
   Accents: .is-blue / .is-teal / .is-violet / .is-purple / .is-orange (default teal).
   ============================================================================ */
.slide .fs-titled-panel {
  --tp-accent: var(--fs-teal);
  display: flex; flex-direction: column; min-height: 0;
  border-radius: 18px;
  border: 1px solid color-mix(in srgb, var(--tp-accent) 32%, transparent);
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--tp-accent) 8%, transparent),
    color-mix(in srgb, var(--tp-accent) 2%, transparent));
  padding: 20px 26px;
}
.slide .fs-titled-panel__head {
  display: flex; align-items: baseline; gap: 14px;
  padding-bottom: 16px;
  border-bottom: 1px solid color-mix(in srgb, var(--tp-accent) 28%, transparent);
}
.slide .fs-titled-panel__title { font: 800 32px/1.2 var(--fs-font-cjk); color: #fff; margin: 0; }
.slide .fs-titled-panel__tag {
  font: 800 24px/1 var(--fs-font-latin); color: var(--tp-accent); letter-spacing: 0.04em;
}
.slide .fs-titled-panel__rows {
  display: flex; flex-direction: column; flex: 1; min-height: 0;
  justify-content: space-between;
}
.slide .fs-titled-panel__row {
  display: grid; grid-template-columns: auto 1fr; column-gap: 16px;
  align-items: start; padding: 18px 0;
  border-top: 1px solid rgba(255,255,255,0.10);
}
.slide .fs-titled-panel__row:first-child { border-top: none; }
.slide .fs-titled-panel__n  { font: 800 30px/1 var(--fs-font-latin); color: var(--tp-accent); }
.slide .fs-titled-panel__rt { font: 800 26px/1.3 var(--fs-font-cjk); color: #fff; margin: 0 0 4px; }
.slide .fs-titled-panel__rd { font: 500 24px/1.45 var(--fs-font-cjk); color: rgba(255,255,255,0.90); margin: 0; }
.slide .fs-titled-panel.is-blue   { --tp-accent: var(--fs-blue); }
.slide .fs-titled-panel.is-teal   { --tp-accent: var(--fs-teal); }
.slide .fs-titled-panel.is-violet { --tp-accent: var(--fs-violet); }
.slide .fs-titled-panel.is-purple { --tp-accent: var(--fs-purple); }
.slide .fs-titled-panel.is-orange { --tp-accent: var(--fs-orange); }

.slide[data-layout="content-2col"] .col-visual {
  border: 1px solid var(--fs-hairline); border-radius: 16px;
  background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0));
  position: relative; overflow: hidden;
}
/* 规范 L3 (2026-06-01) — SINGLE-child visual panel centers its content vertically.
   Root cause (pg29 feishu-ai-scene-tools 实战): a panel container (.col-visual /
   lifted *-pane) holding ONE shorter-than-column block (product shot / single
   card / mock) left it top-stacked — the panel had no internal vertical
   centering, only .stage/.grid did. This is the panel-level twin of the
   content-3up card-centering fix.
   Guards (do NOT break existing defenses):
   · BF12 multi-card columns → excluded via :not(:has(card+card)) so equal-height
     fill still wins;
   · BF2 self-decorated children (.data-panel etc.) → the BF2 frame-strip rule
     still applies; centering a single self-decorated panel is harmless;
   · BF7 hero image (inline min-height directive) → that rule sets its own
     align; an inline-min-height child fills the column so justify-content has
     no slack to act on — no conflict.
   Single-child only: a column with 2+ stacked cards is handled by BF12 above. */
.slide[data-layout="content-2col"] .col-visual:not(:has(> .canonical-card + .canonical-card)):not(:has(> .news-card + .news-card)):not(:has(> .data-panel + .data-panel)) {
  display: flex; flex-direction: column; justify-content: center;
}

/* ============================================================================
   LAYOUT 05 — Quote / 金句
   ============================================================================ */
.slide[data-layout="quote"] {
  background: #000;
}
.slide[data-layout="quote"]::before {
  content: ""; position: absolute; inset: 0; background: var(--fs-grad-glow-blue);
}
.slide[data-layout="quote"] .stack {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; gap: 56px;
  align-items: center; justify-content: center;
  padding: 0 200px; text-align: center;
}
.slide[data-layout="quote"] blockquote {
  font: 600 92px/1.2 var(--fs-font-cjk); color: #fff; max-width: 1500px;
}
.slide[data-layout="quote"] .attrib {
  font: 500 30px/1.4 var(--fs-font-cjk); color: var(--fs-text-48);
  letter-spacing: 0.04em;
}

/* ============================================================================
   LAYOUT 06 — Stats (4-up KPI)
   ============================================================================ */
.slide[data-layout="stats"] { background: var(--fs-bg-0); --fs-accent: var(--fs-teal); }
.slide[data-layout="stats"] .header {
  position: absolute; top: 96px; left: 96px; right: 96px;
  padding-bottom: 32px; border-bottom: 1px solid var(--fs-hairline);
  display: grid; grid-template-columns: 1fr auto; align-items: end;
}
.slide[data-layout="stats"] .grid {
  position: absolute; top: 380px; left: 96px; right: 96px;
  display: grid; grid-template-columns: repeat(4, 1fr);
}
.slide[data-layout="stats"] .col {
  padding: 16px 36px; display: flex; flex-direction: column; gap: 22px;
  border-left: 1px solid var(--fs-text-16);
}
.slide[data-layout="stats"] .col:first-child { border-left: 0; padding-left: 0; }
/* 规范: stats trend tags must be readable at slide projection distance.
   ≥ 20 px CJK font, no Latin-uppercase forcing on CJK glyphs (which falls back
   to system font and renders smaller).
   2026-05-20 · bumped 24 → 28 (sub tier). At 24, trend visually disappeared
   next to the 132px hero number per BF13 hero-context label floor — needs to
   sit closer in weight to the .label below it. 28 reads as deliberate label
   without competing with the 132 number. */
.slide[data-layout="stats"] .col .trend {
  display: inline-flex; align-items: center; gap: 8px;
  font: 600 var(--fs-sub)/1 var(--fs-font-cjk); letter-spacing: 0.04em;
  text-transform: none; color: var(--fs-accent);
}
.slide[data-layout="stats"] .col .num {
  font: 700 132px/1 var(--fs-font-latin); letter-spacing: -0.03em;
  color: #fff;
}
.slide[data-layout="stats"] .col .num .unit {
  font-size: var(--fs-title); color: var(--fs-text-72); margin-left: 8px; font-weight: 600;
}
.slide[data-layout="stats"] .col .label {
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk); color: var(--fs-text-72);
}
/* 2026-05-20 · font family switched latin → cjk on .source / .footnote.
   When source text mixes Latin and CJK ("Source · 内部传输实测 2024 Q4"),
   var(--fs-font-latin) falls back to system font for the CJK glyphs and
   renders smaller / mismatched. CJK-first font handles both scripts more
   uniformly at 16px. */
.slide[data-layout="stats"] .col .source {
  font: 500 var(--fs-foot)/1.4 var(--fs-font-cjk); color: var(--fs-text-40);
  letter-spacing: 0.06em;
}
.slide[data-layout="stats"] .footnote {
  position: absolute; left: 96px; right: 96px; bottom: 110px;
  font: 500 var(--fs-foot)/1.4 var(--fs-font-cjk); color: var(--fs-text-40);
  letter-spacing: 0.04em;
}

/* ============================================================================
   LAYOUT 07 — Image-text (full-bleed image, type over)
   ============================================================================ */
.slide[data-layout="image-text"] {
  background-color: #000; background-size: cover; background-position: center;
}
.slide[data-layout="image-text"]::before {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(0,0,0,0.45) 0%, rgba(0,0,0,0.0) 30%, rgba(0,0,0,0.7) 100%),
              linear-gradient(90deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 60%);
}
.slide[data-layout="image-text"] .stage {
  position: absolute; left: 96px; right: 96px; bottom: 130px; max-width: 1100px;
}
.slide[data-layout="image-text"] h2.title {
  /* allow:typescale — image-text 是 hero-style 全幅图布局(类 cover/section),
     2026-05-20 加入 Hero exception 名单,从 4-tier 的 48 升到 master-spec 的 88
     (与 section H2 同级)。在 cinematic 全屏画面上 48 px ZH 标题视觉空虚。*/
  margin-top: 24px;
  font: 700 88px/1.1 var(--fs-font-cjk); letter-spacing: -0.005em;
  padding-block: 10px;
}
.slide[data-layout="image-text"] .lede { margin-top: 28px; color: var(--fs-text-65); }

/* ============================================================================
   LAYOUT 08 — Table
   ============================================================================ */
.slide[data-layout="table"] { background: var(--fs-bg-0); }
.slide[data-layout="table"] .header {
  position: absolute; top: 96px; left: 96px; right: 96px;
  padding-bottom: 32px; border-bottom: 1px solid var(--fs-hairline);
  display: grid; grid-template-columns: 1fr auto; align-items: end;
}
.slide[data-layout="table"] .table-wrap {
  position: absolute; top: 360px; left: 96px; right: 96px; bottom: 120px;
  display: flex; flex-direction: column;
}
.slide[data-layout="table"] table { width: 100%; border-collapse: collapse; }
/* 规范: table header row uses CJK font at body weight, NOT Latin-uppercase
   tracked. The previous 16 px / Latin / uppercase pattern was unreadable for
   CJK column labels because the font fallback rendered them at a smaller
   apparent size. Headers must be ≥ 24 px / 600 / CJK / white. */
.slide[data-layout="table"] thead th {
  text-align: left; padding: 22px 24px;
  font: 600 var(--fs-body)/1.3 var(--fs-font-cjk); letter-spacing: 0.02em;
  text-transform: none; color: #fff;
  border-bottom: 1px solid var(--fs-divider);
}
.slide[data-layout="table"] tbody td {
  padding: 26px 24px; vertical-align: top;
  font: 500 var(--fs-body)/1.45 var(--fs-font-cjk); color: var(--fs-text-72);
  border-bottom: 1px solid var(--fs-hairline);
}
.slide[data-layout="table"] tbody td:first-child {
  color: #fff; font-weight: 600;
}
.slide[data-layout="table"] tbody tr:last-child td { border-bottom: 0; }
/* Optional caption / 说明 paragraph under the table. Same convention as
   stats layout — pin above the page footer. Without this rule, .footnote
   is the only non-absolute child in the slide and gets dumped at the top
   of the canvas (above the header). :has() so tables WITHOUT a footnote
   keep using the full body zone — no penalty. */
.slide[data-layout="table"]:has(.footnote) .table-wrap { bottom: 180px; }
.slide[data-layout="table"] .footnote {
  position: absolute; left: 96px; right: 96px; bottom: 96px;
  font: 500 var(--fs-foot)/1.5 var(--fs-font-cjk); color: var(--fs-text-40);
  letter-spacing: 0.02em;
}

/* ============================================================================
   LAYOUT 09 — Timeline (horizontal milestones)
   ============================================================================ */
.slide[data-layout="timeline"] { background: var(--fs-bg-0); }
.slide[data-layout="timeline"] .header {
  position: absolute; top: 96px; left: 96px; right: 96px;
  padding-bottom: 32px; border-bottom: 1px solid var(--fs-hairline);
  display: grid; grid-template-columns: 1fr auto; align-items: end;
}
.slide[data-layout="timeline"] .axis {
  position: absolute; top: 540px; left: 96px; right: 96px; height: 2px;
  background: linear-gradient(90deg, var(--fs-accent), var(--fs-text-16));
  border-radius: 2px;
}
.slide[data-layout="timeline"] .nodes {
  position: absolute; top: 400px; left: 96px; right: 96px; bottom: 160px;
  display: grid; grid-template-columns: repeat(var(--cols, 4), 1fr); gap: 24px;
}
.slide[data-layout="timeline"] .node {
  display: flex; flex-direction: column; gap: 18px; position: relative;
  padding-top: 168px;
}
.slide[data-layout="timeline"] .node::before {
  content: ""; position: absolute; left: 0; top: 132px;
  width: 18px; height: 18px; border-radius: 999px;
  background: var(--fs-accent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--fs-accent) 18%, transparent);
}
.slide[data-layout="timeline"] .node .when {
  font: 700 var(--fs-sub)/1 var(--fs-font-latin); color: #fff; letter-spacing: 0.04em;
}
.slide[data-layout="timeline"] .node .what {
  font: 600 var(--fs-sub)/1.3 var(--fs-font-cjk); color: #fff;
}
.slide[data-layout="timeline"] .node .desc {
  /* 2026-05-16: 18 → 22 — timeline node body content per SKILL.md
     typography floor. Was reading as illegible fine print on projector. */
  font: 500 var(--fs-body)/1.5 var(--fs-font-cjk); color: var(--fs-text-65);
  text-wrap: balance;   /* 2026-05-26 · even the 2-line wrap (R-VIS-ORPHAN) */
}

/* ============================================================================
   LAYOUT 10 — Process (numbered horizontal flow)
   ============================================================================ */
.slide[data-layout="process"] { background: var(--fs-bg-0); }
.slide[data-layout="process"] .header {
  position: absolute; top: 96px; left: 96px; right: 96px;
  padding-bottom: 32px; border-bottom: 1px solid var(--fs-hairline);
  display: grid; grid-template-columns: 1fr auto; align-items: end;
}
.slide[data-layout="process"] .flow {
  position: absolute; top: 420px; left: 96px; right: 96px; bottom: 160px;
  display: grid; grid-template-columns: repeat(var(--cols, 4), 1fr) ; gap: 28px;
  align-items: stretch;
}
.slide[data-layout="process"] .step {
  border: 1px solid var(--fs-hairline);
  background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0));
  border-radius: 16px; padding: 32px;
  display: flex; flex-direction: column; gap: 20px; position: relative;
}
.slide[data-layout="process"] .step .stnum {
  font: 700 var(--fs-title)/1 var(--fs-font-latin); color: var(--fs-accent); opacity: 0.6;
}
.slide[data-layout="process"] .step h3 {
  /* 2026-05-17: 28 → 48 — when stnum (48 hero) is the visual anchor of
     each card, the step title must hold its own at Title tier. */
  font: 700 var(--fs-title)/1.2 var(--fs-font-cjk); color: #fff;
}
.slide[data-layout="process"] .step p {
  /* 2026-05-16: 18 → 22 — process-step body per SKILL.md typography floor. */
  font: 500 var(--fs-body)/1.55 var(--fs-font-cjk); color: var(--fs-text-65);
}
/* (#84) Old 16px chevron arrow REMOVED — it merged with the canonical
   `.step::after` clip-path arrow (defined further below) on every non-last
   step, producing a garbled double glyph. That clip-path rule is now the
   single source of the process step arrow. */

/* 规范 L4: process layout's output panel (right-side ~400 px wide) attribute
   list MUST be a single column — 2-col grid in a 400 px container yields
   ~180 px cells which truncate body-floor (22 px) text like
   "Communication style". Vertical stack fits the panel's tall proportion. */
.slide[data-layout="process"] .output {
  display: flex; flex-direction: column; gap: 16px;
}
.slide[data-layout="process"] .output .attrs {
  display: grid;
  grid-template-columns: 1fr;            /* single column — never 1fr 1fr */
  gap: 14px 0;
}
.slide[data-layout="process"] .output .attrs > * {
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk);
  color: var(--fs-text-72);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ============================================================================
   LAYOUT 11 — Agenda (TOC) — vertical pill stack (v2, 2026-05-06)
   - Background: section gradient (cool blue glow ribbons L+R)
   - Pills centered on canvas; no top header by default (.header is hidden)
   - Each pill: italic blue numeral + white title text
   - Matches the 飞书 master 议程页 spec (single-frame agenda, content centered)
   - Replaces v1 (TOC 2-col grid). User feedback 2026-05-06: "目录这个修改,
     更新到标准技能上" — vertical pill design promoted to default.
   ============================================================================ */
.slide[data-layout="agenda"] { background: var(--fs-grad-section); }

/* Hide header by default — agenda is a single-focus page, the 3 pills ARE
   the content. If a deck explicitly NEEDS a header (e.g. a recap variant),
   wrap it as an opt-in: data-variant="with-header". */
.slide[data-layout="agenda"]:not([data-variant="with-header"]) .header { display: none; }
.slide[data-layout="agenda"][data-variant="with-header"] .header {
  position: absolute; top: 96px; left: 96px; right: 96px;
}
.slide[data-layout="agenda"][data-variant="with-header"] .header h2 {
  font: 700 var(--fs-title)/1.05 var(--fs-font-cjk); letter-spacing: -0.005em;
}

/* TOC = vertical pill stack, centered on canvas
   2026-05-20 · 自适应 padding/gap by pill count (BF-AGENDA-DENSITY).
   default tuned for N=3-4; N=5 / N≥6 progressively tighten so 6 pills
   no longer overflow the 840px usable area and break "visually centered". */
.slide[data-layout="agenda"] .toc {
  position: absolute; inset: 0;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 56px;
  padding: 120px 96px;
}
.slide[data-layout="agenda"][data-variant="with-header"] .toc { padding-top: 280px; }

/* N=5 · 紧凑档 */
.slide[data-layout="agenda"] .toc:has(.item:nth-child(5)) {
  gap: 36px; padding: 80px 96px;
}
.slide[data-layout="agenda"] .toc:has(.item:nth-child(5)) .item { padding: 28px 96px; }

/* N≥6 · 极致紧凑 */
.slide[data-layout="agenda"] .toc:has(.item:nth-child(6)) {
  gap: 24px; padding: 60px 96px;
}
.slide[data-layout="agenda"] .toc:has(.item:nth-child(6)) .item { padding: 22px 96px; }

/* Each .item = a pill card */
.slide[data-layout="agenda"] .toc .item {
  display: grid; grid-template-columns: 140px 1fr;
  align-items: center; gap: 40px;
  width: 1180px; max-width: 100%;
  padding: 44px 96px;
  background: rgba(8,14,40,0.45);
  border: 1.5px solid rgba(60,127,255,0.55);
  border-radius: 16px;
  box-shadow: 0 0 0 1px rgba(60,127,255,0.18) inset;
  transition: border-color 200ms;
  /* Override v1 grid-row defaults (paddings / borders) */
  border-bottom: 1.5px solid rgba(60,127,255,0.55);
}
.slide[data-layout="agenda"] .toc .item:hover { border-color: rgba(60,127,255,0.85); }
.slide[data-layout="agenda"] .toc .item.is-dim { opacity: 0.42; }
.slide[data-layout="agenda"] .toc .item.is-active { border-color: var(--fs-teal); }
.slide[data-layout="agenda"] .toc .item.is-active .n { color: var(--fs-teal); }

/* Numeral — italic, large, brand blue */
.slide[data-layout="agenda"] .toc .item .n {
  font: italic 700 var(--fs-title)/1 var(--fs-font-latin);
  color: var(--fs-blue);
  letter-spacing: 0.04em;
}

/* Title text — white, bold, generous size */
.slide[data-layout="agenda"] .toc .item .title-zh {
  /* allow:white-opacity — agenda pill text master-spec hierarchy at 0.92
     keeps the active item's 1.0 readable as "this is the section we're in". */
  font: 600 var(--fs-sub)/1.2 var(--fs-font-cjk);
  color: rgba(255,255,255,0.92);
  letter-spacing: 0.005em;
}
/* Bilingual EN suffix retained for opt-in bilingual decks */
.slide[data-layout="agenda"] .toc .item .title-en {
  display: block; margin-top: 6px;
  font: 500 var(--fs-foot)/1.3 var(--fs-font-latin);
  color: var(--fs-text-48);
}
/* If the agenda item DOM is just <div class="n">.. </div><div class="title-zh">..</div>
   (no inner wrapper), it should still render correctly because grid handles the 2-col
   layout. The recipe in SKILL.md uses <div><div class="title-zh">..</div></div> wrapper
   form for compat with bilingual; both forms work with these rules. */

/* ============================================================================
   LAYOUT 12 — Big stat (single hero number)
   ============================================================================ */
.slide[data-layout="big-stat"] { background: var(--fs-grad-hero); }
.slide[data-layout="big-stat"] .stage {
  position: absolute; inset: 0; padding: 0 96px;
  display: grid; grid-template-columns: 1fr 1fr; align-items: center; gap: 80px;
}
.slide[data-layout="big-stat"] .num {
  font: 700 312px/0.95 var(--fs-font-latin);
  background: var(--fs-grad-keyline);
  -webkit-background-clip: text; background-clip: text; color: transparent;
  letter-spacing: -0.04em;
}
.slide[data-layout="big-stat"] .num .unit {
  font-size: 96px; -webkit-text-fill-color: #fff; color: #fff;
  background: none; opacity: 0.85; margin-left: 8px;
}
.slide[data-layout="big-stat"] .copy h2 {
  font: 700 64px/1.15 var(--fs-font-cjk); margin-bottom: 24px;
}
.slide[data-layout="big-stat"] .copy p {
  font: 500 var(--fs-body)/1.55 var(--fs-font-cjk); color: var(--fs-text-65); max-width: 600px;
}
/* 2026-05-20 · big-stat eyebrow override. Global .slide .eyebrow is 16px
   Latin (designed for footer chrome). In big-stat next to a 312px hero
   number, 16 reads as invisible. Bump to 28 (sub tier) so eyebrow earns
   its labeling role in the hero composition. */
.slide[data-layout="big-stat"] .copy .eyebrow {
  font-size: var(--fs-sub);
  margin-bottom: 18px;
  letter-spacing: 0.08em;
}

/* BF11 — Hero zone content centered, not left-flush (2026-05-16).
   When big-stat is composed as a 2-col stage (hero number left + focus
   card right), the LEFT column's content can be visually centered within
   its half via the `.hero.is-centered` modifier — useful when the right
   column carries a card that "balances" the visual weight from the left.
   OPT-IN to avoid retroactively shifting legacy big-stat decks that
   relied on the default left-flush layout. New decks should write:
       <div class="hero is-centered"><div class="num">56%</div>...</div>
*/
.slide[data-layout="big-stat"] .hero.is-centered {
  display: flex; flex-direction: column;
  align-items: center; text-align: center;
  gap: 22px;
}
.slide[data-layout="big-stat"] .hero.is-centered .gap-row,
.slide[data-layout="big-stat"] .hero.is-centered .stat-row {
  justify-content: center;
}

/* BF10 — Mixed-size row alignment (2026-05-16).
   When a row contains text elements at significantly different font-sizes
   (ratio > 1.5×, e.g. a 48px numeral followed by 24px body text), the
   default `align-items: baseline` LOOKS misaligned because the typographic
   baselines diverge by 50%+ of the smaller font's x-height. Center
   alignment puts the smaller element at the visual middle of the bigger
   element's line-box — reads as "on the same line".
   Use `.mixed-row` as a grid/flex container utility class for this pattern.
   Example: `<div class="mixed-row"><span class="num">01</span>
            <p class="text">内容...</p></div>` */
.slide .mixed-row {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 22px;
  align-items: center;       /* explicit — NOT baseline */
}

/* BF12 — Multi-card column equal-height (2026-05-16).
   When a `.col-text` or `.col-visual` contains MULTIPLE cards (typically
   2-3 stacked vertically), the cards should fill the column equally.
   Scoped via `:has()` so that SINGLE-child visual columns (image / UI
   mock / hero illustration) keep their legacy "styled container" behavior
   and don't get force-stacked into a flex column. */
.slide .col-text:has(> .canonical-card + .canonical-card),
.slide .col-text:has(> .news-card + .news-card),
.slide .col-text:has(> .canonical-card + .news-card),
.slide .col-text:has(> .news-card + .canonical-card),
.slide .col-visual:has(> .canonical-card + .canonical-card),
.slide .col-visual:has(> .news-card + .news-card),
.slide .col-visual:has(> .canonical-card + .news-card),
.slide .col-visual:has(> .news-card + .canonical-card),
.slide .col-visual:has(> .data-panel + .data-panel) {
  display: flex; flex-direction: column; gap: 24px;
}
.slide .col-text:has(> .canonical-card + .canonical-card) > .canonical-card,
.slide .col-text:has(> .news-card + .news-card) > .news-card,
.slide .col-visual:has(> .canonical-card + .canonical-card) > .canonical-card,
.slide .col-visual:has(> .news-card + .news-card) > .news-card,
.slide .col-visual:has(> .data-panel + .data-panel) > .data-panel {
  flex: 1;
}

/* ============================================================================
   LAYOUT 13 — Closing / End (封底带 slogan) — master slideLayout8
   - Background: same flower image as cover
   - Logo top-LEFT at (120, 121) — handled by .wordmark cover/end override
   - Slogan PNG ("先进团队 先用飞书") at (102, 348) size 561×345
   - That's it — no title, no CTA, no contact grid.
     The brand statement does the entire job.
   ============================================================================ */
.slide[data-layout="end"] {
  background: #000 var(--fs-asset-cover-bg) center/cover no-repeat;
}
.slide[data-layout="end"] .wordmark {
  top: 121px;            /* master pic16: y=80.7 native */
}
.slide[data-layout="end"] .slogan {
  position: absolute;
  top: 348px; left: 102px;             /* master pic5: x=67.9 y=231.7 native × 1.5 */
  width: 561px; height: 345px;          /* master 374.1 × 229.9 native × 1.5 */
  background: var(--fs-asset-slogan) left center/contain no-repeat;
}
/* Optional contact line at bottom — kept off the master but provided as opt-in */
.slide[data-layout="end"] .contact {
  /* allow:white-opacity — small Latin contact line at bottom-left of end
     page; 0.55 is the "chrome / metadata" tone, sized 16px (Foot tier). */
  position: absolute; left: 124px; bottom: 80px;
  font: 500 var(--fs-foot)/1.5 var(--fs-font-latin);
  color: rgba(255,255,255,0.55); letter-spacing: 0.06em;
}
/* (end .footer hide rule retired — global .slide .footer { display: none } now handles it.) */

/* ============================================================================
   Helpers — accent overrides for siblings (charts, columns, pills)
   ============================================================================ */
/* 规范: cyan is INLINE-WORD-HIGHLIGHT only — never a slide accent.
   Removed cyan from this map intentionally. Use .accent-text or .hl
   for cyan inline highlight. */
.slide [data-accent="blue"]   { --fs-accent: var(--fs-blue); }
.slide [data-accent="teal"]   { --fs-accent: var(--fs-teal); }
.slide [data-accent="purple"] { --fs-accent: var(--fs-purple); }
.slide [data-accent="violet"] { --fs-accent: var(--fs-violet); }
.slide [data-accent="orange"] { --fs-accent: var(--fs-orange); }

/* slide-level accent override (applies to chrome too) */
.slide[data-accent="blue"]   { --fs-accent: var(--fs-blue); }
.slide[data-accent="teal"]   { --fs-accent: var(--fs-teal); }
.slide[data-accent="purple"] { --fs-accent: var(--fs-purple); }
.slide[data-accent="violet"] { --fs-accent: var(--fs-violet); }
.slide[data-accent="orange"] { --fs-accent: var(--fs-orange); }

/* ============================================================================
   MASTER PARITY OVERRIDES (loaded last to win cascade)
   The .thmx master lays out content pages with a SMALL top-left title and a
   approved 豆包工作｜飞书 lockup top-right. No eyebrow underline, no bottom rule under
   the header. Body content fills the rest of the slide. These rules align our
   12 content layouts to that pattern.
   ============================================================================ */

/* Subtle ambient gradient for content/agenda/table/etc (master uses image1.jpg) */
.slide[data-layout="agenda"],
.slide[data-layout="content-3up"],
.slide[data-layout="content-2col"],
.slide[data-layout="content-3up"],
.slide[data-layout="content-2col"],
.slide[data-layout="stats"],
.slide[data-layout="table"],
.slide[data-layout="timeline"],
.slide[data-layout="process"],
.slide[data-layout="big-stat"],
.slide[data-layout="quote"],
.slide[data-layout="content-before-after"],
.slide[data-layout="matrix-2x2"],
.slide[data-layout="issue-tree"],
.slide[data-layout="flow-swim"],
.slide[data-layout="waterfall"],
.slide[data-layout="arch-stack"],
.slide[data-layout="logo-wall"],
.slide[data-layout="chart"],
.slide[data-layout="canvas"],
/* F-307 (2026-06-13) · raw + iframe-embed must paint content-bg on the SLIDE too, not
   just the frame (line ~222 whitelist). The frame-only fix left a transparent
   .slide showing the frame's non-16:9 `cover` crop — the image's dark top edge
   bleeds above the design's content-bg framing → "标题上方的黑条" on 16:10 /
   16:9-letterboxed viewports. canvas was already here; raw/iframe-embed were
   not, so lifted raw pages whose recovered CSS lacked a slide bg rendered the
   bar. This is a DEFAULT — a raw slide emulating cover/end/hero still overrides
   it via its own per-slide background in custom_css (equal specificity, later
   in source order = wins). Keeps slide ↔ frame backgrounds consistent. */
.slide[data-layout="raw"],
.slide[data-layout="iframe-embed"] {
  background: #000 var(--fs-asset-content-bg) center/cover no-repeat;
}

/* Unified small-title header for all content layouts.
   规范: page header title stays at master coordinates (73, 61); the colored
   top-right lockup uses y=58 so its lettering shares the same visible top.
   Title is 52 px / 600 / SINGLE LINE.
   Optional eyebrow goes BELOW the title as a secondary tag.
   <br> inside the title is forbidden (CSS strips it as a defense). */
.slide[data-layout="agenda"] .header,
.slide[data-layout="content-3up"] .header,
.slide[data-layout="content-2col"] .header,
.slide[data-layout="stats"] .header,
.slide[data-layout="table"] .header,
.slide[data-layout="timeline"] .header,
.slide[data-layout="process"] .header,
.slide[data-layout="big-stat"] .header,
/* 2026-05-28 · `raw` joins the unified .header positioning so native-lift
   slides (deck.json layout: "raw" with .header inside their data.html)
   automatically get the master title coord (top:61, left:73, right:420)
   instead of falling to top:0 static flow. Lifts that want a custom header
   position can override via the slide's inline <style>. */
.slide[data-layout="raw"] .header,
/* 2026-06-12 · `canvas` joins too — imported PPTX pages can opt into the
   framework chrome title (data.title/subtitle) and land on master coords. */
.slide[data-layout="canvas"] .header {
  position: absolute; top: 61px; left: 73px; right: 441px;  /* clears 317px official lockup */
  padding-bottom: 0; border-bottom: 0;
  display: block;
}
.slide .header > .pageno { display: none; }
.slide .header > div:first-child {
  display: flex; flex-direction: column-reverse; align-items: flex-start;
  /* DOM order: eyebrow, title — visual order: title above, eyebrow below */
}
.slide .header .eyebrow {
  display: block; margin: 14px 0 0 0;
  font: 600 var(--fs-foot)/1 var(--fs-font-latin); letter-spacing: 0.18em;
  color: var(--fs-accent);
}
.slide .header .title-zh,
.slide .header h2,
.slide .header h2.title-zh {
  display: block; margin: 0; padding: 0;
  text-wrap: balance;
  /* 2026-05-15: dropped `!important` from margin — it silently broke every
     author-written `.title-zh { margin-bottom: Npx }` override, forcing
     a Devtools dig to discover that the rule wasn't honored. Now author
     CSS in per-page <style> blocks can adjust title margin normally;
     if you need to push a subtitle further from the title, prefer
     `margin-top` on the subtitle (see `.page-sub` default below). */
  /* 2026-06-11: line-height 1.1 → 1.2 — at 1.1 the line box (52.8px @48) was
     shorter than a Latin glyph's full height, so the `overflow:hidden` below
     (kept for the ellipsis-on-too-long-title feature) CLIPPED the descenders of
     g/p/y/j on English titles. 1.2 gives the box room to contain descenders so
     overflow no longer shaves them. CJK titles have no descenders → no visual
     change beyond a ~5px taller title box. */
  font: 600 var(--fs-title)/1.2 var(--fs-font-cjk);
  letter-spacing: -0.005em; color: #fff;
  white-space: nowrap;                      /* 规范: single line — no wrap */
  max-width: 100%; overflow: hidden; text-overflow: ellipsis;
}

/* ---- Hero-style content-page subtitle (single line under the H2) ----
   Authoring pattern: "Title + one line of context underneath."
   Use class `.page-sub` on a <p> directly after the H2 inside .header.

       <div class="header">
         <h2 class="title-zh">飞书与企微,已经不是同一类产品</h2>
         <p class="page-sub">下一代协同 vs 上一代 IM</p>
       </div>

   Defaults (master-aligned 2026-05-15, elevated from CTG-run feedback):
     · font-size: var(--fs-sub) (rung 4 on the type ladder)
     · color: #fff (no opacity tricks — body floor applies)
     · margin-top: 36px (= 0.82× the 44/52 px title — wide enough that
       the subtitle reads as a separate line, narrow enough to stay
       grouped with the H2)

   This is opt-in: a slide with no `.page-sub` renders exactly as before.
*/
.slide .header .page-sub {
  display: block;
  margin: 36px 0 0 0;
  padding: 0;
  font: 500 var(--fs-sub)/1.45 var(--fs-font-cjk);
  letter-spacing: 0;
  color: #fff;
  max-width: 100%;
}
/* Defense: strip stray <br> from header titles (规范: titles must be one line) */
.slide .header .title-zh br,
.slide .header h2 br { display: none; }

/* 2026-05-19 · text-wrap baseline (skill-level)
   Prevent ugly orphan-character last-line wraps on common card titles /
   names / sub-labels. `text-wrap: balance` re-distributes the line break
   so the lines have similar character counts. CSS-only, zero JS cost.
   Author can override per-rule. Applies to titles + card-name + role-name. */
.slide .card .ctitle, .slide .card-name, .slide .role-name,
.slide .ns-card h4, .slide .scene-card .sc-name, .slide .feat-label,
.slide .bot-name, .slide .arch-label, .slide .dir-name {
  text-wrap: balance;
  word-break: break-word;
}

/* 规范 (per reference deck): content-page HEADER is a single-line title +
   top-right logo, NOTHING ELSE. The eyebrow under .header is suppressed on
   EVERY layout — multi-layer header (eyebrow + title + sub-line) clutters
   the page chrome and competes with slide content for attention.
   Eyebrows are still usable as a class elsewhere (cards, section dividers,
   stats columns) — just not as part of the page-header stack. */
.slide .header .eyebrow { display: none; }

/* Reposition each body-content area to use the freed vertical space.
   These rules supersede the per-layout `.grid` / `.flow` / `.nodes` top values.

   规范 L2: every body-content container must vertically center its row when
   content is shorter than the available canvas height. Without this, cards
   stack at the top with empty bottom — the most-reported "looks unfinished"
   bug. align-content: center applies only when row natural height < container.
   When content fills 80%+ naturally, align-content has no visual effect, so
   this is safe to apply universally. */
.slide[data-layout="content-3up"]  .grid       { top: 200px; bottom: 110px; align-content: center; }
.slide[data-layout="content-2col"] .grid       { top: 200px; bottom: 110px; align-content: center; }
.slide[data-layout="stats"]        .grid       { top: 220px; bottom: 200px; align-content: center; }
.slide[data-layout="stats"]        .footnote   { bottom: 96px; }
.slide[data-layout="table"]        .table-wrap { top: 200px; bottom: 110px; }
/* Timeline opts OUT of L2 vertical centering. .axis is absolutely positioned
   relative to .slide (not .nodes), so it can't follow when .nodes' rows center
   vertically — dots drift down by (zone_h - row_h)/2 and unalign from the
   horizontal line. Keep nodes top-pinned at 400 so dot center (400 + node
   padding-top 132 + dot radius 9) = 541 = .axis center (540 + 1). */
.slide[data-layout="timeline"]     .nodes      { top: 400px; bottom: 160px; }
.slide[data-layout="timeline"]     .axis       { top: 540px; }
.slide[data-layout="process"]      .flow       { top: 240px; bottom: 130px; align-content: center; }

/* `.stage` is the canonical name for a body-content container in the abstract
   规范 (some layouts use .grid / .flow / .nodes for historical reasons —
   they're aliases). Generic .stage rule provides the L2 default for any
   custom layout an author writes. */
.slide .stage { align-content: center; justify-content: center; }

/* ---- .stage wrapper pattern (v1.3.2) ----
   When a slide places helpers (.pullquote / .cta-box / .kpi-strip / .lede)
   as siblings of the body container, those helpers WILL fall to top-of-canvas
   via static flow because .grid / .flow / .nodes / .toc / .table-wrap are
   absolutely positioned by their layout rules.

   The fix: wrap body container + helpers in <div class="stage">. The .stage
   takes the absolute body-zone position; inner containers override their
   default absolute positioning to flow naturally inside the flex column.

   Markup pattern:
     <div class="slide" data-layout="content-2col">
       <div class="header">…</div>
       <div class="stage">
         <p class="lede">…</p>            ← optional intro
         <div class="grid">…</div>         ← body content
         <p class="pullquote">…</p>        ← optional emphasis
         <div class="cta-box">…</div>      ← optional CTA
       </div>
     </div>     <!-- .footer / .source-footer both retired 2026-05; pager UI handles page no., data citations go inline. -->

   No backward-compat impact: pre-1.3.2 decks (no .stage wrapper) keep
   absolute body positioning unchanged via the rules at lines 936-943.
*/
.slide[data-layout="content-2col"] > .stage,
.slide[data-layout="content-3up"]  > .stage,
.slide[data-layout="process"]      > .stage,
.slide[data-layout="timeline"]     > .stage,
.slide[data-layout="table"]        > .stage,
.slide[data-layout="agenda"]       > .stage,
.slide[data-layout="stats"]        > .stage,
/* 2026-05-28 · `raw` joins the unified .stage positioning so native-lift
   slides automatically get the master body-zone box (top:200, bottom:200,
   left:96, right:96, flex column, justify-content:center). Without this,
   lifted .stage falls to static flow → content goes edge-to-edge ("贴边")
   because the source slide's inline .stage rule typically only overrides
   top/bottom/gap and relies on the framework cascade for position/left/right.
   Lifts that want a full-bleed body (e.g. a chart filling the canvas) can
   override via the slide's inline <style> with `padding: 0` on .slide,
   or by redeclaring .stage with their own values + !important. */
.slide[data-layout="raw"]          > .stage {
  /* 2026-05-31 · OWNER DECISION (supersedes the 2026-05-15 symmetric default):
     anchor the body zone to the [title-bottom → screen-bottom] band so content
     centers BELOW the title (band mid ≈ 596), per the canonical rule "content
     centers in [titleBottom→bottom]; the title never participates". The old
     symmetric 200/200 (center y=540) read as "内容偏上" because it folded the
     title band into the centering. top:168 clears a two-line title (title
     bottom ≈ 151); bottom:56 keeps a small floor margin → band mid
     (168 + 1024)/2 ≈ 596. This REVERSES the 2026-05-15 CTG "reads slightly low"
     change — owner re-decided 2026-05-31. Dense slides that genuinely want the
     legacy canvas-center (y=540) opt back via `.stage--center540` (see below).

     F-301 (2026-06-11) · subtitle-aware band anchor. The fixed `top:168px` was
     MAIN-TITLE-ONLY math — a `.page-sub` subtitle pushes the real title-band
     bottom to ~196 (1 line) / ~236 (2 lines), so 168 started the stage ABOVE
     the subtitle and the centering mid read ~40–60px too high. The runtime now
     measures each rendered `.header` bbox bottom (which encloses .page-sub)
     into the slide's `--fs-band-bottom` (design px — see setBandAnchor in
     feishu-deck.js); stage top = band bottom + 56 keeps the flex center
     EXACTLY on the canonical band mid (headerBottom+1080)/2 — the same anchor
     canvas-center uses — because bottom is also 56. The 112px fallback
     reproduces the legacy 168 for header-less slides / no-JS rendering, so
     nothing moves unless a real header was measured. */
  position: absolute;
  top: calc(var(--fs-band-bottom, 112px) + 56px);
  left: 96px; right: 96px; bottom: 56px;
  display: flex; flex-direction: column;
  justify-content: center;
  gap: 24px;
  z-index: 1;
}

/* Opt-in: dense content that needs the legacy stage height (220..970,
   reaches further toward the footer area). Use sparingly — symmetric is
   the visual default; this is for backwards compatibility with decks
   tuned to 770 px stage. */
.slide > .stage.stage--tall {
  top: 220px; bottom: 110px;
}

/* 2026-05-31 · opt BACK to the legacy symmetric canvas-center (center y=540).
   For dense slides where centering-below-title reads as "too low" (the
   2026-05-15 CTG preference). The default is now title-anchored (≈596). */
.slide > .stage.stage--center540 {
  top: 200px; bottom: 200px;
}

/* 2026-05-29 · CARD-FILL utility class.
   When a flex-column card has `min-height` set for visual presence (so it's
   taller than its content's natural height), the default `justify-content:
   flex-start` packs content at the top with empty space at the bottom of
   the card — feels half-filled. `justify-content: space-between` distributes
   children evenly across the card's height, so the card content visually
   fills the frame.

   Use this WHEN the card has min-height larger than its natural content
   height. If content fills the card naturally (content height ≥ min-height),
   this has no effect (space-between needs slack).

   This is NOT the same as BF9's "dead-middle space" anti-pattern:
   BF9 is about ACCIDENTAL stretching (flex:1 / align-items:stretch with sparse
   content). card-fill is about INTENTIONAL min-height for visual sizing —
   the slack is by design, and space-between fills it deliberately.

   Triggered by 2026-05-29 P15: 5-card row with min-height 640px for visual
   presence; content packs at top, ~160px empty at bottom of each card.
   Applying space-between distributes the 5 children (head + system + doing
   + stuck + by) evenly. */
.fs-card-fill {
  justify-content: space-between !important;
}

/* 2026-05-29 · ANCHORED-TITLE STAGE pattern.
   When `.stage` contains a TITLE element as a direct child (.ttl-block,
   .ttl-main, .header, h1, h2), the default `justify-content: center` on
   .stage drags the title down to vertical center — wrong: title should
   stay anchored at TOP, body should fill remaining vertical space.

   Triggered by 2026-05-29 P15 lift: source `kangshifu-8-depts` puts title
   INSIDE .stage (with `.header { display: none }` hiding the framework
   default header). My fix initially over-corrected: with grid content-sized,
   stage's `justify-content: center` pulled the title down with the cards,
   leaving empty band at top AND bottom but title not at top.

   Fix: when stage has a title direct child, switch to flex-start (anchor
   title) AND require body container to have `flex: 1 + align-content:
   center` so it fills the remainder + centers its content within. This
   is a framework default — authors don't need to think about it.

   The body container is whatever takes flex: 1 (the author's responsibility
   to set on the body grid / nodes / etc.). Cards inside should set their
   own min-height for visual presence (sweet-spot ~480-560px). */
.slide > .stage:has(> .ttl-block),
.slide > .stage:has(> .ttl-main),
.slide > .stage:has(> .header),
.slide > .stage:has(> h1),
.slide > .stage:has(> h2) {
  justify-content: flex-start;
}

/* Inner body containers flow naturally inside .stage — override the
   layout-default absolute positioning. Specificity (0,3,0) > (0,2,0). */
.slide[data-layout="content-2col"] > .stage > .grid,
.slide[data-layout="content-3up"]  > .stage > .grid,
.slide[data-layout="agenda"]       > .stage > .toc,
.slide[data-layout="stats"]        > .stage > .grid,
.slide[data-layout="process"]      > .stage > .flow,
.slide[data-layout="timeline"]     > .stage > .nodes,
.slide[data-layout="table"]        > .stage > .table-wrap {
  position: static;
  top: auto; right: auto; bottom: auto; left: auto;
}

/* 2026-05-31 · 生成即对(配合 R-VIS-CANVAS-CENTER 校验):content-2col/3up 的
   .grid 在 .stage 内 flex:1 撑满 + 行内居中。统一 .stage 锚标题底(top:168/
   bottom:56,带中线≈596)后,grid 若取自然高会被 stage 的 justify-content:center
   在 856 带里"硬居中"→ 满铺页悬在偏下(用户反馈"太靠下")。改为 grid flex:1
   吃满整个 856 带:
     · 稀疏内容 → align-content:center 在带内居中(中线≈596,= R-VIS-CANVAS-CENTER 基准)
     · 满铺内容 → 无 slack → 卡片顶对齐铺满饱满(不沉)
   于是"稀疏居中 / 满铺撑满"成为自动的 CSS 行为,无需阈值判断,render 出来天生对。
   只 scope content-2col/3up(其它版式内层容器未被 .stage 包裹,不受影响)。 */
.slide[data-layout="content-2col"] > .stage > .grid,
.slide[data-layout="content-3up"]  > .stage > .grid {
  flex: 1 1 auto;
  min-height: 0;
  align-content: center;
}
/* content-2col 视觉列(图/iframe)防被 grid 行拉伸变形 */
.slide[data-layout="content-2col"] > .stage > .grid { align-items: center; }

/* 2026-06-05 · BF-GRIDFILL-SIBLING · the `flex:1 撑满` above silently assumes
   the .grid is the SOLE body block in .stage. When the .stage ALSO holds a
   sibling block — a footer anchor-band, a lede line, a disclaimer strip — the
   grown .grid eats EVERY pixel of vertical slack: cards float dead-centre in an
   over-tall grid and the sibling is shoved to the far edge, opening a big dead
   band between them. Worse, the stage's own `justify-content:center` becomes a
   no-op (flex:1 left it zero slack to distribute), so the intended
   "group the blocks + centre the group" never happens. This is the
   "为撑满屏幕把页脚顶到底" anti-pattern — filling the screen is NOT a goal; a
   centred group with balanced top/bottom whitespace is.
   Fix: the fill only makes sense for a lone grid. When the grid has siblings it
   must take intrinsic height so the stage groups + centres all blocks together
   (sparse → centred ≈596; dense → fills naturally — same as a lone grid, minus
   the manufactured slack). Gate the fill to :only-child. Authors who genuinely
   want a middle block to stretch (top+bottom anchored layouts) opt back in with
   the explicit `.fs-fill` class below — consistent with .fs-card-fill /
   .stage--tall. Specificity (0,5,0) > the (0,4,0) base rule, so it wins. */
.slide[data-layout="content-2col"] > .stage > .grid:not(:only-child),
.slide[data-layout="content-3up"]  > .stage > .grid:not(:only-child) {
  flex: 0 0 auto;
}

/* Opt-in: a stage child that SHOULD stretch to fill remaining vertical space
   (e.g. a middle block in an intentional top-quote → fill → bottom-disclaimer
   layout, or a body that fills below a pinned title). `!important` so it beats
   the :only-child gate above regardless of source order. */
.slide > .stage > .fs-fill {
  flex: 1 1 auto !important;
  min-height: 0;
}

/* ============================================================================
 *  SCREENSHOT TILE  ·  .shot-twin / .shot-col / .shot-pill / img.shot-img
 *  2026-06-05 · codified from meeting-recap-ai (label-above) + ai-knows-you.
 *  N FULL (un-cropped) product screenshots side by side, each with a pill label.
 *
 *  WHEN: show screenshots WHOLE, each topped (or footed) by a short pill label.
 *  Distinct from story-case `.scene-frame` (feishu-deck-patterns.css), which
 *  CROPS a documentary still via `background-size: cover` + overlay caption.
 *  NOTE: lives in feishu-deck.css (the always-loaded core), NOT
 *  feishu-deck-patterns.css — that file is only linked by story-case/one-pager
 *  decks, so a pattern that must work everywhere belongs here.
 *
 *  WHY a real <img>, not a `background-size:contain` frame (this is the whole
 *  point of the pattern). A contain image floats inside a FIXED-height frame, so
 *  shots of different aspect ratios sit at different vertical offsets and the
 *  gap between the *visible image* and its label differs column-to-column
 *  (measured 14 vs 54px on the source page). The validator can't see it
 *  (R-VIS-GUTTER measures DOM rects, not the floated background extent), and the
 *  runtime `balanceColumns` fix (feishu-deck.js) only half-solves it — it
 *  tightens the frame but then CENTRES each unit, so the labels end up
 *  mis-aligned across columns. An <img> sizes to its own aspect ratio with NO
 *  letterbox slack, and balanceColumns ignores it (it only touches
 *  background-size:contain). So: top-align the columns → labels line up; the
 *  pill↔image gap is the flex `gap` → identical for every aspect ratio; each
 *  image is as tall as it needs to be (shorter shots simply end higher).
 * ============================================================================ */
.slide .shot-twin {
  display: grid;
  grid-template-columns: repeat(var(--shot-cols, 2), 1fr);
  gap: 28px;
  align-items: start;        /* top-align columns → pill labels line up across columns */
  justify-items: center;
}
.slide .shot-col {
  display: flex; flex-direction: column;
  gap: 14px;                 /* THE label↔image gap — one constant, every aspect ratio */
  align-items: center; min-width: 0;
}
.slide .shot-pill {
  align-self: center;
  padding: 10px 24px;
  background: rgba(60,127,255,0.20);
  border: 1px solid rgba(60,127,255,0.50);
  border-radius: 999px;
  font: 700 24px/1.2 var(--fs-font-cjk);
  color: #fff;
  letter-spacing: 0.005em;
}
.slide .shot-col.is-violet .shot-pill { background: rgba(159,111,241,0.20); border-color: rgba(159,111,241,0.50); }
.slide .shot-col.is-teal   .shot-pill { background: rgba(51,214,192,0.18);  border-color: rgba(51,214,192,0.50); }
.slide .shot-col.is-orange .shot-pill { background: rgba(254,127,0,0.18);   border-color: rgba(254,127,0,0.50); }

.slide img.shot-img {
  display: block;
  max-width: 100%;
  max-height: var(--shot-max-h, 740px);   /* floor for tall portraits; override per deck if the stage is shorter */
  width: auto; height: auto;               /* scale to natural aspect ratio — no letterbox, no float */
  border-radius: 12px;
  background: rgba(255,255,255,0.03);
}
/* caption-BELOW variant: author the pill AFTER the image in markup and add this
   class on the twin; bottom-align so the image bottoms (and the captions under
   them) line up. */
.slide .shot-twin--cap-below { align-items: end; }

/* When timeline is .stage-wrapped, the .axis (still absolutely positioned
   relative to .slide) needs to align with the .nodes' new flow position.
   The .stage centers content vertically in y=220..970 (mid ≈ 595). The
   .nodes' dot circles sit at .node padding-top:168 from .nodes top.
   Empirically, .axis at top: 50% of slide aligns reasonably well. */
.slide[data-layout="timeline"]:has(> .stage) > .axis { top: 50%; }

/* 规范 L3: cards must size to content, not stretch to full canvas. The grid
   parent does the vertical centering (via L2 rules above); cards take
   intrinsic height. This eliminates the "card empty-middle" bug where a
   stretched card with margin-top: auto on inner pills creates a giant gap. */
.slide .grid > .card,
.slide .flow > .step {
  align-self: stretch;        /* equal-height cards within their row (cosmetic) */
  margin: 0;                   /* override the auto-margin default — we're in grid now */
}
/* Override the .card { margin: auto 0 } default when inside a grid;
   the auto-margin made sense when card sat alone, but in a grid row the
   grid does the vertical placement. */

/* (Agenda v1 TOC-grid rules removed 2026-05-06 — superseded by v2.1
   pill-stack rules at line 854+. Removing v1 here is critical because
   it appeared LATER in the cascade and was overriding v2.1 styles.) */

/* Big-stat keeps its dramatic numeral but moves under the small-title header.
   规范: fixed-shape layout default-centers vertically. */
.slide[data-layout="big-stat"] .stage {
  top: 220px; bottom: 130px;
  align-items: center; justify-content: center;
  align-content: center;
}
.slide[data-layout="big-stat"] .num { font-size: 240px; line-height: 1; padding-top: 16px; }
.slide[data-layout="big-stat"] .num .unit { font-size: 72px; }

/* (Footer-on-non-cover/non-end rule retired 2026-05 — .footer is fully
   removed from templates; the global hide above kills any leftover DOM.) */

/* ============================================================================
   LAYOUT OVERFLOW DEFENSES (规范: stage must never exceed 1080×1920)
   These rules prevent the four classic flex/grid pitfalls that blow out the
   slide canvas. Any custom layout we add to a slide should follow these.
   ============================================================================ */

/* (1) Flex column with flex:1 children + min-content: parent overflows.
       Solution: every flex item also gets min-height: 0 so it can shrink. */
.slide [class*="flex-col"],
.slide .stage,
.slide .grid,
.slide .flow,
.slide .col-text {
  min-height: 0; min-width: 0;
}

/* (2) CSS Grid rows with content that overflows: cells take max-content height.
       Solution: use minmax(0, 1fr) and apply min-height:0 to grid cells. */
.slide .grid > *,
.slide .flow > *,
.slide .nodes > *,
.slide .toc > * {
  min-width: 0; min-height: 0;
}

/* (3) flex-wrap: wrap on a min-width:0 parent makes mixed-width children
       blow up scrollHeight. Default our pill rows to nowrap with horizontal
       overflow hidden — if you need wrapping, declare it explicitly. */
.slide .pills,
.slide .cta-row {
  flex-wrap: nowrap; overflow-x: hidden;
}

/* (4) Card vertical density control. Default = card heights are content-driven
       (margin: auto 0 centers them in the cell). Add class="is-stretch" only
       when content density justifies stretching to fill the cell. */
.slide .card { margin: auto 0; }
.slide .card.is-stretch { margin: 0; flex: 1 1 auto; }
.slide .grid .card { align-self: stretch; } /* legacy: existing layouts expect stretch */

/* (5) Cover-stage and full-bleed image text containers: clip overflow defensively */
.slide[data-layout="cover"] .stage,
.slide[data-layout="image-text"] .stage,
.slide[data-layout="end"]   .stage { overflow: hidden; }

/* AURORA — superseded by data-decor="aurora". The .has-aurora class is
   deprecated. Use data-decor="aurora" instead. (Empty rule kept for one
   release to avoid silent breakage of decks that still use .has-aurora.) */

/* ============================================================================
   PER-PAGE REVEAL ANIMATION — staggered via --child-i set by JS at init.
   Single keyframes + single rule replaces 7 nth-child rules (P3 perf).
   GPU-friendly: translate3d + will-change-style is implicit via animation.
   ============================================================================ */
@media (prefers-reduced-motion: no-preference) {
  .slide-frame.is-current .slide > * {
    opacity: 0; transform: translate3d(0, 10px, 0);
    animation: fs-reveal 0.28s ease forwards;
    animation-delay: calc(0.035s * var(--child-i, 1));
  }
  /* chrome (wordmark) reveals immediately, no stagger.
     .footer was retired 2026-05 — present-mode pager replaces it. */
  .slide-frame.is-current .slide .wordmark {
    opacity: 1; transform: none; animation: none;
  }
  /* First load: skip the staggered reveal on the first slide so users see
     content immediately. After the user navigates (idx changes), the
     reveal animation kicks back in for subsequent slides. */
  .deck:not([data-nav-armed]) .slide-frame:first-child .slide > * {
    opacity: 1 !important; transform: none !important; animation: none !important;
  }
}
@keyframes fs-reveal {
  from { opacity: 0; transform: translate3d(0, 14px, 0); }
  to   { opacity: 1; transform: translate3d(0, 0, 0); }
}

/* ============================================================================
   ATMOSPHERIC DECORATION — data-decor attribute (规范)
   Decoration is ORTHOGONAL to layout. A slide can carry any combination of
   layout + variant + decor. When re-rendering an existing slide into a
   standard layout, the slide's distinctive atmospheric imagery (radial glows,
   full-bleed photos, brand gradients, aurora, film grain) MUST be preserved
   via data-decor — silent stripping makes the redesign feel sterile.
   The decor renders as a ::before pseudo-element under all slide content,
   pointer-events: none. Multiple decors stack via space-separated tokens:
     <div class="slide" data-layout="content-3up" data-decor="violet-glow grain">
   ============================================================================ */

.slide[data-decor]::before {
  content: ""; position: absolute; inset: 0;
  pointer-events: none; z-index: 0;
  /* default: transparent — concrete decor rules below stack via box-shadow tricks
     and background composition */
  background-color: transparent;
  background-repeat: no-repeat;
  background-blend-mode: normal;
}
/* Ensure all slide content is layered ABOVE the decor */
.slide > * { position: relative; z-index: 1; }

/* — Decor tokens: each adds a background layer.
     Use ~= (whitespace-separated word matcher) so tokens compose freely. — */

/* violet-glow: lower-right violet bloom (Digital Workforce / 数字员工 signature) */
.slide[data-decor~="violet-glow"]::before {
  background-image:
    radial-gradient(50% 42% at 88% 92%, rgba(159,111,241,0.34), transparent 65%),
    radial-gradient(38% 30% at 78% 78%, rgba(92,63,251,0.18), transparent 70%);
}

/* blue-glow: centered cool blue bloom (quote / hero accent) */
.slide[data-decor~="blue-glow"]::before {
  background-image:
    radial-gradient(70% 55% at 50% 55%, rgba(60,127,255,0.22), transparent 60%);
}

/* mix-glow: purple top-right + blue bottom-left (closing-style cinematic mix) */
.slide[data-decor~="mix-glow"]::before {
  background-image:
    radial-gradient(55% 45% at 92% 8%, rgba(159,111,241,0.30), transparent 60%),
    radial-gradient(55% 45% at 8% 92%, rgba(60,127,255,0.22), transparent 60%);
}
/* 2026-05-29 · letterbox-aware decor mirror (BF15.1 framework fix).
   mix-glow's bright zone is at 92% 8% (top-right) — on non-16:9 fullscreen
   viewports the slide's ::before tint stops at the slide edge but the
   letterbox area above stays untinted → visible luma jump = "上面有黑色的边".
   Mirror decor onto `.slide-frame::after` so it covers letterbox + slide
   uniformly, and suppress slide's own ::before to prevent double-tint
   stacking at the slide boundary. */
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="mix-glow"])::after {
  content: ''; position: absolute; inset: 0;
  pointer-events: none; z-index: 0;
  background-image:
    radial-gradient(55% 45% at 92% 8%, rgba(159,111,241,0.30), transparent 60%),
    radial-gradient(55% 45% at 8% 92%, rgba(60,127,255,0.22), transparent 60%);
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="mix-glow"]) > .slide[data-decor~="mix-glow"]::before {
  background-image: none;
}

/* teal-glow: bottom-left teal bloom (data / KPI emphasis) */
.slide[data-decor~="teal-glow"]::before {
  background-image:
    radial-gradient(48% 42% at 14% 86%, rgba(51,214,192,0.26), transparent 65%);
}

/* orange-spark: top-right warm flare (alert / 例外 emphasis) */
.slide[data-decor~="orange-spark"]::before {
  background-image:
    radial-gradient(40% 32% at 88% 18%, rgba(254,127,0,0.22), transparent 65%);
}
/* 2026-05-29 · letterbox-aware decor mirror (BF15.1 framework fix · same as mix-glow).
   orange-spark bright zone at 88% 18% (top-right) → same letterbox luma jump issue. */
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="orange-spark"])::after {
  content: ''; position: absolute; inset: 0;
  pointer-events: none; z-index: 0;
  background-image:
    radial-gradient(40% 32% at 88% 18%, rgba(254,127,0,0.22), transparent 65%);
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="orange-spark"]) > .slide[data-decor~="orange-spark"]::before {
  background-image: none;
}

/* aurora: three-color ambient (matches the legacy .has-aurora class) */
.slide[data-decor~="aurora"]::before {
  background-image:
    radial-gradient(40% 35% at 12% 18%, rgba(60,127,255,0.18), transparent 70%),
    radial-gradient(35% 30% at 88% 12%, rgba(159,111,241,0.14), transparent 70%),
    radial-gradient(40% 32% at 70% 92%, rgba(51,214,192,0.12), transparent 70%);
}

/* grain: subtle film grain via CSS noise (no external asset) */
.slide[data-decor~="grain"]::after {
  content: ""; position: absolute; inset: 0;
  pointer-events: none; z-index: 0;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.22'/></svg>");
  background-size: 200px 200px;
  mix-blend-mode: overlay;
  opacity: 0.32;
}

/* 2026-06-14 · letterbox-aware decor mirror — extend BF15.1 (which covered only
   mix-glow / orange-spark) to the remaining decor tokens. SAME root cause: the
   slide's ::before/::after decor stops at the 16:9 slide edge, so on a non-16:9
   viewport (incl. 16:10 MacBook fullscreen) the letterbox bands stay untinted →
   a horizontal luma seam at the slide edge ("上面有不一样的背景色 / 一条黑边").
   Mirror each decor onto the viewport-filling .slide-frame so it covers letterbox
   + slide uniformly, and suppress the slide's own copy to avoid double-paint.
   Glows → .slide-frame::after (one glow per page); grain → .slide-frame::before
   so grain can coexist with a glow on the same page (e.g. `mix-glow grain`). */
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="blue-glow"])::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background-image:
    radial-gradient(70% 55% at 50% 55%, rgba(60,127,255,0.22), transparent 60%);
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="blue-glow"]) > .slide[data-decor~="blue-glow"]::before { background-image: none; }

.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="violet-glow"])::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background-image:
    radial-gradient(50% 42% at 88% 92%, rgba(159,111,241,0.34), transparent 65%),
    radial-gradient(38% 30% at 78% 78%, rgba(92,63,251,0.18), transparent 70%);
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="violet-glow"]) > .slide[data-decor~="violet-glow"]::before { background-image: none; }

.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="teal-glow"])::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background-image:
    radial-gradient(48% 42% at 14% 86%, rgba(51,214,192,0.26), transparent 65%);
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="teal-glow"]) > .slide[data-decor~="teal-glow"]::before { background-image: none; }

.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="aurora"])::after {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background-image:
    radial-gradient(40% 35% at 12% 18%, rgba(60,127,255,0.18), transparent 70%),
    radial-gradient(35% 30% at 88% 12%, rgba(159,111,241,0.14), transparent 70%),
    radial-gradient(40% 32% at 70% 92%, rgba(51,214,192,0.12), transparent 70%);
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="aurora"]) > .slide[data-decor~="aurora"]::before { background-image: none; }

/* grain mirror — .slide-frame::before (::after reserved for glows). Same noise +
   overlay blend; suppress the slide's own grain ::after to avoid double-grain. */
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="grain"])::before {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.22'/></svg>");
  background-size: 200px 200px;
  mix-blend-mode: overlay;
  opacity: 0.32;
}
.deck[data-mode="present"] .slide-frame:has(> .slide[data-decor~="grain"]) > .slide[data-decor~="grain"]::after { background-image: none; }

/* topo: faint topographic line motif (process / engineering pages) */
.slide[data-decor~="topo"]::before {
  background-image:
    repeating-linear-gradient(115deg,
      rgba(255,255,255,0.045) 0px,
      rgba(255,255,255,0.045) 1px,
      transparent 1px,
      transparent 28px),
    radial-gradient(60% 50% at 80% 40%, rgba(60,127,255,0.10), transparent 70%);
}

/* flower-bg: full-bleed flower master image (cover/end style for any layout) */
.slide[data-decor~="flower-bg"] {
  background-image: var(--fs-asset-cover-bg) !important;
  background-size: cover !important;
  background-position: center !important;
}
/* darker overlay so text on flower-bg remains legible on content layouts */
.slide[data-decor~="flower-bg"]:not([data-layout="cover"]):not([data-layout="end"])::before {
  background-image:
    linear-gradient(90deg, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.55) 38%, rgba(0,0,0,0.18) 75%, transparent 100%),
    linear-gradient(180deg, rgba(0,0,0,0.45) 0%, transparent 25%, transparent 70%, rgba(0,0,0,0.55) 100%);
}

/* section-bg: re-uses the section gradient on any layout (color-rich chapters) */
.slide[data-decor~="section-bg"] {
  background-image: var(--fs-asset-section-bg) !important;
  background-size: cover !important;
  background-position: center !important;
}

/* photo-bg: opt-in slot for a custom photographic background.
   Author sets the URL via inline style or a CSS variable on the slide:
     <div class="slide" data-decor="photo-bg" style="--photo: url('...')"> */
.slide[data-decor~="photo-bg"] {
  background-image: var(--photo, none) !important;
  background-size: cover !important; background-position: center !important;
}
.slide[data-decor~="photo-bg"]::before {
  background-image:
    linear-gradient(180deg, rgba(0,0,0,0.4) 0%, transparent 30%, rgba(0,0,0,0.7) 100%),
    linear-gradient(90deg, rgba(0,0,0,0.55) 0%, transparent 60%);
}

/* Legacy .has-aurora rule removed — use data-decor="aurora" instead. */

/* ============================================================================
   NARRATIVE PATTERN COMPONENTS (DESIGN.md §9 patterns A–K)
   Optional opt-in classes for advanced narrative layouts. See DESIGN.md.
   ============================================================================ */

/* North-Star chip (pattern C) — `★ 北极星指标 · [指标名]` */
.slide .north-star {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 10px 18px; border-radius: 999px;
  border: 1px dashed color-mix(in srgb, var(--fs-teal) 60%, transparent);
  font: 600 var(--fs-foot)/1 var(--fs-font-cjk);
  color: var(--fs-teal); letter-spacing: 0.02em;
}
.slide .north-star::before {
  content: "★"; font-size: var(--fs-foot); color: var(--fs-teal);
}

/* Verdict pill matrix (pattern B) — Go / Conditional / No-Go cards */
.slide .verdict-card {
  position: relative; overflow: hidden; border-radius: var(--fs-radius-card);
  border: 2px solid var(--fs-text-16);
  background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0));
  padding: 36px;
}
.slide .verdict-card::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 5px;
  background: var(--fs-text-16);
}
.slide .verdict-card[data-verdict="go"] { border-color: color-mix(in srgb, var(--fs-teal) 60%, transparent); }
.slide .verdict-card[data-verdict="go"]::before { background: var(--fs-teal); }
.slide .verdict-card[data-verdict="conditional"] { border-color: color-mix(in srgb, var(--fs-violet) 60%, transparent); }
.slide .verdict-card[data-verdict="conditional"]::before { background: var(--fs-violet); }
.slide .verdict-card[data-verdict="nogo"] { border-color: color-mix(in srgb, var(--fs-orange) 60%, transparent); }
.slide .verdict-card[data-verdict="nogo"]::before { background: var(--fs-orange); }
.slide .verdict-card .badge {
  /* 2026-05-17: was absolute top-right at 16px chrome — got clipped/overlapped
     on dense cards and read as illegible. Now inline self-aligned-start pill
     at Body tier (24); reads as a peer label, never overlaps. */
  align-self: flex-start;
  font: 700 var(--fs-body)/1 var(--fs-font-cjk); letter-spacing: 0.04em;
  padding: 10px 20px; border-radius: 999px;
  color: var(--fs-text-72);
}
.slide .verdict-card[data-verdict="go"] .badge        { color: var(--fs-teal); }
.slide .verdict-card[data-verdict="conditional"] .badge{ color: var(--fs-violet); }
.slide .verdict-card[data-verdict="nogo"] .badge      { color: var(--fs-orange); }

/* Boundary band (pattern D) — 不做 / 做 contrast */
.slide .boundary-band {
  display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
}
.slide .boundary-no, .slide .boundary-yes {
  border-radius: 16px; padding: 28px;
  background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0));
}
.slide .boundary-no  { border: 1px dashed color-mix(in srgb, var(--fs-orange) 60%, transparent); }
.slide .boundary-yes { border: 1px solid color-mix(in srgb, var(--fs-teal) 60%, transparent); }
.slide .boundary-no .pill, .slide .boundary-yes .pill {
  display: inline-flex; padding: 8px 14px; font: 600 var(--fs-foot)/1 var(--fs-font-latin);
  letter-spacing: 0.14em; text-transform: uppercase; border-radius: 999px;
  margin-bottom: 14px;
}
.slide .boundary-no .pill  { background: rgba(254,127,0,0.16);  color: var(--fs-orange); }
.slide .boundary-yes .pill { background: rgba(51,214,192,0.16); color: var(--fs-teal); }
.slide .boundary-no .body  { font: 500 var(--fs-body)/1.55 var(--fs-font-cjk); color: var(--fs-text-65); text-decoration: line-through; text-decoration-color: rgba(254,127,0,.45); }
.slide .boundary-yes .body { font: 500 var(--fs-body)/1.55 var(--fs-font-cjk); color: var(--fs-text); }
.slide .boundary-yes .hl   { color: var(--fs-teal); font-weight: 600; }

/* Evolution chip (pattern F) — 现阶段 → 未来 */
.slide .evolution-chip {
  display: grid; grid-template-columns: auto 1fr; gap: 12px 18px;
  padding: 18px 22px; border-radius: 14px;
  border: 1px dashed var(--fs-text-16);
}
.slide .evolution-chip .stage-tag {
  font: 600 var(--fs-foot)/1 var(--fs-font-latin); letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--fs-text-48);
  white-space: nowrap;
}
.slide .evolution-chip .stage-body {
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk); color: var(--fs-text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.slide .evolution-chip .stage-body.is-future { color: var(--fs-teal); font-weight: 600; }

/* Three-color principle band (pattern J) */
.slide .principle-band {
  display: inline-flex; flex-wrap: wrap; gap: 24px;
  padding: 18px 24px; border-radius: 14px;
  background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0));
  border: 1px solid var(--fs-hairline);
}
.slide .principle-band .principle {
  /* 2026-05-16: 18 → 22 — principles carry strategic content, not chrome.
     Per SKILL.md: "Content chips that carry meaning ≥ 18 px min" — but
     these are headline strategy labels, treated as body. */
  display: inline-flex; align-items: center; gap: 10px;
  font: 600 var(--fs-body)/1.2 var(--fs-font-cjk);
}
.slide .principle-band .principle::before {
  content: ""; width: 8px; height: 8px; border-radius: 999px;
  background: currentColor;
  box-shadow: 0 0 0 6px color-mix(in srgb, currentColor 16%, transparent);
}
.slide .principle-band .principle[data-color="teal"]   { color: var(--fs-teal); }
.slide .principle-band .principle[data-color="blue"]   { color: var(--fs-blue); }
.slide .principle-band .principle[data-color="purple"] { color: var(--fs-purple); }
.slide .principle-band .principle[data-color="violet"] { color: var(--fs-violet); }
.slide .principle-band .principle[data-color="orange"] { color: var(--fs-orange); }

/* ----------------------------------------------------------------------------
   Two-hand architecture (pattern H+) — top decision-maker crown, two SVG
   curved-dashed lines, two hands grid (left blue / right teal), bottom base.
   Used for "we do exactly two things" value-proposition slides where you
   need to show: who decides → what split between two halves → on what base.
   Brand palette only: blue+teal (NOT v2's blue+orange).

   Markup:
     <div class="two-hand-arch">
       <div class="arch-top">品牌总部 · CEO / 销售 VP / 渠道总监</div>
       <div class="arch-lines">
         <svg viewBox="0 0 800 60" preserveAspectRatio="none">
           <path d="M400,0 Q400,30 200,60" stroke="…" .../>
           <path d="M400,0 Q400,30 600,60" stroke="…" .../>
         </svg>
       </div>
       <div class="arch-hands">
         <div class="arch-hand left"> <h3>左手 · X</h3> ... </div>
         <div class="arch-hand right"><h3>右手 · Y</h3> ... </div>
       </div>
       <div class="arch-base">底座 · 飞书 IM · 文档 · 多维表格 …</div>
     </div>
   ---------------------------------------------------------------------------- */
.slide .two-hand-arch {
  display: grid;
  grid-template-rows: auto auto 1fr auto;
  gap: 18px;
  height: 100%;
}
.slide .arch-top {
  align-self: center; justify-self: center;
  padding: 18px 36px; border-radius: 14px;
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--fs-blue) 22%, transparent),
    color-mix(in srgb, var(--fs-purple) 14%, transparent));
  border: 1px solid color-mix(in srgb, var(--fs-blue) 45%, transparent);
  font: 700 var(--fs-body)/1.3 var(--fs-font-cjk);
  color: var(--fs-text);
  letter-spacing: 0.06em;
  display: inline-flex; align-items: center; gap: 14px;
}
.slide .arch-top svg { width: 24px; height: 24px; color: var(--fs-blue); }
.slide .arch-lines { height: 60px; width: 100%; display: flex; justify-content: center; }
.slide .arch-lines svg { width: 100%; height: 100%; overflow: visible; }
.slide .arch-hands {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: 32px; align-items: stretch;
}
.slide .arch-hand {
  border-radius: var(--fs-radius-card); padding: 32px;
  border: 1px solid var(--fs-hairline);
  position: relative;
  display: flex; flex-direction: column; gap: 14px;
}
.slide .arch-hand.left {
  background: linear-gradient(160deg,
    color-mix(in srgb, var(--fs-blue) 18%, transparent),
    color-mix(in srgb, var(--fs-blue) 2%, transparent));
  border-color: color-mix(in srgb, var(--fs-blue) 45%, transparent);
}
.slide .arch-hand.right {
  background: linear-gradient(160deg,
    color-mix(in srgb, var(--fs-teal) 16%, transparent),
    color-mix(in srgb, var(--fs-teal) 2%, transparent));
  border-color: color-mix(in srgb, var(--fs-teal) 45%, transparent);
}
.slide .arch-hand-title { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
.slide .arch-hand-title h3 {
  font: 700 var(--fs-sub)/1.2 var(--fs-font-cjk);
  color: var(--fs-text);
}
.slide .arch-hand-title .em {
  /* 2026-05-16: 16 → 22 — EN motto under hand title is a content sub-line
     (carries the architecture's English name); per SKILL.md it's body. */
  font: 600 var(--fs-body)/1.2 var(--fs-font-latin);
  letter-spacing: 0.18em;
  color: var(--fs-text-72);
}
.slide .arch-hand .sub {
  /* 2026-05-16: 20 → 22 — hand explanation line per SKILL.md body floor. */
  font: 500 var(--fs-body)/1.5 var(--fs-font-cjk);
  color: var(--fs-text-72);
}
.slide .arch-items { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.slide .arch-item {
  /* 2026-05-16: 20 → 22 — numbered architecture item body per body floor. */
  display: flex; align-items: center; gap: 14px;
  padding: 14px 16px; border-radius: 10px;
  background: rgba(255,255,255,0.05);
  font: 500 var(--fs-body)/1.45 var(--fs-font-cjk);
  color: var(--fs-text);
}
.slide .arch-item .n {
  flex: none; width: 30px; height: 30px;
  border-radius: 8px;
  display: grid; place-items: center;
  background: var(--fs-blue); color: #fff;
  font: 700 var(--fs-foot)/1 var(--fs-font-cjk);
}
.slide .arch-hand.right .arch-item .n { background: var(--fs-teal); color: #0A0E1B; }
.slide .arch-base {
  /* 2026-05-16: 20 → 22 — base layer label per SKILL.md body floor. */
  padding: 18px 28px; border-radius: 14px;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--fs-blue) 12%, transparent),
    color-mix(in srgb, var(--fs-teal) 12%, transparent));
  border: 1px dashed color-mix(in srgb, var(--fs-text) 18%, transparent);
  text-align: center;
  font: 500 var(--fs-body)/1.45 var(--fs-font-cjk);
  color: var(--fs-text-72);
  letter-spacing: 0.04em;
}
.slide .arch-base b { color: var(--fs-text); font-weight: 700; }

/* .source-footer (data-citation line) RETIRED 2026-05 alongside .footer
   chrome. Data citations now go inline in slide body or as a normal
   .caption/.eyebrow if context requires. Hide any leftover DOM in
   legacy decks. Templates and render.py no longer emit this markup. */
.slide .source-footer { display: none; }

/* Mobile portrait toast (Section 六 #4) */
@media (orientation: portrait) and (max-width: 900px) {
  body.fs-portrait-warn::after {
    content: "横屏体验更佳 · Rotate for best view";
    position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
    background: rgba(60,127,255,0.92); color: #fff;
    font: 500 13px/1 var(--fs-font-cjk);
    padding: 10px 18px; border-radius: 999px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.45);
    z-index: 200; pointer-events: none;
    animation: fs-toast-fade 7s ease forwards;
  }
}
@keyframes fs-toast-fade {
  0%   { opacity: 0; transform: translateX(-50%) translateY(20px); }
  10%  { opacity: 1; transform: translateX(-50%) translateY(0); }
  85%  { opacity: 1; }
  100% { opacity: 0; }
}

/* iframe / phone-frame helpers (Section 五) */
.slide .phone-frame {
  /* allow:drop-shadow — phone-frame is a UI mock simulating a real
     phone device; depth shadow is part of the simulation. */
  width: 280px; aspect-ratio: 9/19.5;
  background: #000; padding: 7px; border-radius: 32px;
  position: relative;
  box-shadow: 0 24px 64px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.08);
}
.slide .phone-frame::before {
  /* notch */
  content: ""; position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
  width: 80px; height: 18px; border-radius: 999px;
  background: #000; pointer-events: none; z-index: 2;
}
.slide .phone-frame::after {
  /* home indicator */
  content: ""; position: absolute; bottom: 6px; left: 50%; transform: translateX(-50%);
  width: 90px; height: 3px; border-radius: 999px;
  background: rgba(255,255,255,0.85); pointer-events: none; z-index: 2;
}
.slide .phone-frame .phone-screen {
  width: 100%; height: 100%; border-radius: 26px; overflow: hidden;
  background: #000;
}
.slide .phone-frame .phone-screen iframe {
  width: 100%; height: 100%; border: 0; display: block;
}

.slide .desktop-frame {
  /* allow:drop-shadow — desktop-frame is a UI mock simulating a real
     monitor / app window; depth shadow is part of the simulation. */
  width: 100%; aspect-ratio: 16/10;
  border-radius: 16px; overflow: hidden;
  background: #000;
  box-shadow: 0 24px 64px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.08);
  position: relative;
}
.slide .desktop-frame iframe {
  width: 100%; height: 100%; border: 0; display: block;
}
.slide .desktop-frame .iframe-hint {
  position: absolute; top: 16px; right: 16px;
  font: 600 var(--fs-foot)/1 var(--fs-font-latin); letter-spacing: 0.14em;
  text-transform: uppercase;
  background: rgba(60,127,255,0.85); color: #fff;
  padding: 8px 14px; border-radius: 999px;
  pointer-events: none;             /* 规范: hint never blocks clicks */
  animation: fs-hint-fade 7s ease forwards;
}
@keyframes fs-hint-fade {
  0%, 70% { opacity: 1; }
  100% { opacity: 0; }
}

/* ============================================================================
   VARIANT EXAMPLES (规范: data-variant must redeclare all structural props)
   --------------------------------------------------------------------------
   These canonical variants show the discipline. Each variant block redeclares
   EVERY directional/structural property of its base layout — even when the
   value is identical to the base. This makes variants self-contained and
   audit-friendly. See SKILL.md "Variant override discipline" for the full
   explanation. validate.py R47 enforces the redeclaration.
   ============================================================================ */

/* content-3up · variant="vertical" → flip the 3-card row into a vertical stack
   (useful when the third card is genuinely longer and would push the row out
   of canvas height) */
.slide[data-layout="content-3up"][data-variant="vertical"] .grid {
  display: grid;                                     /* same engine */
  grid-template-columns: 1fr;                        /* was: repeat(3, 1fr) */
  grid-template-rows: repeat(3, auto);               /* was: auto */
  align-items: stretch;                              /* same — explicit anyway */
  align-content: center;                             /* same */
  justify-items: stretch;                            /* same */
  justify-content: stretch;                          /* same */
  gap: 24px;                                         /* tighter than base 36px */
}

/* content-2col · variant="reversed" → swap text/visual sides without re-ordering DOM */
.slide[data-layout="content-2col"][data-variant="reversed"] .grid {
  display: grid;                                     /* same engine */
  grid-template-columns: 1fr 1.1fr;                  /* was: 1.1fr 1fr */
  grid-template-rows: auto;                          /* same */
  align-items: stretch;                              /* same */
  align-content: center;                             /* same */
  justify-items: stretch;                            /* same */
  justify-content: stretch;                          /* same */
  gap: 96px;                                         /* same */
  direction: rtl;                                    /* swap visual order */
}
.slide[data-layout="content-2col"][data-variant="reversed"] .grid > * {
  direction: ltr;                                    /* restore content direction */
}

/* process · variant="vertical" → step row becomes vertical timeline */
.slide[data-layout="process"][data-variant="vertical"] .flow {
  display: grid;                                     /* swap from grid-cols to grid-rows */
  grid-template-columns: 1fr;                        /* was: repeat(N, 1fr) */
  grid-template-rows: repeat(var(--cols, 4), auto);  /* explicit row count */
  align-items: stretch;                              /* same */
  align-content: center;                             /* same */
  justify-items: stretch;                            /* same */
  justify-content: stretch;                          /* same */
  gap: 18px;                                         /* tighter for vertical */
}
/* (#84) vertical-variant chevron-hide REMOVED — it only suppressed the old
   16px chevron (now deleted). The canonical `.step::after` arrow is already
   hidden in vertical mode by the broader
   `[data-variant="vertical"] .step::after { display:none }` rule. */

/* ============================================================================
   UI MOCK PRIMITIVES (规范: re-render screenshots as HTML, not raster images)
   --------------------------------------------------------------------------
   When adapting source content into HTML, every system UI screenshot,
   chat thread, file list, spreadsheet, dashboard, or app window MUST be
   recreated in HTML/CSS — never embedded as a JPG/PNG.
   - HTML mocks scale crisply to any resolution (no pixelation in fullscreen)
   - Use brand fonts/colors so the mock harmonizes with the deck
   - Are inspectable, accessible, and lighter than raster
   - Avoid licensing / NDA exposure of real product screenshots
   The classes below are the canonical primitives. Compose them.
   ============================================================================ */

/* ---- Window / frame chrome ---- */

.slide .ui-window {
  /* allow:drop-shadow — UI-mock app windows simulate real desktop apps;
     depth shadow is part of the simulation. */
  position: relative;
  background: linear-gradient(180deg, #131723 0%, #0B0E18 100%);
  border: 1px solid rgba(255,255,255,0.08);
  border-radius: 16px;
  overflow: hidden;
  display: flex; flex-direction: column;
  box-shadow: 0 24px 64px rgba(0,0,0,0.45);
}
.slide .ui-titlebar {
  display: flex; align-items: center; gap: 14px;
  padding: 14px 18px;
  border-bottom: 1px solid rgba(255,255,255,0.06);
  background: rgba(255,255,255,0.02);
  font: 500 var(--fs-foot)/1 var(--fs-font-cjk);
  color: var(--fs-text-72);
}
.slide .ui-traffic-lights {
  display: inline-flex; gap: 8px; align-items: center;
}
.slide .ui-traffic-lights::before,
.slide .ui-traffic-lights::after,
.slide .ui-traffic-lights > i {
  content: ""; display: inline-block; width: 12px; height: 12px;
  border-radius: 999px;
}
.slide .ui-traffic-lights::before { background: #FF5F57; }
.slide .ui-traffic-lights > i     { background: #FEBC2E; }
.slide .ui-traffic-lights::after  { background: #28C840; }

/* ---- Browser-style window with URL pill ---- */

.slide .ui-browser .ui-titlebar {
  gap: 16px;
  padding: 12px 16px;
}
.slide .ui-browser .ui-urlbar {
  flex: 1;
  background: rgba(255,255,255,0.06);
  border: 1px solid rgba(255,255,255,0.08);
  border-radius: 999px;
  padding: 8px 18px;
  font: 500 var(--fs-foot)/1 var(--fs-font-latin);
  color: var(--fs-text-72);
  letter-spacing: 0.02em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ---- Sidebar + main split ---- */

.slide .ui-window .ui-body {
  display: flex; flex-direction: column;
  flex: 1 1 auto; min-height: 0;
  gap: 12px; padding: 16px;
}
/* Sidebar+main split (legacy ui-sidebar pattern) re-enables row direction
   and kills the gap/padding so the sidebar can paint border-right edge-
   to-edge inside the window. Detected via :has() so authors don't need
   an explicit modifier class on .ui-body. */
.slide .ui-window .ui-body:has(> .ui-sidebar) {
  flex-direction: row;
  gap: 0; padding: 0;
}
.slide .ui-sidebar {
  width: 260px; flex: 0 0 260px;
  background: rgba(255,255,255,0.02);
  border-right: 1px solid rgba(255,255,255,0.06);
  padding: 20px 14px; overflow: hidden;
  display: flex; flex-direction: column; gap: 4px;
}
.slide .ui-sidebar .ui-section {
  font: 600 var(--fs-foot)/1 var(--fs-font-latin); letter-spacing: 0.14em;
  text-transform: uppercase; color: var(--fs-text-40);
  padding: 14px 12px 8px;
}
.slide .ui-main {
  flex: 1 1 auto; min-width: 0; min-height: 0;
  display: flex; flex-direction: column;
}

/* ---- Toolbar with tabs ---- */

.slide .ui-toolbar {
  display: flex; align-items: center; gap: 24px;
  padding: 12px 22px;
  border-bottom: 1px solid rgba(255,255,255,0.06);
  background: rgba(255,255,255,0.015);
}
.slide .ui-tab-bar { display: flex; gap: 4px; align-items: center; }
.slide .ui-tab {
  position: relative;
  padding: 10px 16px;
  font: 500 var(--fs-foot)/1.2 var(--fs-font-cjk);
  color: var(--fs-text-48);
  border-radius: 8px;
  cursor: default;
}
.slide .ui-tab.is-active {
  color: #fff; font-weight: 600;
  background: rgba(60,127,255,0.10);
}
.slide .ui-tab.is-active::after {
  content: ""; position: absolute; left: 16px; right: 16px; bottom: -13px;
  height: 2px; background: var(--fs-blue);
}

/* ---- Lists (chat / contacts / files) ---- */

.slide .ui-list {
  display: flex; flex-direction: column;
  overflow: hidden;
}
.slide .ui-list-item {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 14px;
  padding: 14px 18px;
  border-bottom: 1px solid rgba(255,255,255,0.04);
  font: 500 var(--fs-foot)/1.4 var(--fs-font-cjk);
  color: var(--fs-text-72);
}
.slide .ui-list-item.is-selected {
  background: rgba(60,127,255,0.10);
  color: #fff;
}
.slide .ui-list-item .ui-meta {
  font: 500 var(--fs-foot)/1 var(--fs-font-latin);
  color: var(--fs-text-40); letter-spacing: 0.02em;
  white-space: nowrap;
}
.slide .ui-list-item .ui-line {
  display: flex; flex-direction: column; gap: 4px; min-width: 0;
}
.slide .ui-list-item .ui-line .name {
  font-size: var(--fs-foot); color: #fff; font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.slide .ui-list-item .ui-line .preview {
  font-size: var(--fs-foot); color: var(--fs-text-48);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ---- Avatar (round, color-mix initial) ---- */

.slide .ui-avatar {
  width: 40px; height: 40px; border-radius: 999px;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--fs-blue) 22%, #131723);
  color: #fff; font: 700 var(--fs-foot)/1 var(--fs-font-latin);
  letter-spacing: 0.02em; text-transform: uppercase;
}
/* Per-tone variants — rgba baked statically (was color-mix). Identical visual,
   skips the per-repaint mix calculation. */
.slide .ui-avatar[data-tone="teal"]   { background: #1F2D34; background: rgba(51,214,192,0.26); }
.slide .ui-avatar[data-tone="purple"] { background: #1E1A38; background: rgba(92,63,251,0.26); }
.slide .ui-avatar[data-tone="orange"] { background: #292019; background: rgba(254,127,0,0.26); }
.slide .ui-avatar.lg { width: 56px; height: 56px; font-size: var(--fs-body); }
.slide .ui-avatar.sm { width: 32px; height: 32px; font-size: var(--fs-foot); }

/* ---- Chat bubbles ---- */

.slide .ui-msg {
  max-width: 70%;
  padding: 14px 18px;
  border-radius: 16px;
  font: 500 var(--fs-foot)/1.45 var(--fs-font-cjk);
  background: rgba(255,255,255,0.06);
  color: var(--fs-text);
  border: 1px solid rgba(255,255,255,0.04);
}
.slide .ui-msg.is-self {
  background: var(--fs-blue);
  border-color: transparent;
  color: #fff;
  align-self: flex-end;
  border-bottom-right-radius: 4px;
}
.slide .ui-msg.is-other {
  align-self: flex-start;
  border-bottom-left-radius: 4px;
}
.slide .ui-msg-stack {
  display: flex; flex-direction: column; gap: 10px;
  padding: 18px;
}

/* ---- Form inputs / buttons ---- */

.slide .ui-input {
  display: block; width: 100%;
  padding: 12px 16px;
  background: rgba(255,255,255,0.04);
  border: 1px solid rgba(255,255,255,0.10);
  border-radius: 10px;
  font: 500 var(--fs-foot)/1.3 var(--fs-font-cjk);
  color: #fff;
}
.slide .ui-input::placeholder { color: var(--fs-text-40); }
.slide .ui-input:focus { outline: 0; border-color: var(--fs-blue); }

.slide .ui-btn {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 12px 22px;
  border-radius: 10px;
  border: 1px solid transparent;
  font: 600 var(--fs-foot)/1 var(--fs-font-cjk);
  cursor: default;
}
.slide .ui-btn.is-primary {
  background: var(--fs-blue); color: #fff;
}
.slide .ui-btn.is-secondary {
  background: rgba(255,255,255,0.06); color: #fff;
  border-color: rgba(255,255,255,0.10);
}
.slide .ui-btn.is-ghost {
  /* allow:white-opacity — ghost UI button text is INTENTIONALLY subordinate
     to its sibling primary/secondary buttons in a Lark-style mock; this is
     the .ui-* primitive recreating an app's UI tone, not slide content. */
  background: transparent; color: var(--fs-text-72);
  border-color: rgba(255,255,255,0.16);
}

/* ---- Cell-grid (Base / spreadsheet / multi-dim table) ---- */

.slide .ui-grid {
  display: grid; gap: 0;
  border: 1px solid rgba(255,255,255,0.06);
  border-radius: 10px;
  overflow: hidden;
  background: rgba(255,255,255,0.015);
}
.slide .ui-grid .ui-cell {
  padding: 12px 16px;
  border-right: 1px solid rgba(255,255,255,0.04);
  border-bottom: 1px solid rgba(255,255,255,0.04);
  font: 500 var(--fs-foot)/1.3 var(--fs-font-cjk);
  color: var(--fs-text-72);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.slide .ui-grid .ui-cell.is-header {
  background: rgba(255,255,255,0.04);
  color: #fff; font-weight: 600;
  font-size: var(--fs-foot); letter-spacing: 0.04em;
}
.slide .ui-grid .ui-cell.is-tag {
  display: inline-block;
}
.slide .ui-grid .ui-cell .ui-pill {
  display: inline-block; padding: 4px 10px; border-radius: 999px;
  font: 600 var(--fs-foot)/1 var(--fs-font-latin); letter-spacing: 0.06em;
}
/* Per-tone pill backgrounds — rgba baked (was color-mix). */
.slide .ui-grid .ui-cell .ui-pill[data-tone="teal"]   { background: rgba(51,214,192,0.18);  color: var(--fs-teal); }
.slide .ui-grid .ui-cell .ui-pill[data-tone="blue"]   { background: rgba(60,127,255,0.18);  color: var(--fs-blue); }
.slide .ui-grid .ui-cell .ui-pill[data-tone="purple"] { background: rgba(92,63,251,0.18);   color: var(--fs-purple); }
.slide .ui-grid .ui-cell .ui-pill[data-tone="orange"] { background: rgba(254,127,0,0.18);   color: var(--fs-orange); }

/* ---- Status dots / badges ---- */

.slide .ui-status-dot {
  display: inline-block; width: 8px; height: 8px; border-radius: 999px;
  background: var(--fs-text-40); vertical-align: middle;
}
.slide .ui-status-dot.is-online { background: var(--fs-teal); }
.slide .ui-status-dot.is-busy   { background: var(--fs-orange); }
.slide .ui-status-dot.is-offline{ background: var(--fs-text-40); }

.slide .ui-badge {
  display: inline-grid; place-items: center;
  min-width: 22px; height: 22px; padding: 0 7px; border-radius: 999px;
  background: var(--fs-blue); color: #fff;
  font: 700 var(--fs-foot)/1 var(--fs-font-latin);
}
.slide .ui-badge.is-mute { background: rgba(255,255,255,0.16); color: var(--fs-text-72); }

/* ---- Progress bar ---- */

.slide .ui-progress {
  display: block; width: 100%; height: 4px;
  background: rgba(255,255,255,0.08); border-radius: 999px;
  overflow: hidden;
}
.slide .ui-progress > .bar {
  display: block; height: 100%;
  background: var(--fs-grad-keyline);
  width: var(--ui-progress, 0%);
}

/* ============================================================================
   RICHNESS PRIMITIVES (v1.3) — production-grade visual + interaction layer
   ============================================================================
   Every class below was promoted from the deck_v3_feishu reference build, the
   highest-fidelity hand-built feishu deck the team has shipped. The design rule
   the v3 author followed: *never strand a card on a flat dark canvas — give it
   gradient depth, hover affordance, and a typographic anchor.* These primitives
   make that achievable in markup-only edits to a deck, no per-deck CSS.
   All declarations use --fs-* tokens; no new colors introduced. Every selector
   nests under .slide so they can't leak into the host page or .deck-ui chrome.
   ============================================================================ */

/* .grid-bg REMOVED in v1.3.8.
   The 飞书 master's lark-content-bg.jpg already provides the only ambient
   texture content pages should have. Adding a CSS dot-grid on top creates
   double-noise off-master. Class kept resolvable as a no-op for legacy
   deck markup (decks that still ship `<div class="grid-bg"></div>` will
   render nothing instead of double-rendering the grid). */
.slide .grid-bg { display: none; }

/* Card hover affordance — color + glow only. NEVER translate.
   Why no `transform: translateY(...)`: the transformed box is what the
   browser hit-tests, so a -4px lift moves the card's hit area up by 4px.
   If the cursor is near the bottom edge, it falls outside the lifted card
   → :hover off → card returns → cursor inside again → infinite hover loop
   producing visible flicker. Background + border-color changes are stable
   because they don't move the hit area. The 0 0 0 glow ring is allowed
   under R12 (no-shadow) since spread+blur=0 isn't a drop shadow. */
.slide .card {
  transition:
    background 0.35s cubic-bezier(0.4, 0, 0.2, 1),
    border-color 0.35s cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide .card:hover {
  background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.01));
  border-color: color-mix(in srgb, var(--fs-blue) 45%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-blue) 35%, transparent);
}

/* Tile gradient enhancement — replaces flat tinted square with blue→violet
   gradient + accent border. Uses the existing .tile selector so every card
   that already has <div class="tile"> picks this up automatically. */
.slide .card .tile {
  background:
    linear-gradient(135deg,
      color-mix(in srgb, var(--fs-blue) 22%, transparent),
      color-mix(in srgb, var(--fs-purple) 14%, transparent));
  border-color: color-mix(in srgb, var(--fs-blue) 40%, transparent);
  color: var(--fs-blue);
}

/* Card .num typographic anchor — promote to 36px 700 (was inheriting smaller).
   Matches the v3 reference where number+icon header creates a strong eyebrow. */
.slide .card .head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.slide .card .num {
  font: 700 var(--fs-sub)/1 var(--fs-font-cjk);
  color: var(--fs-text-48);  /* was --fs-text-50 (undefined, silent fallback to currentColor) */
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}

/* ----------------------------------------------------------------------------
   .pullquote — single-line emphasis row, used to seal a slide's argument.
   Place AFTER the body grid. Default left-bar is teal; .is-orange flips it.
   ---------------------------------------------------------------------------- */
.slide .pullquote {
  margin-top: 24px;
  padding: 14px 0 14px 28px;
  border-left: 4px solid var(--fs-teal);
  font: 600 var(--fs-sub)/1.45 var(--fs-font-cjk);
  color: var(--fs-text);
  max-width: 1400px;
}
.slide .pullquote.is-orange { border-left-color: var(--fs-orange); }
.slide .pullquote.is-blue   { border-left-color: var(--fs-blue);   }
.slide .pullquote.is-violet { border-left-color: var(--fs-violet); }

/* ----------------------------------------------------------------------------
   .voice-card — customer-quote testimonial card. Drop into a content-3up grid
   instead of .card when the cell is a verbatim customer voice.
   ---------------------------------------------------------------------------- */
.slide .voice-card {
  position: relative;
  background: linear-gradient(160deg, rgba(255,255,255,0.05), rgba(255,255,255,0.01));
  border: 1px solid var(--fs-hairline);
  border-radius: var(--fs-radius-card);
  padding: 36px 32px;
  display: flex; flex-direction: column; gap: 18px;
  min-width: 0;
}
.slide .voice-card::before {
  content: "\201C";
  position: absolute; top: 6px; left: 24px;
  font: var(--fs-title)/1 Georgia, serif;
  color: var(--fs-blue);
  opacity: 0.45;
}
.slide .voice-card .q {
  font: 500 var(--fs-body)/1.7 var(--fs-font-cjk);
  color: var(--fs-text);
  margin-top: 36px;
}
.slide .voice-card .who {
  /* 2026-05-16: 18 → 22 — customer/attribution name MUST be legible on
     projector; this is content, not chrome. Use .attrib (14 px chrome)
     for anonymous attribution like "anonymous source · 2024 industry data". */
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk);
  color: var(--fs-text-72);
  letter-spacing: 0.04em;
  border-top: 1px dashed var(--fs-hairline);
  padding-top: 16px;
  margin-top: auto;
}

/* ----------------------------------------------------------------------------
   .cta-box / .cta-btn — call-to-action strip, typically the LAST element on
   slides where you want the user to act (CTA, deep dive, ROI commitment).
   .is-teal variant for "promise / commitment" framing.
   ---------------------------------------------------------------------------- */
.slide .cta-box {
  margin-top: 24px;
  padding: 28px 36px;
  border-radius: var(--fs-radius-card);
  background: linear-gradient(120deg, rgba(60,127,255,0.16), rgba(51,214,192,0.08));
  border: 1px solid color-mix(in srgb, var(--fs-blue) 35%, transparent);
  display: flex; align-items: center; gap: 28px; justify-content: space-between;
}
.slide .cta-box.is-teal {
  background: linear-gradient(120deg, rgba(51,214,192,0.18), rgba(60,127,255,0.08));
  border-color: color-mix(in srgb, var(--fs-teal) 40%, transparent);
}
.slide .cta-box .l h3 {
  font: 700 var(--fs-sub)/1.3 var(--fs-font-cjk);
  color: var(--fs-text);
  margin-bottom: 8px;
}
.slide .cta-box .l p {
  /* 2026-05-16: 20 → 22 — CTA explanation body per SKILL.md body floor. */
  font: 500 var(--fs-body)/1.55 var(--fs-font-cjk);
  color: var(--fs-text-72);
}
.slide .cta-box .cta-btn {
  /* 2026-05-16: 20 → 22 — CTA button label is the slide's emphasis,
     must be legible on projector. */
  flex-shrink: 0;
  padding: 16px 32px;
  border: 1px solid color-mix(in srgb, var(--fs-blue) 55%, transparent);
  border-radius: var(--fs-radius-pill);
  background: var(--fs-text);
  color: var(--fs-bg-1);  /* was --fs-bg (undefined, silent fallback to currentColor → invisible on white bg) */
  font: 700 var(--fs-body)/1 var(--fs-font-cjk);
  letter-spacing: 0.04em;
  cursor: pointer;
  transition:
    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* No transform: scale() on hover — same flicker pattern as the .card lift,
   the scaled hit-area can fall away from the cursor → unhover loop. Use
   color + glow ring instead (R12-allowed since spread+blur=0). */
.slide .cta-box .cta-btn:hover {
  background: #fff;
  border-color: var(--fs-blue);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--fs-blue) 30%, transparent);
}

/* ----------------------------------------------------------------------------
   .kpi-strip — small metric-card row beneath body.
   Set --strip-cols to override default 3-col layout.
   ---------------------------------------------------------------------------- */
.slide .kpi-strip {
  display: grid;
  grid-template-columns: repeat(var(--strip-cols, 3), 1fr);
  gap: 18px;
  margin-top: 22px;
}
.slide .kpi-strip .kpi {
  background: rgba(255,255,255,0.03);
  border: 1px solid var(--fs-hairline);
  border-radius: 14px;
  padding: 22px;
  text-align: center;
}
.slide .kpi-strip .kpi .v {
  font: 800 var(--fs-sub)/1.1 var(--fs-font-cjk);
  color: var(--fs-text);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}
.slide .kpi-strip .kpi .v.is-teal   { color: var(--fs-teal);   }
.slide .kpi-strip .kpi .v.is-blue   { color: var(--fs-blue);   }
.slide .kpi-strip .kpi .v.is-orange { color: var(--fs-orange); }
.slide .kpi-strip .kpi .l {
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk);
  color: var(--fs-text-72);
  margin-top: 6px;
}

/* ----------------------------------------------------------------------------
   .calc — interactive ROI / pricing calculator widget (sliders + result).
   Use range inputs whose `value` attribute drives both display and aria.
   ---------------------------------------------------------------------------- */
.slide .calc {
  background: linear-gradient(135deg, rgba(60,127,255,0.10), rgba(51,214,192,0.05));
  border: 1px solid color-mix(in srgb, var(--fs-blue) 30%, transparent);
  border-radius: var(--fs-radius-card);
  padding: 36px 40px;
  display: flex; flex-direction: column; gap: 14px;
}
.slide .calc-row {
  display: grid;
  grid-template-columns: 280px 1fr 160px;
  align-items: center;
  gap: 24px;
  padding: 18px 0;
  border-bottom: 1px dashed var(--fs-hairline);
}
.slide .calc-row:last-of-type { border-bottom: 0; }
.slide .calc-row label {
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk);
  color: var(--fs-text-72);
}
.slide .calc-row input[type="range"] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 6px;
  background: rgba(255,255,255,0.10);
  border-radius: 3px;
  outline: none;
}
.slide .calc-row input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 24px; height: 24px;
  border-radius: 50%;
  cursor: pointer;
  background: var(--fs-grad-keyline);
  box-shadow: 0 0 0 4px rgba(60,127,255,0.30);   /* glow ring (allowed by R12) */
}
.slide .calc-row input[type="range"]::-moz-range-thumb {
  width: 24px; height: 24px;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  background: var(--fs-blue);
  box-shadow: 0 0 0 4px rgba(60,127,255,0.30);
}
.slide .calc-row .v {
  font: 700 var(--fs-body)/1 var(--fs-font-cjk);
  color: var(--fs-teal);
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.slide .calc-result {
  margin-top: 14px;
  padding: 28px 32px;
  border-radius: 14px;
  background: linear-gradient(135deg, rgba(51,214,192,0.18), rgba(60,127,255,0.10));
  border: 1px solid color-mix(in srgb, var(--fs-teal) 40%, transparent);
  display: flex; justify-content: space-between; align-items: center; gap: 24px;
}
.slide .calc-result .lbl {
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk);
  color: var(--fs-text);
  letter-spacing: 0.04em;
}
.slide .calc-result .amount {
  font: 800 var(--fs-title)/1 var(--fs-font-cjk);
  background: var(--fs-grad-keyline);
  -webkit-background-clip: text; background-clip: text;
  color: transparent;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
}
.slide .calc-hint {
  font: italic 500 var(--fs-foot)/1.55 var(--fs-font-cjk);
  color: var(--fs-text-48);  /* was --fs-text-50 (undefined, silent fallback) */
  letter-spacing: 0.04em;
}

/* ----------------------------------------------------------------------------
   .ui-row / .ui-alert / .ui-kpi — dashboard-mock primitives that go INSIDE a
   .ui-window .ui-body. Re-creates spreadsheet-style data lines, alert banners,
   and metric tiles without raster screenshots.
   ---------------------------------------------------------------------------- */
.slide .ui-row {
  display: flex; justify-content: space-between; align-items: center;
  padding: 14px 18px;
  background: rgba(255,255,255,0.03);
  border-radius: 12px;
  font: 500 var(--fs-body)/1.4 var(--fs-font-cjk);
  color: var(--fs-text-72);
}
.slide .ui-row .lbl { color: var(--fs-text-72); }
.slide .ui-row .val {
  font-weight: 700;
  color: var(--fs-text);
  font-variant-numeric: tabular-nums;
}
.slide .ui-row .val.up { color: var(--fs-teal);   }
.slide .ui-row .val.dn { color: var(--fs-orange); }

.slide .ui-alert {
  padding: 18px 20px;
  border-radius: 12px;
  background: linear-gradient(135deg, rgba(254,127,0,0.12), rgba(254,127,0,0.02));
  border: 1px solid color-mix(in srgb, var(--fs-orange) 35%, transparent);
}
.slide .ui-alert .t {
  font: 700 var(--fs-foot)/1 var(--fs-font-cjk);
  letter-spacing: 0.18em;
  color: var(--fs-orange);
  margin-bottom: 8px;
}
.slide .ui-alert h5 {
  font: 700 var(--fs-body)/1.3 var(--fs-font-cjk);
  color: var(--fs-text);
  margin-bottom: 6px;
}
.slide .ui-alert p {
  font: 500 var(--fs-foot)/1.55 var(--fs-font-cjk);
  color: var(--fs-text-72);
}

.slide .ui-kpi {
  padding: 18px 20px;
  border-radius: 12px;
  background: linear-gradient(135deg, rgba(60,127,255,0.14), rgba(60,127,255,0.02));
  border: 1px solid color-mix(in srgb, var(--fs-blue) 35%, transparent);
}
.slide .ui-kpi .t {
  font: 700 var(--fs-foot)/1 var(--fs-font-cjk);
  letter-spacing: 0.18em;
  color: var(--fs-blue);
  margin-bottom: 8px;
}
.slide .ui-kpi .v {
  font: 800 var(--fs-sub)/1 var(--fs-font-cjk);
  color: var(--fs-text);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}
.slide .ui-kpi .d {
  font: 600 var(--fs-foot)/1.4 var(--fs-font-cjk);
  color: var(--fs-teal);
  margin-top: 4px;
}
.slide .ui-kpi.is-teal {
  background: linear-gradient(135deg, rgba(51,214,192,0.16), rgba(51,214,192,0.02));
  border-color: color-mix(in srgb, var(--fs-teal) 40%, transparent);
}
.slide .ui-kpi.is-teal .t { color: var(--fs-teal); }

/* ----------------------------------------------------------------------------
   .data-panel — brand-aligned "report block" container for showing structured
   data (lists, status rows, KPI summaries, callouts). USE THIS by default;
   reach for `.ui-window` only when actually mocking a macOS desktop app.
   Distinguishing visuals: 4 px side-accent bar (blue / teal / violet),
   inset top highlight, gradient keyline under the heading. NO traffic lights.
   ---------------------------------------------------------------------------- */
.slide .data-panel {
  background: rgba(10, 18, 48, 0.55);
  border: 1px solid rgba(60, 127, 255, 0.15);
  border-left: 4px solid var(--fs-blue);
  border-radius: 14px;
  padding: 30px 34px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
  display: flex;
  flex-direction: column;
  gap: 18px;
}
.slide .data-panel > h4 {
  font: 600 var(--fs-body)/1.3 var(--fs-font-cjk);
  color: var(--fs-text);
  letter-spacing: 0.02em;
  margin: 0;
}
.slide .data-panel > hr {
  height: 1px;
  background: linear-gradient(to right,
    color-mix(in srgb, var(--fs-blue) 60%, transparent), transparent);
  margin: 0;
  border: 0;
}
/* Lightweight in-panel row — flatter than .ui-row (no pill background).
   Use when the panel itself already provides the framing.  */
.slide .data-panel .row {
  display: flex;
  gap: 18px;
  align-items: baseline;
  font: 500 var(--fs-body)/1.45 var(--fs-font-cjk);
  color: var(--fs-text);
}
.slide .data-panel .row::before {
  content: "▸";
  color: var(--fs-blue);
  font-size: var(--fs-foot);
  flex-shrink: 0;
}
.slide .data-panel .row .lbl { flex: 1;          color: var(--fs-text); }
.slide .data-panel .row .val { flex-shrink: 0;   color: var(--fs-teal); font-weight: 600; }
.slide .data-panel .row .val.warn,
.slide .data-panel .row .val.dn  { color: var(--fs-orange); }
.slide .data-panel .row .val.up  { color: var(--fs-teal);   }

/* Tonal variants — opt-in via .is-teal / .is-violet for differentiation
   when multiple data-panels coexist on a slide.                         */
.slide .data-panel.is-teal {
  border-left-color: var(--fs-teal);
  border-color: rgba(51, 214, 192, 0.15);
}
.slide .data-panel.is-teal > hr {
  background: linear-gradient(to right,
    color-mix(in srgb, var(--fs-teal) 60%, transparent), transparent);
}
.slide .data-panel.is-teal .row::before { color: var(--fs-teal); }

.slide .data-panel.is-violet {
  border-left-color: #5C3FFB;
  border-color: rgba(92, 63, 251, 0.15);
}
.slide .data-panel.is-violet > hr {
  background: linear-gradient(to right, rgba(92, 63, 251, 0.6), transparent);
}
.slide .data-panel.is-violet .row::before { color: #5C3FFB; }

/* ----------------------------------------------------------------------------
   .ui-wave — animated audio waveform, for INPUT side of a transform widget.
   10 bars driven by staggered keyframe so the wave undulates left→right.
   ---------------------------------------------------------------------------- */
.slide .ui-wave {
  display: flex; align-items: flex-end; justify-content: center;
  gap: 4px;
  height: 80px;
  margin: 18px 0;
}
.slide .ui-wave i {
  width: 6px;
  background: var(--fs-grad-keyline);
  border-radius: 3px;
  animation: fs-wave 1.4s ease-in-out infinite;
}
.slide .ui-wave i:nth-child(1)  { height: 25%; }
.slide .ui-wave i:nth-child(2)  { height: 55%; animation-delay: 0.10s; }
.slide .ui-wave i:nth-child(3)  { height: 85%; animation-delay: 0.20s; }
.slide .ui-wave i:nth-child(4)  { height: 40%; animation-delay: 0.30s; }
.slide .ui-wave i:nth-child(5)  { height: 70%; animation-delay: 0.40s; }
.slide .ui-wave i:nth-child(6)  { height: 95%; animation-delay: 0.50s; }
.slide .ui-wave i:nth-child(7)  { height: 50%; animation-delay: 0.60s; }
.slide .ui-wave i:nth-child(8)  { height: 30%; animation-delay: 0.70s; }
.slide .ui-wave i:nth-child(9)  { height: 75%; animation-delay: 0.80s; }
.slide .ui-wave i:nth-child(10) { height: 45%; animation-delay: 0.90s; }
@keyframes fs-wave {
  0%, 100% { opacity: 0.4; }
  50%      { opacity: 1;   }
}

/* ----------------------------------------------------------------------------
   .north-star-map — N-up survey of parallel projects / initiatives.
   Each card carries a per-card accent color, an idx numeral, a single h4 title,
   the project's North-Star metric, the core sellable, and 3 tag chips listing
   sub-capabilities. Optional `.is-hero` modifier highlights the lead card with
   a thicker accent border. Pattern L · DESIGN.md §9-L.
   ---------------------------------------------------------------------------- */
.slide .north-star-map {
  display: grid;
  grid-template-columns: repeat(var(--cols, 5), 1fr);
  gap: 14px;
  align-self: center;        /* vertical center inside .stage */
}
.slide .ns-card {
  background: rgba(15, 22, 50, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 14px;
  padding: 22px 20px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  position: relative;
}
.slide .ns-card.is-hero {
  border-width: 2px;
  border-color: var(--fs-blue);
  box-shadow: 0 0 0 4px rgba(60, 127, 255, 0.10);
}
.slide .ns-card .idx {
  font: 800 var(--fs-title)/1 var(--fs-font-cjk);
  letter-spacing: -0.02em;
  color: var(--fs-blue);
}
.slide .ns-card h4 {
  font: 700 var(--fs-sub)/1.3 var(--fs-font-cjk);
  color: var(--fs-text);
  margin: 0;
}
.slide .ns-card .star-label,
.slide .ns-card .core-label {
  font: 700 var(--fs-foot)/1 var(--fs-font-cjk);
  letter-spacing: 0.18em;
  color: var(--fs-text-72);
  margin-top: 4px;
}
.slide .ns-card .star {
  font: 700 var(--fs-body)/1.3 var(--fs-font-cjk);
  color: var(--fs-teal);
}
.slide .ns-card .core {
  font: 600 var(--fs-foot)/1.4 var(--fs-font-cjk);
  color: var(--fs-text);
}
.slide .ns-card .tags {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: auto;
  padding-top: 12px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.slide .ns-card .tag-chip {
  font: 600 var(--fs-foot)/1.3 var(--fs-font-cjk);
  padding: 6px 10px;
  border-radius: 7px;
  border: 1px solid rgba(255, 255, 255, 0.10);
  background: rgba(255, 255, 255, 0.03);
  text-align: center;
  color: var(--fs-blue);    /* default — overridden by tonal variant */
}
/* Tonal variants — accent the idx numeral, the star value, and the chips. */
.slide .ns-card.is-blue   .idx,
.slide .ns-card.is-blue   .tag-chip { color: var(--fs-blue);   }
.slide .ns-card.is-violet .idx,
.slide .ns-card.is-violet .tag-chip { color: #5C3FFB;          }
.slide .ns-card.is-teal   .idx,
.slide .ns-card.is-teal   .tag-chip { color: var(--fs-teal);   }
.slide .ns-card.is-purple .idx,
.slide .ns-card.is-purple .tag-chip { color: #9F6FF1;          }
.slide .ns-card.is-orange .idx,
.slide .ns-card.is-orange .tag-chip { color: var(--fs-orange); }
.slide .ns-card.is-violet.is-hero { border-color: #5C3FFB; box-shadow: 0 0 0 4px rgba(92, 63, 251, 0.10); }
.slide .ns-card.is-teal.is-hero   { border-color: var(--fs-teal);   box-shadow: 0 0 0 4px rgba(51, 214, 192, 0.10); }
.slide .ns-card.is-purple.is-hero { border-color: #9F6FF1; box-shadow: 0 0 0 4px rgba(159, 111, 241, 0.10); }
.slide .ns-card.is-orange.is-hero { border-color: var(--fs-orange); box-shadow: 0 0 0 4px rgba(254, 127, 0, 0.10); }

/* ----------------------------------------------------------------------------
   .scene-grid / .scene-card — adjacency grid showing how one principle applies
   across N domains, with a quantified economic lever per scene. Default 3×2 = 6
   scenes; override grid via `style="--cols:4"` etc. The lever uses dashed outline
   + a bold <em> for the impact number — that's the rhetorical hook. Pattern M.
   ---------------------------------------------------------------------------- */
.slide .scene-grid {
  display: grid;
  grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
  gap: 14px;
  align-self: center;
}
.slide .scene-card {
  background: rgba(15, 22, 50, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 14px;
  padding: 22px 22px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  position: relative;
  overflow: hidden;
  /* `color` is set by the tonal variant below; ::before reads it via currentColor. */
  color: var(--fs-blue);
}
.slide .scene-card::before {
  content: "";
  position: absolute;
  left: 0; top: 0;
  height: 3px; width: 100%;
  background: currentColor;
  opacity: 0.85;
}
.slide .scene-card .sc-icon {
  width: 42px; height: 42px;
  border-radius: 10px;
  display: flex; align-items: center; justify-content: center;
  background: rgba(255, 255, 255, 0.04);
  flex-shrink: 0;
  margin-top: 4px;
}
.slide .scene-card .sc-icon svg {
  width: 22px; height: 22px;
  stroke: currentColor;
  fill: none;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.slide .scene-card .sc-top {
  display: flex; align-items: center; gap: 12px;
}
.slide .scene-card .sc-name {
  font: 700 var(--fs-body)/1.2 var(--fs-font-cjk);
  color: var(--fs-text);
  letter-spacing: -0.005em;
}
.slide .scene-card hr,
.slide .scene-card .sc-divider {
  height: 1px;
  background: rgba(255, 255, 255, 0.10);
  margin: 4px 0;
  border: 0;
}
.slide .scene-card .sc-label {
  font: 700 var(--fs-foot)/1 var(--fs-font-cjk);
  letter-spacing: 0.18em;
  color: currentColor;
}
.slide .scene-card .sc-obj {
  /* 2026-05-16: 18 → 22 — scene-card "个性化对象" body line per body floor.
     The 6-up grid is dense, but 18 px reads as fine print on projector. */
  font: 600 var(--fs-body)/1.45 var(--fs-font-cjk);
  color: var(--fs-text);
}
.slide .scene-card .sc-lever {
  /* 2026-05-16: 16 → 22 — scene-card economic-lever line is the rhetorical
     hook per SKILL.md scene-grid recipe; MUST be legible. */
  font: 500 var(--fs-body)/1.45 var(--fs-font-cjk);
  color: var(--fs-text-72);
  padding: 10px 12px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.025);
  border: 1px dashed rgba(255, 255, 255, 0.12);
  margin-top: auto;
}
.slide .scene-card .sc-lever em {
  font-style: normal;
  font-weight: 800;
  color: var(--fs-text);
}
/* Tonal variants — recolor the top accent bar, icon, and label. */
.slide .scene-card.is-blue   { color: var(--fs-blue);   }
.slide .scene-card.is-violet { color: #5C3FFB;          }
.slide .scene-card.is-teal   { color: var(--fs-teal);   }
.slide .scene-card.is-purple { color: #9F6FF1;          }
.slide .scene-card.is-orange { color: var(--fs-orange); }

/* ----------------------------------------------------------------------------
   .report-item — tagged finding row for the OUTPUT side of a transform widget.
   Default = teal (做得好). .is-warn = orange (漏关键). .is-info = blue (建议).
   ---------------------------------------------------------------------------- */
.slide .report-item {
  padding: 14px 18px;
  background: rgba(255,255,255,0.04);
  border-left: 3px solid var(--fs-teal);
  border-radius: 8px;
  font: 500 var(--fs-body)/1.55 var(--fs-font-cjk);
  color: var(--fs-text-72);
  display: flex; align-items: flex-start; gap: 14px;
}
.slide .report-item .tag {
  flex: none;
  display: inline-block;
  padding: 3px 10px;
  border-radius: 6px;
  background: rgba(51,214,192,0.15);
  color: var(--fs-teal);
  font: 700 var(--fs-foot)/1.3 var(--fs-font-cjk);
  letter-spacing: 0.06em;
}
.slide .report-item.is-warn { border-left-color: var(--fs-orange); }
.slide .report-item.is-warn .tag { background: rgba(254,127,0,0.15);  color: var(--fs-orange); }
.slide .report-item.is-info { border-left-color: var(--fs-blue);   }
.slide .report-item.is-info .tag { background: rgba(60,127,255,0.15); color: var(--fs-blue);   }
.slide .report-item b { color: var(--fs-text); font-weight: 700; }

/* ----------------------------------------------------------------------------
   Process step arrow — chevron between cards.
   The shipped flow already has spacing; this draws the connection.
   Auto-hides on the last step and on data-variant="vertical".
   ---------------------------------------------------------------------------- */
.slide[data-layout="process"] .step {
  position: relative;
}
.slide[data-layout="process"] .step::after {
  content: "";
  position: absolute;
  right: -14px; top: 50%;
  width: 24px; height: 24px;
  background: var(--fs-blue);
  clip-path: polygon(0 30%, 55% 30%, 55% 0, 100% 50%, 55% 100%, 55% 70%, 0 70%);
  transform: translateY(-50%);
  z-index: 2;
  pointer-events: none;
}
.slide[data-layout="process"] .step:last-child::after,
.slide[data-layout="process"][data-variant="vertical"] .step::after { display: none; }


/* ============================================================================
   v1.4 layout fixes — 2026-05-02
   Four production-deck regressions caught in the 数字员工指南 deck (杰森
   2026-04-28 transcript). Each fix maps to one user-visible bug; comment
   names the rule id (BF1-BF4) so the validator can reference them later.
   ============================================================================ */

/* BF1 — big-stat short numerals hugging the canvas left edge.
   The .stage uses a 1fr | 1fr grid and .num lives in the LEFT cell, defaulting
   to justify-self: stretch / text-align: left. With single-character values
   ("5", "3"), the digit sits at the very left padding (96px from slide edge)
   and looks orphaned.

   v2 (2026-05-03) — v1 used justify-self: end / text-align: right. That
   right-anchored the numeral against the slide centerline, which made it
   feel jammed up against the .copy block on the right side. v2 uses
   center alignment instead, sitting the numeral in the visual center of
   its left half-canvas. Authors are encouraged to use multi-character
   values (e.g. "30 → 5" instead of bare "5") so the cell fills naturally
   AND the comparison is visible in one glance. */
.slide[data-layout="big-stat"] .num {
  justify-self: center;
  text-align: center;
}

/* BF2 — .col-visual double-frame when carrying a self-decorated child.
   The .col-visual default frame (1px hairline + 16px radius + faint
   gradient) is meant for raw image / mock placeholders. Putting a
   self-framed component inside (data-panel, ui-window, kpi-strip,
   scene-grid, north-star-map, calc, ui-kpi) renders an outer "browser
   chrome" wrapper around the inner panel — the user reads it as visual
   noise. When such a child is detected, strip the wrapper frame so only
   the inner component's decoration shows. */
.slide[data-layout="content-2col"] .col-visual:has(> .data-panel),
.slide[data-layout="content-2col"] .col-visual:has(> .ui-window),
.slide[data-layout="content-2col"] .col-visual:has(> .kpi-strip),
.slide[data-layout="content-2col"] .col-visual:has(> .scene-grid),
.slide[data-layout="content-2col"] .col-visual:has(> .north-star-map),
.slide[data-layout="content-2col"] .col-visual:has(> .calc),
.slide[data-layout="content-2col"] .col-visual:has(> .ui-kpi) {
  border: none !important;
  background: none !important;
  border-radius: 0 !important;
  padding: 0 !important;
  overflow: visible !important;
}

/* BF3 — helpers (.scene-grid / .north-star-map) compressed in middle of stage.
   These helpers default to align-self: center and take only their content's
   natural height. Inside a layout's .stage flex column, this leaves visible
   empty space top + bottom when the cell count is small (e.g. 4 cards at
   --cols:2 = 2x2). Override so they span full stage width.

   v2 (2026-05-02 PM) — v1 also added `flex: 1; align-content: stretch` to
   force cards to fill the stage vertically. That overshot: ns-cards at
   --cols:5 stretched to ~750px tall while content was only ~400px,
   leaving giant empty borders, and `.tags` (which uses margin-top: auto)
   ended up jammed against the bottom border, looking like the border was
   "blocking" the text. v2 drops the vertical stretch and instead bumps
   card padding + grid gap when the helper is dominant — cards stay
   content-sized but feel naturally richer, so the grid occupies more of
   the canvas without empty interiors. */
.slide > .stage > .scene-grid,
.slide > .stage > .north-star-map {
  align-self: stretch;          /* full horizontal width */
  width: 100%;
}
/* When the helper is the major body block (alone, or paired only with a
   trailing pullquote / lede), bump card padding + grid gap
   so the visual mass spreads across the canvas naturally. NO stretch on
   the grid or the cards — content-sized cards keep .tags / lever items
   close to their content rather than against the bottom border. */
.slide > .stage > .scene-grid:only-child,
.slide > .stage > .scene-grid:first-child:nth-last-child(-n+2) {
  gap: 24px;
}
.slide > .stage > .scene-grid:only-child .scene-card,
.slide > .stage > .scene-grid:first-child:nth-last-child(-n+2) .scene-card {
  padding: 32px 28px;
}
.slide > .stage > .north-star-map:only-child,
.slide > .stage > .north-star-map:first-child:nth-last-child(-n+2) {
  gap: 18px;
}
.slide > .stage > .north-star-map:only-child .ns-card,
.slide > .stage > .north-star-map:first-child:nth-last-child(-n+2) .ns-card {
  padding: 28px 22px;
}

/* BF4 — pullquote left-bar pulls text 32px right of grid edge.
   A pullquote placed below a body grid (.grid / .scene-grid /
   .north-star-map) inherits padding-left: 28px + border-left: 4px,
   which shifts its text 32px to the right of the grid's left edge.
   Visually the pullquote text appears INDENTED relative to the cards
   above — readers register it as misaligned. Pull the bar OUTSIDE
   the text column with negative margin so the text-left aligns with
   the grid's left edge while the bar still reads as decoration. */
.slide > .stage > .pullquote,
.slide > .stage > * + .pullquote {
  margin-left: -32px;           /* compensate for border + padding */
}

/* BF5 — macOS traffic-lights (red/yellow/green dots) hidden by default
   in 飞书 enterprise decks. The dots make any .ui-window mock read as a
   casual macOS screenshot rather than a brand-aligned data panel — too
   consumer/playful for 汇报 / 客户提案 contexts. The .ui-titlebar without
   the dots still reads as a "window header bar", which is enough chrome
   for an app-mock context (e.g. mocking a Lark Base 多维表格 view).
   To opt back in for a genuine macOS-aesthetic real-screenshot mock,
   add `data-show-chrome` on the parent .ui-window. */
.slide .ui-window:not([data-show-chrome]) .ui-traffic-lights {
  display: none;
}

/* BF7 — content-2col with hero image: align top AND bottom.
   When .col-visual carries an inline min-height directive (the typical
   "16:9 reference scene at 600px" hero pattern), CSS Grid's
   align-items: stretch makes both columns the same row height, but
   .col-text packs content at the top with default flex-start, leaving
   empty space below. Distribute children with space-between so the
   first child aligns with image top, the last with image bottom. */
.slide[data-layout="content-2col"] .grid:has(> .col-visual[style*="min-height"]) > .col-text {
  justify-content: space-between;
}

/* BF8 — chart container shrinks under flex-column .stage pressure, but
   inner Grid `.bars` doesn't (Grid items are intrinsically sized). The
   bars overflow downward, dropping below the X-axis line drawn at chart's
   `bottom: <padding>`. Reported on P06 ARR chart (2026-05-08).

   Defense: lock common chart classes from shrinking. Authors writing a
   new chart class should follow this pattern (or use one of these
   class names). The cost — when chart + other stage block exceed stage
   height, the OTHER block shrinks instead, which is fine for
   tables/lists/KPI strips. */
.slide .stage > .arr-chart,
.slide .stage > .store-chart,
.slide .stage > .bar-chart {
  flex-shrink: 0;
}

/* BF6 — .ui-grid (Lark Base / spreadsheet mock) defaulting to fixed-px
   columns sits at intrinsic width and clusters at the left of its parent.
   Reported as "内容都在一头" (content stuck on one side) on a sales-table
   slide where columns were `130px 90px 80px 70px` (370px total) inside an
   864px col-visual.

   Defense: every .ui-grid stretches to fill its container by default.
   Authors can keep the relative column proportions by switching to fr
   units (`grid-template-columns: 1.5fr 1fr 1fr 0.8fr`); the absolute
   widths get redistributed proportionally. If a deck legitimately needs
   a narrow content-sized table (e.g. left-aligned key-value box),
   override `.ui-grid { width: max-content; }` inline.

   The flex stretching also propagates correctly through .ui-window's
   flex column — the grid takes the row width that .ui-window provides. */
.slide .ui-grid {
  width: 100%;
  align-self: stretch;
}

/* ============================================================================
   Print (export) hint
   ============================================================================ */
@media print {
  .deck-ui { display: none !important; }
  .deck { display: block !important; }
  .slide-frame { page-break-after: always; }
}

/* ============================================================================
   Mobile UX (≤900px) — tap-to-enlarge + swipe nav
   ----------------------------------------------------------------------------
   Root cause this fixes: on mobile, auto mode = 'scroll' → each slide-frame is
   100% viewport width with aspect-ratio 16/9. On a 393×852 phone that's
   ~393×220 — design canvas 1920×1080 scales to ~0.2×, so 22px body text
   becomes ~4.5px on screen. User-reported symptom: "手机端打开就是错乱的".

   Fix philosophy: scroll mode stays as the overview, but tapping any slide
   switches to present mode (slide fills viewport) for that one frame. From
   there a left/right swipe paginates; tap "← 返回列表" returns to scroll.
   Pair with the .js handlers (init() bottom block).
   ============================================================================ */
@media (max-width: 900px) {
  /* Hint on each scroll-mode frame that tapping enlarges it. */
  .deck[data-mode="scroll"] .slide-frame { cursor: pointer; }
  .deck[data-mode="scroll"] .slide-frame::after {
    content: "点击放大";
    position: absolute; right: 10px; bottom: 10px;
    z-index: 50;
    background: rgba(60,127,255,0.92); color: #fff;
    font: 700 11px/1 -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
    padding: 5px 10px; border-radius: 999px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.5);
    pointer-events: none;
    letter-spacing: 0.04em;
  }
  /* Mobile present-mode chrome: back button + page indicator. */
  .fs-mobile-back {
    position: fixed; top: max(10px, env(safe-area-inset-top));
    left: max(10px, env(safe-area-inset-left));
    z-index: 1001;
    background: rgba(8,12,24,0.85); color: #fff;
    font: 700 13px/1 -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
    padding: 9px 14px; border-radius: 999px;
    border: 1px solid rgba(255,255,255,0.20);
    backdrop-filter: blur(8px);
    box-shadow: 0 4px 12px rgba(0,0,0,0.5);
    display: none;
    cursor: pointer;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
  }
  .fs-mobile-pageno {
    position: fixed; bottom: max(14px, env(safe-area-inset-bottom));
    left: 50%; transform: translateX(-50%);
    z-index: 1001;
    background: rgba(8,12,24,0.85); color: #fff;
    font: 700 13px/1 -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
    padding: 8px 16px; border-radius: 999px;
    border: 1px solid rgba(255,255,255,0.20);
    backdrop-filter: blur(8px);
    display: none;
    pointer-events: none;
    letter-spacing: 0.05em;
  }
  .deck[data-mode="present"] ~ .fs-mobile-back,
  .deck[data-mode="present"] ~ .fs-mobile-pageno { display: block; }
}

/* ============================================================================
   规范 L3 分布修复 sweep(2026-05-30)— 把 content-3up 的"内容尺寸+居中"
   correct-by-construction 打法推广到其它多卡/多块版式。每条都 scoped 到该版式的
   渲染结构,不动全局 1722 行 legacy stretch;均经 assets/check-distribution.py
   注入测 + 真渲染验证:目标版式不均内容 findings 下降、零回归。
   (content-3up 的那条在 ~778 行;extras 版式 matrix/before-after/logo-wall 在
    deck-json/templates/extra-layouts.css 末尾同名 sweep 块。
    stats 故意不改 —— KPI 列顶基线对齐是正确阅读结构,几何"均衡"反而破坏它。)
   ============================================================================ */

/* content-2col:两列都含 framed box 时 un-stretch,让框共享竖直中线(治左高右低)。
   :not(:has(> .col-visual[min-height])) 守住 BF7 raw hero-image(它靠 stretch 让
   col-text space-between 贴到图片上下沿)。canonical 2col 本就免疫,这是 hardening。 */
.slide[data-layout="content-2col"] .grid:not(:has(> .col-visual[style*="min-height"])) {
  align-items: center;
}

/* process:步骤卡同 content-3up —— 按内容尺寸 + 共享中线 + 卡内垂直居中,
   消除内容最多那张挤底、其余顶锚空一大片。 */
.slide[data-layout="process"] .flow > .step {
  align-self: center;
  justify-content: center;
}

/* ============================================================================
   Presenter mode (speaker view) — PowerPoint/Keynote-style. Built by
   feishu-deck.js setupPresenter(); toggled with 'P'. Audience sees the deck
   (this window behind the overlay, and/or the projector window); the presenter
   sees current + next previews, notes, and a timer.
   ============================================================================ */
.fs-presenter {
  position: fixed; inset: 0;
  z-index: 2147483646;          /* F-374: above any in-slide demo (incl. a demo
                                   that sets its own very high z-index) so the
                                   speaker-view controls stay clickable */
  display: none;                /* JS sets flex */
  flex-direction: column;
  background: #0b0e16;
  color: #e6edf7;
  padding: 20px 22px 14px;
  gap: 14px;
  font: 500 14px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.fs-presenter .pv-grid {
  flex: 1; min-height: 0;
  display: grid;
  grid-template-columns: 1.55fr 1fr;
  gap: 18px;
}
.fs-presenter .pv-col { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.fs-presenter .pv-lab { font-size: 12px; color: #8b93ad; margin: 0 0 6px 2px; letter-spacing: .04em; }
.fs-presenter .pv-notes-lab { margin-top: 14px; }
.fs-presenter .pv-box {
  position: relative;
  width: 100%;
  aspect-ratio: var(--fs-deck-aspect, 1920 / 1080);
  overflow: hidden;
  background: #000;
  border-radius: 10px;
  box-shadow: 0 0 0 1px rgba(255,255,255,.08);
}
.fs-presenter .pv-cur { box-shadow: 0 0 0 2px rgba(58,134,255,.55); }
.fs-presenter .pv-embed { position: absolute; inset: 0; background: linear-gradient(135deg,#1a2238,#0c1222); }
.fs-presenter .pv-notes {
  flex: 1; min-height: 0; overflow-y: auto;
  background: #121726; border: 1px solid #2a3450; border-radius: 8px;
  padding: 12px 14px; font-size: 17px; line-height: 1.6; color: #fff;
  white-space: pre-wrap;
  width: 100%; box-sizing: border-box; resize: none;   /* editable textarea */
  font-family: inherit; outline: none;
}
.fs-presenter .pv-notes:focus { border-color: #3a86ff; }
.fs-presenter .pv-save { background: #2a8c5a; }
.fs-presenter .pv-save:hover { background: #24794d; }
.fs-presenter .pv-bar {
  display: flex; align-items: center; justify-content: center; gap: 14px; flex-wrap: wrap;
}
.fs-presenter .pv-btn {
  border: 0; border-radius: 999px; padding: 9px 16px; font-size: 15px; cursor: pointer;
  background: rgba(255,255,255,.12); color: #fff;
}
.fs-presenter .pv-btn:hover { background: rgba(255,255,255,.2); }
.fs-presenter .pv-proj { background: #3a86ff; }
.fs-presenter .pv-proj:hover { background: #2f74e6; }
.fs-presenter .pv-pos {
  display: inline-flex; align-items: baseline; justify-content: center;
  font-size: 15px; color: #cfe0ff; min-width: 82px; text-align: center;
}
.fs-presenter .pv-page-input {
  width: 2.6ch; min-width: 22px; box-sizing: content-box;
  margin: 0; padding: 4px 3px; border: 1px solid transparent; border-radius: 6px;
  background: transparent; color: #fff; outline: none; text-align: center;
  font: inherit; line-height: 1; font-variant-numeric: tabular-nums;
  appearance: none; -webkit-appearance: none;
  transition: border-color .12s ease, background .12s ease;
}
.fs-presenter .pv-page-input:hover {
  border-color: rgba(255,255,255,.18); background: rgba(255,255,255,.08);
}
.fs-presenter .pv-page-input:focus {
  border-color: #3a86ff; background: rgba(58,134,255,.24);
}
.fs-presenter .pv-page-sep { color: #8b93ad; margin: 0 3px; }
.fs-presenter .pv-timer {
  font-size: 22px; font-variant-numeric: tabular-nums; color: #fff;
  margin-left: 8px; min-width: 64px; text-align: center;
}
