    /* ── 對話訊息：Step-based Conversation（取代 W1 的扁平 margin-top 分層）──
       一個 Step = 一則 AI Question + 一則 User Answer，是版面最小單位、也是 spacing 唯一管理者。
       Step 本身無背景/邊框/陰影（邏輯群組，非卡片），分組感只靠間距。 */
    /* 對話容器：Step 間距唯一管理者，經 gap 統一控制，不靠各訊息 margin 或 :not(:last-child) */
    /* 這裡只負責訊息排版，不再是 scroll container（捲動已交給 #s-input.active/#s-record.active
       本身，見上方）。flex:1 0 auto＝內容短時吃掉輸入列以外的剩餘空間、把輸入列推到畫面底部；
       shrink:0＝內容長到超出 screen 高度時，不准被壓縮，讓 screen 整個捲動，而不是自己內部
       擠出捲軸。 */
    #chat-history, #record-chat-history {
      /* 提成變數是為了讓 .retry-step 能相對它算自己的間距（見本檔末段）。
         改這裡等於改 Step 間距，兩個斷點各一份。 */
      --chat-step-gap: 4.5rem; /* 72px，桌機 */
      display: flex; flex-direction: column; gap: var(--chat-step-gap);
      /* 最後一個 Step 到 textarea 的總距離也要等於 Step 間距：
         3.75rem padding + input-bar 既有 0.75rem padding-top = 4.5rem。 */
      padding-top: 1rem; padding-bottom: 3.75rem;
      flex: 1 0 auto;
    }
    /* 04d Emily 回饋 #6：body 在 chat-fullscreen 撐滿 100vw 後，這裡補回原本的內容欄寬度，
       捲軸因此落在容器（＝視窗）右緣，內容仍維持 --content-w 置中，公式見 html.chat-fullscreen 定義。
       h1／.info-card（見下方兩條，巢狀在 .record-header 裡也一樣命中，descendant selector
       不管深幾層）各自需要同樣的置中，不能共用同一條 selector：.info-card 有背景／邊框，
       得用 margin 讓整張卡片內縮，用 padding 會讓卡片邊框跟著撐到視窗邊緣、只有卡內文字內縮，
       跟等待頁的卡片寬度對不齊。 */
    html.chat-fullscreen #chat-history, html.chat-fullscreen #record-chat-history {
      padding-left: var(--bleed-pad); padding-right: var(--bleed-pad);
    }
    html.chat-fullscreen #s-record h1, html.chat-fullscreen #s-record .info-card {
      margin-left: var(--bleed-pad); margin-right: var(--bleed-pad);
    }
    /* 展開鈕跟 h1／任務卡同一條左緣（只設左右，上距留給 .record-task-toggle 自己） */
    html.chat-fullscreen .record-task-toggle { margin-left: var(--bleed-pad); margin-right: var(--bleed-pad); }
    /* 04d Emily 回饋：紀錄頁標題／任務卡不能隨訊息捲走（founder 明確要求保留這個行為）。
       h1＋.info-card 包進這層，position:sticky;top:0 釘在 #s-record.active 這個 scroll port
       頂端，不隨內容捲動；padding-top:3rem 是原本掛在 #s-record.active 上的頂距搬過來（掛在
       scroll port 自己身上的話，捲一下那段留白就滑走了，標題會被拖著一起消失，跟這裡要的
       「固定」效果矛盾）。background:var(--bg)＋z-index 蓋住從底下捲上來的訊息，不然文字會透出來
       （必須跟 body 同色：只要有一點色差，捲上來的內容會在邊界透出一條，白底時代看不出來、暖底會）。
       flex-shrink:0：跟 #chat-history 同一個道理，內容溢出時整個 screen 捲動，這層不准被壓縮。 */
    /* 長任務名稱爆版修正（手機真機回報）：任務卡長高後，這層 sticky 標題會高過可視區，
       sticky 元素只要比可視區高就永遠釘在頂端捲不走——問題被夾在釘住的標題與釘住的輸入列
       之間，畫面確實有捲動，捲出來的東西卻全被標題蓋著（實測 393×420：標題 314px、輸入列
       84px，問題只剩 22px 縫，捲到底反而整句躲進標題後面）。兩層解法，主從不可對調：
       ① 主線＝預設收合兩行（見 #chosen-label），讓標題區的自然高度本來就矮，上限根本不會生效，
          平時不會出現巢狀捲動框；
       ② 防呆＝下面的 max-height，只在使用者主動展開後才咬得到，超出的部分由卡片自己內捲。
       上限公式把不變量直接寫進算術，不寫死比例：可視高（100% 解析自 #s-record.active 的
       --chat-vh，鍵盤開啟時自動縮）扣掉輸入列實測高（--input-bar-h，syncInputBarHeight 維護）
       再扣 5rem 給對話區（＝訊息區 1rem padding + 至少兩行問句）。外層 max(3rem, …) 是防呆：
       極窄高時 calc 會算成負值，負的 max-height 是無效宣告、整條規則會被丟掉、上限反而消失。
       保證範圍：可視高 ≥ 350px（iPhone SE 開鍵盤的實測量級）時，收合態標題必定低於上限、
       問題區至少 5rem；更矮時降級成 3rem 下限＋卡片內捲，不再保證兩行。 */
    .record-header {
      position: sticky; top: 0; z-index: 1;
      background: var(--bg); flex-shrink: 0;
      padding-top: 3rem;
      display: flex; flex-direction: column; min-height: 0;
      max-height: max(3rem, calc(100% - var(--input-bar-h, 6rem) - 5rem));
      /* 卡片的 margin-bottom 移到這裡當 padding：間距若留在卡片上，展開時會連同卡片一起被
         內捲，展開/收合鈕會被推走（鈕必須永遠點得到，見下方 .record-task-toggle）。 */
      padding-bottom: 2.5rem;
    }
    /* h1 與展開鈕不縮、不捲；只有卡片可縮並在展開後內捲 */
    .record-header h1, .record-task-toggle { flex-shrink: 0; }
    .record-header .info-card {
      margin-bottom: 0; min-height: 0;
      overflow-y: auto; overscroll-behavior: contain;
    }
    /* 收合態：兩行截斷。line-clamp 需要 -webkit-box + overflow:hidden 同時成立，
       標準 line-clamp 並寫給日後不需要前綴的引擎。 */
    #chosen-label {
      display: -webkit-box; -webkit-box-orient: vertical;
      -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
    }
    .record-header.expanded #chosen-label { display: block; overflow: visible; }
    /* 鈕是 .record-header 的直接子元素（不在卡片內），卡片內捲時位置不動 */
    .record-task-toggle { align-self: flex-start; margin-top: 0.75rem; }
    .record-task-toggle[hidden] { display: none; }
    /* Step：Question→Answer 間距唯一管理者 */
    .step {
      display: flex; flex-direction: column; gap: 1.25rem; /* 20px */
    }
    /* AI＝純文字、無框無底、左對齊、全寬（像文件，利長文/清單/未來 inline 選項）；
       Question/Answer 本身不帶 margin，間距全交給父層 .step 的 gap */
    .chat-assistant {
      color: var(--text); align-self: flex-start; max-width: 100%;
      font-size: var(--font-primary); line-height: 1.75; white-space: pre-line;
    }
    /* 每天第一句話的載入指示（見 enterEntryWithGeneratedOpening）。純 CSS，無 JS。
       實測那段等待中位數約 2 秒、60 次呼叫沒有一次超過 3 秒——所以要的是「還有東西要來」
       的訊號，不是一句話。 */
    /* 點的定位改成「錨在文字基線」，不再是「盒子的幾何中心」（04e，founder 走查回報偏高）。
       原本 inline-flex ＋ align-items:center ＋ min-height:1.6em 有兩個問題疊在一起：
       ① 1.6em 是 body 行高還是 1.6 時寫的。04e 把行高改成 1.75 之後，這個盒子（27.2px）比
          一行字的行盒（29.75px）矮 2.55px，文字一到整塊會往下長，看起來頓一下。
       ② 盒子的幾何中心 ≠ 文字的視覺中線。中文字面大約落在 baseline 上方 0.88em 到下方 0.12em，
          視覺中線在 baseline 上方約 0.38em；幾何中心比它高，所以點看起來浮在字的上方。
       改法：inline-block ＋ vertical-align 用長度值，直接把點的下緣抬離 baseline。
       0.22em（抬升）＋ 0.17em（半徑）＝ 點心落在 baseline 上方 0.39em，貼合中文視覺中線。
       ⚠️ 這個寫法不吃 line-height，日後再調行高也不會漂掉——別改回 flex 置中＋min-height。
       gap 改用相鄰兄弟的 margin-left（非 flex 容器沒有 gap），值 0.32em 不變。 */
    .chat-assistant.is-thinking .dot {
      display: inline-block;
      width: 0.34em; height: 0.34em; border-radius: 50%;
      vertical-align: 0.22em;
      background: var(--text); opacity: 0.25;
      animation: olitbit-thinking 1.4s ease-in-out infinite;
    }
    .chat-assistant.is-thinking .dot + .dot { margin-left: 0.32em; }
    .chat-assistant.is-thinking .dot:nth-child(2) { animation-delay: 0.18s; }
    .chat-assistant.is-thinking .dot:nth-child(3) { animation-delay: 0.36s; }
    @keyframes olitbit-thinking {
      0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
      30%           { opacity: 0.75; transform: translateY(-0.18em); }
    }
    /* 使用者在系統層關掉動畫時不硬演，改成靜態但仍看得出是「在等」。 */
    @media (prefers-reduced-motion: reduce) {
      .chat-assistant.is-thinking .dot { animation: none; opacity: 0.45; }
    }

    /* ── AI 文字的「低存在感分塊浮現」（04e，切塊邏輯在 30-ui.js）─────────────────
       整句已經到手之後才演，所以這裡跟「等待」無關——動畫藏在使用者「讀第一塊」的時間裡。
       判準：不會注意到動畫本身，但覺得文字出現得比較自然。走查覺得節奏不對，**只調這裡**。

       ⚠️ 只做 opacity，**沒有位移**。這不是品味選擇，是 CSS 限制：transform 對
          non-replaced inline 元素無效，而 .reveal-chunk 必須是 inline——改成 inline-block
          的話整塊字會變成不可斷行的單位，長句會在塊邊界硬換行、版面直接壞掉。
          （方向上也剛好對：位移原本就規劃「最多 1px、先試 0」。）

       ⚠️ 只動 opacity 還有第二個好處：文字一進 DOM 就佔滿最終高度，startStep／
          resolveStepQuestion 裡既有的 scrollToBottom() 算到的位置不變，不會有捲動跳動。
          **別改成 display/height/max-height 那類會影響版面的屬性。** */
    .reveal-chunk {
      display: inline;                       /* 見上，不可改成 inline-block */
      opacity: 0;
      animation: olitbit-reveal var(--reveal-fade) ease-out both;
      animation-delay: calc(var(--reveal-stagger) * var(--reveal-i, 0));
    }
    @keyframes olitbit-reveal { to { opacity: 1; } }

    /* 兩檔振幅：只差 stagger 與 fade 長度，機制與切塊演算法完全共用。 */
    /* A 檔｜每天第一句話：一天只出現一次，留 100% 的生命感 */
    .chat-assistant.reveal-opening      { --reveal-stagger: 70ms; --reveal-fade: 220ms; }
    /* B 檔｜入口對話（含時間問句）＋紀錄問句：一次 session 走 5 輪以上，留 60–70% */
    .chat-assistant.reveal-conversation { --reveal-stagger: 45ms; --reveal-fade: 160ms; }

    /* 關掉動態效果時完全不演。JS 端（prefersReducedMotion）本來就不會建 span，這裡是
       第二層防呆：萬一 span 已經建好之後使用者才改系統設定，也不會留下 opacity:0 的隱形字。 */
    @media (prefers-reduced-motion: reduce) {
      .reveal-chunk { animation: none; opacity: 1; }
    }
    /* 使用者＝灰底 pill、右對齊、收窄（保留框，AI 不留框；跟 AI 全寬形成對比，強化輪次區隔）*/
    .chat-user {
      background: var(--bubble-user); color: var(--text); align-self: flex-end;
      padding: 0.625rem 0.875rem; border-radius: var(--radius);
      max-width: 78%; font-size: var(--font-primary); line-height: 1.75;
    }
    /* Mobile：Step 間距降級（本檔第一個 @media query）*/
    @media (max-width: 480px) {
      #chat-history, #record-chat-history {
        --chat-step-gap: 3rem; /* 48px */
        padding-bottom: 2.25rem; /* 2.25rem + input-bar 0.75rem = 3rem */
      }
      #s-waiting { margin-top: 0; }
      .waiting-card { padding: 1.5rem; }
      .waiting-actions { justify-content: stretch; }
      .waiting-btn { width: 100%; }
      /* 紀錄頁 sticky 標題：桌機的呼吸間距在手機會把收合態標題頂到上限之上（就變成一進頁
         就出現矮捲動框）。這裡只收間距，不動字級與結構。 */
      .record-header { padding-top: 1.5rem; padding-bottom: 1.5rem; }
      #s-record h1 { margin-bottom: 1rem; }
      .record-header .info-card { padding: 0.875rem 1rem; }
    }
    /* 鍵盤開啟＝可視高驟縮，但 iOS 的 media query 高度不跟著鍵盤變，只能靠 JS 量 visualViewport
       後掛這個 class（見 syncChatShort）。再收一次間距，讓收合態標題在 350px 級可視高仍低於上限。 */
    html.chat-short .record-header { padding-top: 1rem; padding-bottom: 1rem; }
    html.chat-short #s-record h1 { margin-bottom: 0.75rem; }
    html.chat-short .record-header .info-card { padding: 0.75rem 1rem; }
    html.chat-short .record-task-toggle { margin-top: 0.5rem; }
    /* 收合態再收一行（2 → 1，2026-08-19 真機實測補）：04e 把行距／字級調大後，前面那條
       行距補償只還回約 8px、不夠，founder 真機開鍵盤仍會看到任務卡自己跑出捲軸——那正是
       .record-header 上限被咬到的症狀（見上方註解，那個上限是 393×420 真機爆版修出來的）。
       收一行還回約一整個行盒（~27px），比繼續刮像素可靠得多，而且字級不必縮回去。
       鍵盤收起來就回到兩行。⚠️ 配套在 syncChatShort()：行數一變，展開鈕的「有沒有被截斷」
       必須重量一次，否則會出現「文字被截到一行、鈕卻還藏著」的死路。 */
    html.chat-short #chosen-label { -webkit-line-clamp: 1; line-clamp: 1; }
    /* 04e 補償：全站行距 1.6 → 1.75 讓收合態標題長高約 13px（任務兩行 +8.3、task-sub +3.9、
       h1 +1），會吃掉 .record-header max-height 當初留的餘裕（那個上限是 393×420 真機爆版
       修出來的，見上方註解）。鍵盤開著正是高度最稀缺的時刻，也正是這個 chat-short 區塊存在
       的理由——所以只在這裡把行距收回 1.6，還回約 8px。字級不收（16→17 是這批的目的，
       收了等於白改）；桌機與一般手機直立仍是 1.75。 */
    html.chat-short .record-header .task-line { line-height: 1.6; }
    html.chat-short .record-header .task-sub  { line-height: 1.6; }
    html.chat-short #s-record h1 { line-height: 1.4; }

    /* ══ 想調手感就改這一組（founder 自己調用；改完存檔重整就看得到）══════════
       複製與「再試一次」那整組互動的時間與幅度**全部在這裡**，不必動任何 JS。
       ⚠️ 有三個值 JS 也會讀（30-ui.js 的 copyCssMs()／copyCssPx()）：
          --copy-anim 與 --copy-menu-delay（要知道離場動畫跑多久才能把選單 display:none）
          **必須留單位**（`s` 或 `ms`）；--copy-inset 是 JS 定位時用的（**留 `px`**）。
          三個都別寫成裸數字。其餘四個純 CSS，怎麼改都不會影響 JS。 */
    :root {
      --copy-fade: 0.3s;          /* 桌機小圖示的淡入淡出 */
      --copy-gap: 12px;           /* 桌機圖示離上方泡泡的距離（hover 感應區會自動跟著長）*/
      --copy-inset: 10px;         /* 桌機圖示往泡泡內側縮多少（0＝切齊邊框，太齊會死板）*/
      --copy-anim: 0.28s;         /* 手機：放大／遮罩／選單的動畫長度 */
      --copy-menu-delay: 0.18s;   /* 手機：選單比放大慢多久才出現（接在放大後頭）*/
      --copy-scale: 1.06;         /* 手機：被長按那則放大到幾倍 */
      --copy-dim: 0.32;           /* 手機：背景暗下來的程度，0＝不暗、1＝全黑 */
      --retry-gap: 1.375rem;      /* 22px：「沒有收到回應」離上一則的距離 */
    }

    /* ── 生選項失敗時的「沒有收到回應。　再試一次」（50-time.js showOptionsRetry）──
       founder 2026-08-25 走查定案：**不要紅色、不要驚嘆號、不要 error card、無框無底色。**
       這種偶發的連線／模型失敗不需要製造「出事了」的感覺——Olitbit 不是後台工具。
       收成**一行**（錯誤很小，不值得佔兩行），左側對齊 AI 對話文字，字級小一階。
       margin-top 是相對容器 gap 倒扣回來的：Step 間距 72/48px 對這一行太遠，
       它講的是「你剛剛那一步」的後續，要貼著上一則（22px）。 */
    .retry-step {
      gap: 0; align-items: flex-start;
      margin-top: calc(var(--retry-gap) - var(--chat-step-gap));
    }
    .retry-line {
      display: flex; align-items: baseline; flex-wrap: wrap;
      gap: 0.75rem;
      font-size: 0.875rem;   /* 14px，比 --font-primary(17px) 小一階 */
      line-height: 1.6;
    }
    .retry-msg { color: var(--text-muted); }
    /* 不能只是普通文字（會像文章裡的超連結）：稍深＋底線，讓它讀起來是可以按的東西。 */
    .retry-action {
      background: none; border: none; padding: 0; cursor: pointer;
      font: inherit; color: var(--text);
      text-decoration: underline; text-underline-offset: 3px;
    }
    .retry-action:hover { background: none; color: var(--text); }
    /* 「正在重試…」的狀態：按下去要看得出來按到了，但不要再長得像可以按。
       opacity:1 是為了蓋掉 10-base.css 全域的 button:disabled{opacity:.45}。 */
    .retry-action:disabled {
      color: var(--text-faint); text-decoration: none; cursor: default; opacity: 1;
    }

    /* ── 對話泡泡的複製鈕與長按選單（30-ui.js bindCopyAffordance）──────────────
       容器必須 position:relative：鈕靠 offsetTop／offsetLeft 定位，offsetParent 得是容器。
       ⚠️ .step **不得**設 position——一設 offsetParent 就變成 .step，座標整個錯位，
          而且錯得很安靜（鈕會貼在上一則旁邊，看起來只像「位置怪怪的」）。 */
    #chat-history, #record-chat-history { position: relative; }

    /* 熱區沿用送出鈕的 44×44／50%（visual-foundation ⑤ 的「形狀」白名單），但**不套**
       .send-icon-visual 那顆實心深色圓：送出是主要動作、複製是次要便利，套實心圓會讓
       它在對話裡比 AI 說的話還重。所以是裸 SVG ＋ --text-faint（最淡那一層灰）。
       水平位置由 JS 貼著**泡泡本身**算（不是貼容器邊，見 placeCopyBtn 檔頭）。 */
    .chat-copy {
      position: absolute; z-index: 2;
      width: 44px; height: 44px; border-radius: 50%;
      display: flex; align-items: center; justify-content: center;
      background: none; border: none; padding: 0; cursor: pointer;
      color: var(--text-faint);
      /* ⚠️ 用 opacity 不用 hidden：hidden 是 display:none，**淡入淡出根本跑不起來**
         （founder 2026-08-25 走查）。看不見時 pointer-events:none 讓它完全不擋路。 */
      opacity: 0; pointer-events: none;
      transition: opacity var(--copy-fade) ease;
      /* 12px ＝ 圖示在 44px 熱區裡的上內距（(44-20)/2）。倒扣掉它，--copy-gap 講的才是
         「看得見的圖示離泡泡多遠」，而不是「透明熱區的邊離泡泡多遠」。 */
      margin-top: calc(var(--copy-gap) - 12px);
    }
    .chat-copy.is-visible { opacity: 1; pointer-events: auto; }
    /* 補 background:none：否則全域的 button:hover 深底會洩進來變一坨黑（同 .link-btn 那個坑）*/
    .chat-copy:hover { background: none; color: var(--text-muted); }
    .chat-copy.is-copied { color: var(--text-muted); }
    .chat-copy svg {
      width: 20px; height: 20px; fill: none; stroke: currentColor;
      stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
    }

    /* 手機：選了自訂長按選單就得關掉系統原生的長按選字（兩個手勢會打架）。
       ⚠️ 這是 founder 2026-08-25 知情後的裁定，不是疏漏。只在 pointer:coarse 生效——
          桌機的「選一段字 Cmd+C」那條路**不能斷**。
       ⚠️ 射程**只有 .chat-user**（走查第三輪：AI 那側不給複製，見 30-ui.js COPY_BUBBLE_SEL）。
          AI 那側因此**留著系統原生的長按選字**——那裡沒有自訂選單要跟它搶，關掉只會平白
          讓使用者選不了 AI 的字。兩邊要一起改：這裡放寬而 JS 沒放寬，等於白白關掉原生選字。 */
    @media (pointer: coarse) {
      #chat-history .chat-user, #record-chat-history .chat-user {
        -webkit-touch-callout: none;
        -webkit-user-select: none;
        user-select: none;
      }
      /* 手機沒有真的 hover：藏起來，免得瀏覽器模擬的 mouseover 讓它閃一下 */
      .chat-copy { display: none; }
    }

    /* ── 長按時的三層：遮罩 40 ＜ 選單 45 ＜ 被長按的那一則 50 ──────────────────
       ⚠️ 這三個 z-index 只在**同一個 stacking context** 內才排得出來。
          `#s-input.active`／`#s-record.active` 有 `transform`（30-layout.css），
          那會建立 stacking context ＋ 成為 fixed 子孫的包含塊——所以遮罩與選單在長按當下
          由 JS 搬進那一層（30-ui.js showCopyMenu）。留在 body 層的話泡泡永遠疊不上去，
          「背景暗下來、只有那一則維持亮度」就做不出來。 */
    .copy-backdrop {
      position: fixed; inset: 0; z-index: 40;
      /* 墨色的透明版，不是純黑——純黑在暖紙底上會發灰、看起來像沒對準 */
      background: rgba(44, 40, 37, var(--copy-dim));
      opacity: 0; transition: opacity var(--copy-anim) ease;
    }
    .copy-backdrop[hidden] { display: none; }
    .copy-backdrop.is-open { opacity: 1; }

    /* ⚠️ transition 掛在**基礎選擇器**上，不能只寫在 .is-copy-focus 裡：
       只寫在後者的話，class 一移除連 transition 都跟著消失，**收起來會是瞬間彈回**。 */
    #chat-history .chat-assistant, #chat-history .chat-user,
    #record-chat-history .chat-assistant, #record-chat-history .chat-user {
      transition: transform var(--copy-anim) ease;
    }
    /* 被長按的那一則：抬到遮罩之上、維持原本亮度，並放大。 */
    .chat-assistant.is-copy-focus, .chat-user.is-copy-focus {
      position: relative; z-index: 50;
      transform: scale(var(--copy-scale));
    }
    /* AI 那則沒有底色，抬起來會直接透出後面的遮罩、看不出「只有它是亮的」。
       補一層紙色底，並用等量負 margin 抵銷 padding，維持原本的行距與位置。 */
    .chat-assistant.is-copy-focus {
      background: var(--bg); border-radius: var(--radius);
      padding: 0.25rem 0.5rem; margin: -0.25rem -0.5rem;
    }

    /* 選單：圖示＋文字、比原本小一號，位置由 JS 放在訊息**下方**（不擋到文字）。
       起點是 translateY(-10px)＋縮小，而泡泡的 z-index 比它高——所以看起來是從訊息
       底下往下冒出來。⚠️ **app 零 box-shadow**（⑤ 的射程只有 app，行銷頁不在內）：
       照 visual-foundation〈主體要界線，引用要底色〉給框線。 */
    .copy-menu {
      position: fixed; z-index: 45;
      background: var(--bg); border: 1px solid var(--border-input);
      border-radius: var(--radius); padding: 0.1875rem;
      opacity: 0; transform: translateY(-10px) scale(0.96);
      transform-origin: top center;
      transition: opacity var(--copy-anim) ease, transform var(--copy-anim) ease;
    }
    .copy-menu[hidden] { display: none; }
    /* 延遲只加在**進場**（.is-open）：founder 要選單接在放大動畫後頭才冒出來。
       收起來時 class 一移除、延遲跟著消失，所以關是立刻開始的。 */
    .copy-menu.is-open {
      opacity: 1; transform: translateY(0) scale(1);
      transition-delay: var(--copy-menu-delay);
    }
    .copy-menu-item {
      display: flex; align-items: center; gap: 0.5rem;
      background: none; border: none; cursor: pointer;
      color: var(--text); font-size: 0.9375rem;
      padding: 0.4375rem 0.875rem; border-radius: var(--radius-sm); white-space: nowrap;
    }
    .copy-menu-item:hover { background: var(--bubble-user); }
    .copy-menu-item svg {
      width: 15px; height: 15px; fill: none; stroke: currentColor;
      stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
      color: var(--text-muted);
    }

    /* 動畫一律尊重系統設定（同本檔 .reveal-chunk 的既有做法）。 */
    @media (prefers-reduced-motion: reduce) {
      .copy-backdrop, .copy-menu, .chat-copy,
      #chat-history .chat-assistant, #chat-history .chat-user,
      #record-chat-history .chat-assistant, #record-chat-history .chat-user { transition: none; }
      .chat-assistant.is-copy-focus, .chat-user.is-copy-focus { transform: none; }
      .copy-menu.is-open { transform: none; transition-delay: 0s; }
    }
